AT Protocol Terminal Interface Explorer
at main 382 lines 9.5 kB view raw
1package ui 2 3import ( 4 "context" 5 "log/slog" 6 7 comatproto "github.com/bluesky-social/indigo/api/atproto" 8 "github.com/bluesky-social/indigo/atproto/identity" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/charmbracelet/bubbles/spinner" 11 tea "github.com/charmbracelet/bubbletea" 12 "github.com/charmbracelet/lipgloss" 13 "github.com/treethought/attie/at" 14) 15 16type AppContext struct { 17 identity *identity.Identity 18 repo *comatproto.RepoDescribeRepo_Output 19 collection string 20 record *at.Record 21} 22 23type App struct { 24 client *at.Client 25 search *CommandPallete 26 repoView *RepoView 27 rlist *RecordsList 28 recordView *RecordView 29 jetEventView *JetStreamEventView 30 active tea.Model 31 err string 32 w, h int 33 query string 34 spinner spinner.Model 35 loading bool 36 actx *AppContext 37 38 jetstream *JetStreamView 39 jetSreamActive bool 40 41 // TODO better nav handling 42 // this currently only used for going back from jetstreamevent 43 lastView tea.Model 44} 45 46func NewApp(query string) *App { 47 search := &CommandPallete{} 48 repoView := NewRepoView() 49 spin := spinner.New() 50 spin.Spinner = spinner.Dot 51 52 jc := at.NewJetstreamClient() 53 jv := NewJetStreamView(jc) 54 return &App{ 55 query: query, 56 client: at.NewClient(""), 57 search: search, 58 repoView: repoView, 59 rlist: NewRecordsList(nil), 60 recordView: NewRecordView(false), 61 jetEventView: NewJetEventView(false), 62 active: search, 63 spinner: spin, 64 loading: false, 65 actx: &AppContext{}, 66 jetstream: jv, 67 } 68} 69 70func (a *App) Init() tea.Cmd { 71 a.loading = true 72 if id, err := syntax.ParseAtIdentifier(a.query); err == nil { 73 slog.Info("Starting with query", "id", id.String()) 74 return a.fetchRepo(id.String()) 75 } 76 if uri, err := syntax.ParseATURI(a.query); err == nil { 77 if uri.Collection() == "" { 78 return a.fetchRepo(uri.Authority().String()) 79 } 80 if uri.RecordKey().String() == "" { 81 id := uri.Authority().Handle().String() 82 if uri.Authority().IsDID() { 83 id = uri.Authority().DID().String() 84 } 85 return a.fetchRecords(uri.Collection().String(), id) 86 } 87 88 slog.Info("Starting with query", "uri", uri.String()) 89 return a.fetchRecord(uri.Collection().String(), uri.Authority().String(), uri.RecordKey().String()) 90 } 91 92 a.loading = false 93 return a.active.Init() 94} 95 96const footerHeight = 1 97 98func (a *App) resizeChildren() tea.Cmd { 99 cmds := []tea.Cmd{} 100 h := a.h - footerHeight 101 a.search.SetSize(a.w, h) 102 a.repoView.SetSize(a.w, h) 103 a.rlist.SetSize(a.w, h) 104 a.recordView.SetSize(a.w, h) 105 a.jetstream.SetSize(a.w, h) 106 a.jetEventView.SetSize(a.w, h) 107 return tea.Batch(cmds...) 108} 109 110func (a *App) resetToSearch() tea.Cmd { 111 a.actx.identity = nil 112 a.actx.repo = nil 113 a.actx.collection = "" 114 a.actx.record = nil 115 a.active = a.search 116 a.loading = false 117 return a.search.Init() 118} 119 120func (a *App) setJetStreamActive(active bool) tea.Cmd { 121 if active { 122 a.jetEventView.SetEvent(nil) 123 if a.active != a.jetEventView { 124 a.lastView = a.active 125 } 126 a.jetSreamActive = true 127 a.jetstream.SetSize(a.w, a.h) 128 if a.jetstream.Running() { 129 // pause but keep view and items visisble 130 return a.jetstream.Stop() 131 } 132 133 cxs := []string{} 134 dids := []string{} 135 if a.actx.collection != "" { 136 cxs = append(cxs, a.actx.collection) 137 } 138 if a.actx.identity != nil { 139 dids = append(dids, a.actx.identity.DID.String()) 140 } 141 return a.jetstream.Start(cxs, dids, nil) 142 } 143 144 a.jetSreamActive = false 145 // clear event view 146 a.jetEventView.SetEvent(nil) 147 cmds := []tea.Cmd{ 148 a.jetstream.Stop(), 149 a.jetstream.Clear(), 150 } 151 if a.lastView != nil { 152 a.active = a.lastView 153 } else { 154 cmds = append(cmds, a.resetToSearch()) 155 } 156 return tea.Sequence( 157 cmds..., 158 ) 159} 160 161func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 162 switch msg := msg.(type) { 163 // top level always handle ctrl-c 164 case tea.WindowSizeMsg: 165 a.w = msg.Width 166 a.h = msg.Height 167 return a, a.resizeChildren() 168 case tea.KeyMsg: 169 switch msg.String() { 170 case "ctrl+c", "q": 171 return a, tea.Quit 172 case "ctrl+k": 173 // keep jetstream active, and stop on search submit 174 a.jetSreamActive = false 175 a.active = a.search 176 a.search.loading = false 177 return a, a.search.Init() 178 case "ctrl+j": 179 return a, a.setJetStreamActive(true) 180 case "esc": 181 if a.jetSreamActive { 182 return a, a.setJetStreamActive(false) 183 } 184 switch a.active { 185 case a.repoView: 186 return a, a.resetToSearch() 187 case a.rlist: 188 if a.actx.identity == nil { 189 return a, a.resetToSearch() 190 } 191 if a.actx.repo != nil { 192 a.active = a.repoView 193 return a, a.repoView.Init() 194 } 195 a.active = a.repoView 196 return a, a.fetchRepo(a.actx.identity.DID.String()) 197 case a.recordView: 198 if a.actx.identity == nil { 199 return a, a.resetToSearch() 200 } 201 if a.actx.collection != "" { 202 a.active = a.rlist 203 return a, a.fetchRecords(a.actx.collection, a.actx.identity.DID.String()) 204 } 205 a.active = a.rlist 206 return a, nil 207 case a.jetEventView: 208 return a, a.setJetStreamActive(true) 209 } 210 } 211 212 case searchSubmitMsg: 213 // parse for handle/DID or a record URI 214 id, err := syntax.ParseAtIdentifier(msg.identifier.String()) 215 if err != nil { 216 slog.Error("Failed to parse identifier, should have caught during submission", "error", err) 217 return a, nil 218 } 219 if id.IsDID() || id.IsHandle() { 220 return a, 221 tea.Sequence( 222 a.setJetStreamActive(false), 223 a.fetchRepo(id.String()), 224 ) 225 } 226 227 case repoLoadedMsg: 228 a.loading = false 229 a.actx.identity = msg.repo.Identity 230 a.actx.repo = msg.repo.Repo 231 a.actx.collection = "" 232 a.actx.record = nil 233 cmd := a.repoView.SetRepo(msg.repo) 234 a.repoView.SetSize(a.w, a.h-footerHeight) // Set size before switching view 235 a.active = a.repoView 236 a.search.loading = false 237 return a, cmd 238 239 case selectCollectionMsg: 240 slog.Info("Collection selected", "collection", msg.collection) 241 a.actx.collection = msg.collection 242 return a, a.fetchRecords(msg.collection, a.repoView.repo.Handle) 243 244 case recordsLoadedMsg: 245 a.loading = false 246 a.actx.identity = msg.records.Identity 247 a.actx.collection = msg.records.Collection() 248 a.actx.record = nil 249 cmd := a.rlist.SetRecords(msg.records.Records) 250 a.rlist.SetSize(a.w, a.h-footerHeight) // Set size before switching view 251 a.active = a.rlist 252 a.search.loading = false 253 return a, cmd 254 255 case recordSelectedMsg: 256 a.loading = false 257 a.actx.identity = msg.record.Identity 258 a.actx.collection = msg.record.Record.Collection() 259 a.actx.record = msg.record.Record 260 a.recordView.SetRecord(msg.record.Record) 261 a.recordView.SetSize(a.w, a.h-footerHeight) // Set size before switching view 262 a.active = a.recordView 263 return a, nil 264 265 case jetEventSelectedMsg: 266 a.jetEventView.SetEvent(msg.evt) 267 a.jetEventView.SetSize(a.w, a.h-footerHeight) 268 a.active = a.jetEventView 269 a.jetSreamActive = false 270 return a, nil 271 272 case repoErrorMsg: 273 a.search.err = msg.err.Error() 274 a.search.loading = false 275 return a, nil 276 } 277 278 if a.jetSreamActive { 279 _, cmd := a.jetstream.Update(msg) 280 return a, cmd 281 } 282 283 var cmds []tea.Cmd 284 if a.loading { 285 sp, scmd := a.spinner.Update(msg) 286 a.spinner = sp 287 cmds = append(cmds, scmd) 288 } 289 290 var ac tea.Cmd 291 a.active, ac = a.active.Update(msg) 292 cmds = append(cmds, ac) 293 return a, tea.Batch(cmds...) 294} 295 296func (a *App) fetchRepo(repoId string) tea.Cmd { 297 return func() tea.Msg { 298 slog.Info("Fetching repo", "repoId", repoId) 299 resp, err := a.client.GetRepo(context.Background(), repoId) 300 if err != nil { 301 slog.Error("Failed to get repo", "error", err) 302 return repoErrorMsg{err: err} 303 } 304 slog.Info("Repo loaded", "repo", resp.Repo.Handle) 305 return repoLoadedMsg{repo: resp} 306 } 307} 308 309func (a *App) fetchRecords(collection, repo string) tea.Cmd { 310 return func() tea.Msg { 311 recs, err := a.client.ListRecords(context.Background(), collection, repo) 312 if err != nil { 313 slog.Error("Failed to list records", "error", err) 314 return repoErrorMsg{err: err} 315 } 316 slog.Info("Records loaded", "repo", repo, "collection", collection, "numRecords", len(recs.Records)) 317 return recordsLoadedMsg{records: recs} 318 } 319} 320 321func (a *App) fetchRecord(collection, repo, rkey string) tea.Cmd { 322 return func() tea.Msg { 323 rec, err := a.client.GetRecord(context.Background(), collection, repo, rkey) 324 if err != nil { 325 slog.Error("Failed to get record", "error", err) 326 return repoErrorMsg{err: err} 327 } 328 slog.Info("Record loaded", "repo", repo, "collection", collection, "rkey", rkey) 329 return recordSelectedMsg{ 330 record: rec, 331 } 332 } 333} 334 335func (a *App) footer() string { 336 key := func(k string) string { 337 return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")).Render(k) 338 } 339 sep := dimStyle.Render(" · ") 340 content := key("esc") + dimStyle.Render(" back") + 341 sep + key("ctrl+k") + dimStyle.Render(" search") + 342 sep + key("ctrl+j") + dimStyle.Render(" jetstream") 343 return lipgloss.NewStyle().Width(a.w).Align(lipgloss.Right).Render(content) 344} 345 346func (a *App) View() string { 347 if a.loading { 348 return "Loading... " + a.spinner.View() 349 } 350 var body string 351 if a.jetSreamActive { 352 body = a.jetstream.View() 353 } else { 354 body = a.active.View() 355 } 356 return lipgloss.JoinVertical(lipgloss.Left, body, a.footer()) 357} 358 359// Message types 360type searchSubmitMsg struct { 361 identifier syntax.AtIdentifier 362} 363 364type repoLoadedMsg struct { 365 repo *at.RepoWithIdentity 366} 367 368type selectCollectionMsg struct { 369 collection string 370} 371 372type recordsLoadedMsg struct { 373 records *at.RecordsWithIdentity 374} 375 376type recordSelectedMsg struct { 377 record *at.RecordWithIdentity 378} 379 380type repoErrorMsg struct { 381 err error 382}