AT Protocol Terminal Interface Explorer
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}