AT Protocol Terminal Interface Explorer
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor event/record view and better jetstream nav

+292 -89
+1 -1
at/jetsream.go
··· 67 67 case c.out <- ev: 68 68 return nil 69 69 default: 70 - slog.Warn("deopped event", "did", ev.Did, "kind", ev.Kind) 70 + slog.Warn("dropped event", "did", ev.Did, "kind", ev.Kind) 71 71 } 72 72 return nil 73 73 }
+103 -42
ui/app.go
··· 20 20 } 21 21 22 22 type App struct { 23 - client *at.Client 24 - search *CommandPallete 25 - repoView *RepoView 26 - rlist *RecordsList 27 - recordView *RecordView 28 - active tea.Model 29 - err string 30 - w, h int 31 - query string 32 - spinner spinner.Model 33 - loading bool 34 - actx *AppContext 23 + client *at.Client 24 + search *CommandPallete 25 + repoView *RepoView 26 + rlist *RecordsList 27 + recordView *RecordView 28 + jetEventView *JetStreamEventView 29 + active tea.Model 30 + err string 31 + w, h int 32 + query string 33 + spinner spinner.Model 34 + loading bool 35 + actx *AppContext 36 + 37 + jetstream *JetStreamView 38 + jetSreamActive bool 35 39 36 - jetstream *JetStreamView 40 + // TODO better nav handling 41 + // this currently only used for going back from jetstreamevent 42 + lastView tea.Model 37 43 } 38 44 39 45 func NewApp(query string) *App { ··· 45 51 jc := at.NewJetstreamClient() 46 52 jv := NewJetStreamView(jc) 47 53 return &App{ 48 - query: query, 49 - client: at.NewClient(""), 50 - search: search, 51 - repoView: repoView, 52 - rlist: NewRecordsList(nil), 53 - recordView: NewRecordView(false), 54 - active: search, 55 - spinner: spin, 56 - loading: false, 57 - actx: &AppContext{}, 58 - jetstream: jv, 54 + query: query, 55 + client: at.NewClient(""), 56 + search: search, 57 + repoView: repoView, 58 + rlist: NewRecordsList(nil), 59 + recordView: NewRecordView(false), 60 + jetEventView: NewJetEventView(false), 61 + active: search, 62 + spinner: spin, 63 + loading: false, 64 + actx: &AppContext{}, 65 + jetstream: jv, 59 66 } 60 67 } 61 68 ··· 92 99 a.rlist.SetSize(a.w, a.h) 93 100 a.recordView.SetSize(a.w, a.h) 94 101 a.jetstream.SetSize(a.w, a.h) 102 + a.jetEventView.SetSize(a.w, a.h) 95 103 return tea.Batch(cmds...) 96 104 } 97 105 ··· 105 113 return a.search.Init() 106 114 } 107 115 116 + func (a *App) setJetStreamActive(active bool) tea.Cmd { 117 + if active { 118 + a.jetEventView.SetEvent(nil) 119 + a.lastView = a.active 120 + a.jetSreamActive = true 121 + a.jetstream.SetSize(a.w, a.h) 122 + if a.jetstream.Running() { 123 + // pause but keep view and items visisble 124 + return a.jetstream.Stop() 125 + } 126 + 127 + cxs := []string{} 128 + dids := []string{} 129 + if a.actx.collection != "" { 130 + cxs = append(cxs, a.actx.collection) 131 + } 132 + if a.actx.identity != nil { 133 + dids = append(dids, a.actx.identity.DID.String()) 134 + } 135 + return a.jetstream.Start(cxs, dids, nil) 136 + } 137 + 138 + a.jetSreamActive = false 139 + // clear event view 140 + a.jetEventView.SetEvent(nil) 141 + cmds := []tea.Cmd{ 142 + a.jetstream.Stop(), 143 + a.jetstream.Clear(), 144 + } 145 + if a.lastView != nil { 146 + a.active = a.lastView 147 + } else { 148 + cmds = append(cmds, a.resetToSearch()) 149 + } 150 + return tea.Sequence( 151 + cmds..., 152 + ) 153 + } 154 + 108 155 func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 109 156 switch msg := msg.(type) { 110 157 // top level always handle ctrl-c ··· 117 164 case "ctrl+c", "q": 118 165 return a, tea.Quit 119 166 case "ctrl+k": 167 + // keep jetstream active, and stop on search submit 168 + a.jetSreamActive = false 120 169 a.active = a.search 121 170 a.search.loading = false 122 171 return a, a.search.Init() 123 172 case "ctrl+j": 124 - a.active = a.jetstream 125 - a.jetstream.SetSize(a.w, a.h) 126 - if a.jetstream.Running() { 127 - return a, a.jetstream.Stop() 128 - } else { 129 - cxs := []string{} 130 - dids := []string{} 131 - if a.actx.collection != "" { 132 - cxs = append(cxs, a.actx.collection) 133 - } 134 - if a.actx.identity != nil { 135 - dids = append(dids, a.actx.identity.DID.String()) 136 - } 137 - return a, a.jetstream.Start(cxs, dids, nil) 173 + return a, a.setJetStreamActive(true) 174 + case "esc": 175 + if a.jetSreamActive { 176 + return a, a.setJetStreamActive(false) 138 177 } 139 - case "esc": 140 178 switch a.active { 141 179 case a.repoView: 142 180 return a, a.resetToSearch() ··· 151 189 a.active = a.repoView 152 190 return a, a.fetchRepo(a.actx.identity.DID.String()) 153 191 case a.recordView: 192 + if a.actx.identity == nil { 193 + return a, a.resetToSearch() 194 + } 154 195 if a.actx.collection != "" { 155 196 a.active = a.rlist 156 197 return a, a.fetchRecords(a.actx.collection, a.actx.identity.DID.String()) 157 198 } 158 199 a.active = a.rlist 159 200 return a, nil 160 - case a.jetstream: 161 - return a, a.jetstream.Stop() 201 + case a.jetEventView: 202 + return a, a.setJetStreamActive(true) 162 203 } 163 204 } 164 205 ··· 170 211 return a, nil 171 212 } 172 213 if id.IsDID() || id.IsHandle() { 173 - return a, a.fetchRepo(id.String()) 214 + return a, 215 + tea.Sequence( 216 + a.setJetStreamActive(false), 217 + a.fetchRepo(id.String()), 218 + ) 174 219 } 175 220 176 221 case repoLoadedMsg: ··· 209 254 a.recordView.SetRecord(msg.record.Record) 210 255 a.recordView.SetSize(a.w, a.h) // Set size before switching view 211 256 a.active = a.recordView 257 + return a, nil 258 + 259 + case jetEventSelectedMsg: 260 + a.jetEventView.SetEvent(msg.evt) 261 + a.jetEventView.SetSize(a.w, a.h) 262 + a.active = a.jetEventView 263 + a.jetSreamActive = false 212 264 return a, nil 213 265 214 266 case repoErrorMsg: ··· 217 269 return a, nil 218 270 } 219 271 272 + if a.jetSreamActive { 273 + _, cmd := a.jetstream.Update(msg) 274 + return a, cmd 275 + } 276 + 220 277 var cmds []tea.Cmd 221 278 if a.loading { 222 279 sp, scmd := a.spinner.Update(msg) 223 280 a.spinner = sp 224 281 cmds = append(cmds, scmd) 225 282 } 283 + 226 284 var ac tea.Cmd 227 285 a.active, ac = a.active.Update(msg) 228 286 cmds = append(cmds, ac) ··· 271 329 func (a *App) View() string { 272 330 if a.loading { 273 331 return "Loading... " + a.spinner.View() 332 + } 333 + if a.jetSreamActive { 334 + return a.jetstream.View() 274 335 } 275 336 return a.active.View() 276 337 }
+57
ui/content_view.go
··· 1 + package ui 2 + 3 + import ( 4 + "github.com/charmbracelet/bubbles/viewport" 5 + tea "github.com/charmbracelet/bubbletea" 6 + "github.com/charmbracelet/lipgloss" 7 + ) 8 + 9 + // ContentView wraps a scrollable viewport with a header line. 10 + type ContentView struct { 11 + vp viewport.Model 12 + preview bool 13 + header string 14 + empty bool 15 + } 16 + 17 + func newContentView(preview bool) ContentView { 18 + return ContentView{ 19 + vp: viewport.New(80, 20), 20 + preview: preview, 21 + empty: true, 22 + } 23 + } 24 + 25 + func (v *ContentView) Set(header, content string) { 26 + if header == "" && content == "" { 27 + v.empty = true 28 + v.header = "" 29 + v.vp.SetContent("") 30 + return 31 + } 32 + v.empty = false 33 + v.header = header 34 + v.vp.SetContent(content) 35 + } 36 + 37 + func (v *ContentView) SetSize(w, h int) { 38 + v.vp.Width = w 39 + v.vp.Height = h - lipgloss.Height(v.header) 40 + } 41 + 42 + func (v *ContentView) initVP() tea.Cmd { 43 + return v.vp.Init() 44 + } 45 + 46 + func (v *ContentView) updateVP(msg tea.Msg) tea.Cmd { 47 + var cmd tea.Cmd 48 + v.vp, cmd = v.vp.Update(msg) 49 + return cmd 50 + } 51 + 52 + func (v *ContentView) renderVP() string { 53 + if v.empty { 54 + return "" 55 + } 56 + return lipgloss.JoinVertical(lipgloss.Left, v.header, v.vp.View()) 57 + }
+67
ui/jetevent.go
··· 1 + package ui 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "time" 7 + 8 + "github.com/bluesky-social/jetstream/pkg/models" 9 + tea "github.com/charmbracelet/bubbletea" 10 + ) 11 + 12 + type jetEventSelectedMsg struct { 13 + evt *models.Event 14 + } 15 + 16 + type JetStreamEventView struct { 17 + ContentView 18 + evt *models.Event 19 + } 20 + 21 + func NewJetEventView(preview bool) *JetStreamEventView { 22 + return &JetStreamEventView{ContentView: newContentView(preview)} 23 + } 24 + 25 + func (v *JetStreamEventView) buildHeader() string { 26 + if v.evt == nil { 27 + return "" 28 + } 29 + if v.preview { 30 + return headerStyle.Render(fmt.Sprintf("%s %s %s", 31 + opStyle.Render(string(v.evt.Commit.Operation)), 32 + v.evt.Commit.Collection, 33 + dimStyle.Render(v.evt.Commit.RKey), 34 + )) 35 + } 36 + t := time.Unix(0, v.evt.TimeUS*int64(time.Microsecond)) 37 + return headerStyle.Render(fmt.Sprintf("%s %s/%s %s %s", 38 + didStyle.Render(v.evt.Did), 39 + v.evt.Commit.Collection, 40 + v.evt.Commit.RKey, 41 + opStyle.Render(string(v.evt.Commit.Operation)), 42 + dimStyle.Render(t.Format("2006-01-02 15:04:05")), 43 + )) 44 + } 45 + 46 + func (v *JetStreamEventView) SetEvent(evt *models.Event) { 47 + v.evt = evt 48 + if evt == nil { 49 + v.Set("", "") 50 + return 51 + } 52 + data, err := json.MarshalIndent(evt, "", " ") 53 + if err != nil { 54 + data = fmt.Appendf([]byte{}, "error marshaling event: %v", err) 55 + } 56 + v.Set(v.buildHeader(), string(data)) 57 + } 58 + 59 + func (v *JetStreamEventView) Init() tea.Cmd { 60 + return v.initVP() 61 + } 62 + func (v *JetStreamEventView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 63 + return v, v.updateVP(msg) 64 + } 65 + func (v *JetStreamEventView) View() string { 66 + return v.renderVP() 67 + }
+54 -17
ui/jetstream.go
··· 53 53 54 54 type JetStreamView struct { 55 55 list list.Model 56 + preview *JetStreamEventView 56 57 jc *at.JetStreamClient 57 58 ctx context.Context 58 59 cancel context.CancelFunc 59 60 session session 61 + w, h int 60 62 } 61 63 62 64 func NewJetStreamView(jc *at.JetStreamClient) *JetStreamView { ··· 71 73 l.SetShowStatusBar(false) 72 74 l.SetFilteringEnabled(false) 73 75 return &JetStreamView{ 74 - list: l, 75 - jc: jc, 76 + list: l, 77 + preview: NewJetEventView(true), 78 + jc: jc, 76 79 } 77 80 } 78 81 79 82 func (m *JetStreamView) Listen() tea.Cmd { 80 83 return func() tea.Msg { 81 - slog.Info("Listening for JetStream events...") 82 84 select { 83 85 case err := <-m.jc.Err(): 84 86 slog.Error("JetStream client error", "error", err) ··· 101 103 } 102 104 func (m *JetStreamView) Clear() tea.Cmd { 103 105 return func() tea.Msg { 104 - m.list.SetItems(nil) 105 - return nil 106 + m.session = session{} 107 + m.preview.SetEvent(nil) 108 + return m.list.SetItems(nil) 106 109 } 107 110 } 108 111 ··· 134 137 return nil 135 138 } 136 139 func (m *JetStreamView) SetSize(w, h int) { 137 - headerHeight := lipgloss.Height(m.header()) 138 - m.list.SetSize(w, h-headerHeight) 140 + m.w = w 141 + m.h = h 142 + hh := lipgloss.Height(m.header()) 143 + if m.ctx == nil { 144 + hh += 1 145 + } 146 + if w > 100 { 147 + m.list.SetSize(w/2, h-hh) 148 + m.preview.SetSize(w/2, h-hh) 149 + return 150 + } 151 + m.list.SetSize(w, h-hh) 152 + m.preview.SetSize(0, 0) 139 153 } 140 154 141 155 func (m *JetStreamView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ··· 151 165 m.Listen(), 152 166 ) 153 167 } 168 + switch msg := msg.(type) { 169 + case tea.KeyMsg: 170 + if msg.String() == "enter" { 171 + if item, ok := m.list.SelectedItem().(jetEventItem); ok { 172 + return m, func() tea.Msg { 173 + return jetEventSelectedMsg{evt: item.evt} 174 + } 175 + } 176 + } 177 + } 178 + 154 179 l, cmd := m.list.Update(msg) 155 180 m.list = l 181 + if item, ok := m.list.SelectedItem().(jetEventItem); ok { 182 + m.preview.SetEvent(item.evt) 183 + } 156 184 return m, cmd 157 185 } 158 186 ··· 175 203 } 176 204 lastCursor := dimStyle.Render("live") 177 205 if m.session.lastCursor != nil { 178 - t := time.Unix(0, *m.session.lastCursor*int64(time.Microsecond)) 179 - lastCursor = t.Format("2006-01-02 15:04:05") 206 + lastCursor = fmt.Sprintf("%d", *m.session.lastCursor) 180 207 } 181 208 182 209 title := jetstreamTitleStyle.Render("📡 JetStream Events") ··· 192 219 } 193 220 194 221 func (m *JetStreamView) View() string { 222 + hdr := m.header() 223 + status := "" 195 224 if m.ctx == nil { 196 - return lipgloss.JoinVertical(lipgloss.Left, 197 - jetstreamTitleStyle.Render("📡 JetStream Events"), 198 - dimStyle.Render("\n not connected · press ctrl+j to start"), 199 - ) 225 + status = dimStyle.Render(" not connected · press ctrl+j to start") 226 + } 227 + 228 + if m.w > 100 { 229 + left := lipgloss.JoinVertical(lipgloss.Left, m.list.View()) 230 + right := m.preview.View() 231 + body := lipgloss.JoinHorizontal(lipgloss.Top, left, right) 232 + if status != "" { 233 + return lipgloss.JoinVertical(lipgloss.Left, hdr, status, body) 234 + } 235 + return lipgloss.JoinVertical(lipgloss.Left, hdr, body) 236 + } 237 + 238 + if status != "" { 239 + return lipgloss.JoinVertical(lipgloss.Left, hdr, status, m.list.View()) 200 240 } 201 - return lipgloss.JoinVertical(lipgloss.Left, 202 - m.header(), 203 - m.list.View(), 204 - ) 241 + return lipgloss.JoinVertical(lipgloss.Left, hdr, m.list.View()) 205 242 }
+10 -29
ui/record.go
··· 5 5 "fmt" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 - "github.com/charmbracelet/bubbles/viewport" 9 8 tea "github.com/charmbracelet/bubbletea" 10 - "github.com/charmbracelet/lipgloss" 11 9 "github.com/treethought/attie/at" 12 10 ) 13 11 14 12 type RecordView struct { 15 - record *at.Record 16 - vp viewport.Model 17 - header string 18 - preview bool 13 + ContentView 14 + record *at.Record 19 15 } 20 16 21 17 func NewRecordView(preview bool) *RecordView { 22 - vp := viewport.New(80, 20) 23 - return &RecordView{ 24 - vp: vp, 25 - preview: preview, 26 - } 27 - } 28 - 29 - func (rv *RecordView) SetSize(w, h int) { 30 - rv.vp.Width = w 31 - rv.vp.Height = h - lipgloss.Height(rv.header) 18 + return &RecordView{ContentView: newContentView(preview)} 32 19 } 33 20 34 21 func (rv *RecordView) buildHeader() string { ··· 48 35 49 36 func (rv *RecordView) SetRecord(record *at.Record) { 50 37 rv.record = record 51 - if rv.record == nil || rv.record.Value == nil { 52 - rv.vp.SetContent("") 53 - rv.header = "" 38 + if record == nil || record.Value == nil { 39 + rv.Set("", "") 54 40 return 55 41 } 56 - data, err := json.MarshalIndent(rv.record.Value, "", " ") 42 + data, err := json.MarshalIndent(record.Value, "", " ") 57 43 if err != nil { 58 44 data = fmt.Appendf([]byte{}, "error marshaling record: %v", err) 59 45 } 60 - rv.vp.SetContent(string(data)) 61 - rv.header = rv.buildHeader() 46 + rv.Set(rv.buildHeader(), string(data)) 62 47 } 63 48 64 49 func (rv *RecordView) Init() tea.Cmd { 65 - return rv.vp.Init() 50 + return rv.initVP() 66 51 } 67 - 68 52 func (rv *RecordView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 69 - var cmd tea.Cmd 70 - rv.vp, cmd = rv.vp.Update(msg) 71 - return rv, cmd 53 + return rv, rv.updateVP(msg) 72 54 } 73 - 74 55 func (rv *RecordView) View() string { 75 - return lipgloss.JoinVertical(lipgloss.Left, rv.header, rv.vp.View()) 56 + return rv.renderVP() 76 57 }