AT Protocol Terminal Interface Explorer

use app ctx to handle navigation

+144 -48
+66 -18
at/client.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "fmt" 6 7 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 14 15 ) 15 16 16 - // response wrappers with identity for easier navigation of views 17 + type Record struct { 18 + Uri string 19 + Cid string 20 + Value *json.RawMessage 21 + } 22 + 23 + func (r *Record) Collection() string { 24 + uri, err := syntax.ParseATURI(r.Uri) 25 + if err != nil { 26 + return "" 27 + } 28 + return uri.Collection().String() 29 + } 30 + 31 + func NewRecordFromList(r *agnostic.RepoListRecords_Record) *Record { 32 + return &Record{ 33 + Uri: r.Uri, 34 + Cid: r.Cid, 35 + Value: r.Value, 36 + } 37 + } 38 + 39 + func NewRecordFromGet(r *agnostic.RepoGetRecord_Output) *Record { 40 + cid := "" 41 + if r.Cid != nil { 42 + cid = *r.Cid 43 + } 44 + return &Record{ 45 + Uri: r.Uri, 46 + Cid: cid, 47 + Value: r.Value, 48 + } 49 + } 17 50 18 51 type RepoWithIdentity struct { 19 52 Identity *identity.Identity ··· 22 55 23 56 type RecordsWithIdentity struct { 24 57 Identity *identity.Identity 25 - Records []*agnostic.RepoListRecords_Record 58 + Records []*Record 59 + } 60 + 61 + func (r *RecordsWithIdentity) Collection() string { 62 + if len(r.Records) == 0 { 63 + return "" 64 + } 65 + return r.Records[0].Collection() 26 66 } 27 67 28 68 type RecordWithIdentity struct { 29 69 Identity *identity.Identity 30 - Record *agnostic.RepoGetRecord_Output 70 + Record *Record 31 71 } 32 72 33 73 type Client struct { ··· 65 105 return idd, nil 66 106 } 67 107 68 - func (c *Client) withIdentifier(ctx context.Context, raw string) (*atclient.APIClient, error) { 108 + func (c *Client) withIdentifier(ctx context.Context, raw string) (*atclient.APIClient, *identity.Identity, error) { 69 109 idd, err := c.GetIdentity(ctx, raw) 70 110 if err != nil { 71 - return nil, fmt.Errorf("failed to lookup identifier: %w", err) 111 + return nil, nil, fmt.Errorf("failed to lookup identifier: %w", err) 72 112 } 73 - return atclient.NewAPIClient(idd.PDSEndpoint()), nil 113 + return atclient.NewAPIClient(idd.PDSEndpoint()), idd, nil 74 114 } 75 115 76 116 func (c *Client) GetRepo(ctx context.Context, repo string) (*RepoWithIdentity, error) { 77 - id, err := c.GetIdentity(ctx, repo) 78 - if err != nil { 79 - return nil, fmt.Errorf("failed to lookup identifier: %w", err) 80 - } 81 - 82 - client, err := c.withIdentifier(ctx, repo) 117 + client, id, err := c.withIdentifier(ctx, repo) 83 118 if err != nil { 84 119 return nil, fmt.Errorf("failed to get client with identifier: %w", err) 85 120 } ··· 102 137 }, nil 103 138 } 104 139 105 - func (c *Client) ListRecords(ctx context.Context, collection, repo string) ([]*agnostic.RepoListRecords_Record, error) { 140 + func (c *Client) ListRecords(ctx context.Context, collection, repo string) (*RecordsWithIdentity, error) { 106 141 log.WithFields(log.Fields{ 107 142 "collection": collection, 108 143 "repo": repo, 109 144 }).Info("list records") 110 145 111 - client, err := c.withIdentifier(ctx, repo) 146 + client, id, err := c.withIdentifier(ctx, repo) 112 147 if err != nil { 113 148 return nil, fmt.Errorf("failed to get client with identifier: %w", err) 114 149 } ··· 117 152 if err != nil { 118 153 return nil, fmt.Errorf("failed to list records: %w", err) 119 154 } 120 - return resp.Records, nil 155 + 156 + records := make([]*Record, len(resp.Records)) 157 + for i, r := range resp.Records { 158 + records[i] = NewRecordFromList(r) 159 + } 160 + 161 + return &RecordsWithIdentity{ 162 + Identity: id, 163 + Records: records, 164 + }, nil 121 165 } 122 166 123 - func (c *Client) GetRecord(ctx context.Context, collection, repo, rkey string) (*agnostic.RepoGetRecord_Output, error) { 167 + func (c *Client) GetRecord(ctx context.Context, collection, repo, rkey string) (*RecordWithIdentity, error) { 124 168 log.WithFields(log.Fields{ 125 169 "collection": collection, 126 170 "repo": repo, 127 171 "rkey": rkey, 128 172 }).Info("get record") 129 173 130 - client, err := c.withIdentifier(ctx, repo) 174 + client, id, err := c.withIdentifier(ctx, repo) 131 175 if err != nil { 132 176 return nil, fmt.Errorf("failed to get client with identifier: %w", err) 133 177 } ··· 136 180 if err != nil { 137 181 return nil, fmt.Errorf("failed to get record: %w", err) 138 182 } 139 - return resp, nil 183 + 184 + return &RecordWithIdentity{ 185 + Identity: id, 186 + Record: NewRecordFromGet(resp), 187 + }, nil 140 188 }
+51 -17
ui/app.go
··· 5 5 6 6 log "github.com/sirupsen/logrus" 7 7 8 - "github.com/bluesky-social/indigo/api/agnostic" 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/identity" 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/charmbracelet/bubbles/spinner" 12 12 tea "github.com/charmbracelet/bubbletea" 13 13 "github.com/treethought/goatie/at" 14 14 ) 15 + 16 + type AppContext struct { 17 + identity *identity.Identity 18 + repo *comatproto.RepoDescribeRepo_Output 19 + collection string 20 + record *at.Record 21 + } 15 22 16 23 type App struct { 17 24 client *at.Client 18 25 search *CommandPallete 19 - identity identity.Identity 20 26 repoView *RepoView 21 27 rlist *RecordsList 22 28 recordView *RecordView ··· 26 32 query string 27 33 spinner spinner.Model 28 34 loading bool 35 + actx *AppContext 29 36 } 30 37 31 38 func NewApp(query string) *App { ··· 43 50 active: search, 44 51 spinner: spin, 45 52 loading: false, 53 + actx: &AppContext{}, 46 54 } 47 55 } 48 56 ··· 53 61 return a.fetchRepo(id.String()) 54 62 } 55 63 if uri, err := syntax.ParseATURI(a.query); err == nil { 56 - 57 64 if uri.Collection() == "" { 58 65 return a.fetchRepo(uri.Authority().String()) 59 66 } ··· 82 89 return tea.Batch(cmds...) 83 90 } 84 91 92 + func (a *App) resetToSearch() tea.Cmd { 93 + a.actx.identity = nil 94 + a.actx.repo = nil 95 + a.actx.collection = "" 96 + a.actx.record = nil 97 + a.active = a.search 98 + a.loading = false 99 + return a.search.Init() 100 + } 101 + 85 102 func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 86 103 switch msg := msg.(type) { 87 104 // top level always handle ctrl-c ··· 100 117 case "esc": 101 118 switch a.active { 102 119 case a.repoView: 103 - a.active = a.search 104 - a.search.loading = false 105 - return a, a.search.Init() 120 + return a, a.resetToSearch() 106 121 case a.rlist: 122 + if a.actx.identity == nil { 123 + return a, a.resetToSearch() 124 + } 125 + if a.actx.repo != nil { 126 + a.active = a.repoView 127 + return a, a.repoView.Init() 128 + } 107 129 a.active = a.repoView 108 - return a, nil 130 + return a, a.fetchRepo(a.actx.identity.DID.String()) 109 131 case a.recordView: 132 + if a.actx.collection != "" { 133 + a.active = a.rlist 134 + return a, a.fetchRecords(a.actx.collection, a.actx.identity.DID.String()) 135 + } 110 136 a.active = a.rlist 111 137 return a, nil 112 138 } ··· 126 152 127 153 case repoLoadedMsg: 128 154 a.loading = false 155 + a.actx.identity = msg.repo.Identity 156 + a.actx.repo = msg.repo.Repo 157 + a.actx.collection = "" 158 + a.actx.record = nil 129 159 cmd := a.repoView.SetRepo(msg.repo) 130 160 a.repoView.SetSize(a.w, a.h) // Set size before switching view 131 161 a.active = a.repoView ··· 134 164 135 165 case selectCollectionMsg: 136 166 log.Printf("Collection selected: %s", msg.collection) 167 + a.actx.collection = msg.collection 137 168 return a, a.fetchRecords(msg.collection, a.repoView.repo.Handle) 138 169 139 170 case recordsLoadedMsg: 140 171 a.loading = false 141 - cmd := a.rlist.SetRecords(msg.records) 172 + a.actx.identity = msg.records.Identity 173 + a.actx.collection = msg.records.Collection() 174 + a.actx.record = nil 175 + cmd := a.rlist.SetRecords(msg.records.Records) 142 176 a.rlist.SetSize(a.w, a.h) // Set size before switching view 143 177 a.active = a.rlist 144 178 a.search.loading = false ··· 146 180 147 181 case recordSelectedMsg: 148 182 a.loading = false 149 - a.recordView.SetRecord(msg.record) 183 + a.actx.identity = msg.record.Identity 184 + a.actx.collection = msg.record.Record.Collection() 185 + a.actx.record = msg.record.Record 186 + a.recordView.SetRecord(msg.record.Record) 150 187 a.recordView.SetSize(a.w, a.h) // Set size before switching view 151 188 a.active = a.recordView 152 189 return a, nil ··· 193 230 log.WithFields(log.Fields{ 194 231 "repo": repo, 195 232 "collection": collection, 196 - "numRecords": len(recs), 233 + "numRecords": len(recs.Records), 197 234 }).Info("Records loaded") 198 235 return recordsLoadedMsg{records: recs} 199 236 } ··· 212 249 "rkey": rkey, 213 250 }).Info("Record loaded") 214 251 return recordSelectedMsg{ 215 - record: &agnostic.RepoListRecords_Record{ 216 - Uri: rec.Uri, 217 - Value: rec.Value, 218 - }} 252 + record: rec, 253 + } 219 254 } 220 255 } 221 256 ··· 240 275 } 241 276 242 277 type recordsLoadedMsg struct { 243 - records []*agnostic.RepoListRecords_Record 278 + records *at.RecordsWithIdentity 244 279 } 245 280 246 281 type recordSelectedMsg struct { 247 - record *agnostic.RepoListRecords_Record 282 + record *at.RecordWithIdentity 248 283 } 249 284 250 285 type repoErrorMsg struct { 251 286 err error 252 287 } 253 -
+23 -10
ui/collection.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + log "github.com/sirupsen/logrus" 5 6 "strings" 6 7 7 - "github.com/bluesky-social/indigo/api/agnostic" 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 "github.com/charmbracelet/bubbles/list" 10 10 tea "github.com/charmbracelet/bubbletea" 11 11 "github.com/charmbracelet/lipgloss" 12 + "github.com/treethought/goatie/at" 12 13 ) 13 14 14 15 type RecordsList struct { 15 - rlist list.Model 16 - preview *RecordView 17 - header string 18 - w, h int 16 + rlist list.Model 17 + preview *RecordView 18 + header string 19 + w, h int 20 + collection string 19 21 } 20 22 21 23 type RecordListItem struct { 22 - r *agnostic.RepoListRecords_Record 24 + r *at.Record 23 25 parsed syntax.ATURI 24 26 } 25 27 26 - func NewRecordListItem(r *agnostic.RepoListRecords_Record) RecordListItem { 28 + func NewRecordListItem(r *at.Record) RecordListItem { 27 29 uri, _ := syntax.ParseATURI(r.Uri) 28 30 return RecordListItem{ 29 31 r: r, ··· 49 51 return s[:half] + "..." + s[len(s)-half:] 50 52 } 51 53 52 - func NewRecordsList(records []*agnostic.RepoListRecords_Record) *RecordsList { 54 + func NewRecordsList(records []*at.Record) *RecordsList { 53 55 del := list.DefaultDelegate{ 54 56 ShowDescription: true, 55 57 Styles: list.NewDefaultItemStyles(), ··· 68 70 return rl 69 71 } 70 72 71 - func (rl *RecordsList) SetRecords(records []*agnostic.RepoListRecords_Record) tea.Cmd { 73 + func (rl *RecordsList) SetRecords(records []*at.Record) tea.Cmd { 74 + if records == nil { 75 + log.Error("SetRecords called with nil") 76 + return nil 77 + } 72 78 rl.preview.SetRecord(nil) 73 79 rl.rlist.SetItems(nil) 74 80 items := make([]list.Item, len(records)) ··· 77 83 items[i] = list.Item(ci) 78 84 } 79 85 cmd := rl.rlist.SetItems(items) 86 + if len(items) > 0 { 87 + rl.preview.SetRecord(items[0].(RecordListItem).r) 88 + } 80 89 rl.header = rl.buildHeader() 81 90 return cmd 82 91 } ··· 131 140 case "enter": 132 141 if item, ok := rl.rlist.SelectedItem().(RecordListItem); ok { 133 142 return rl, func() tea.Msg { 134 - return recordSelectedMsg{record: item.r} 143 + return recordSelectedMsg{ 144 + record: &at.RecordWithIdentity{ 145 + Record: item.r, 146 + }, 147 + } 135 148 } 136 149 } 137 150 }
+4 -3
ui/record.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 7 - "github.com/bluesky-social/indigo/api/agnostic" 8 7 "github.com/bluesky-social/indigo/atproto/syntax" 9 8 "github.com/charmbracelet/bubbles/viewport" 10 9 tea "github.com/charmbracelet/bubbletea" 11 10 "github.com/charmbracelet/lipgloss" 11 + "github.com/treethought/goatie/at" 12 12 ) 13 13 14 14 type RecordView struct { 15 - record *agnostic.RepoListRecords_Record 15 + record *at.Record 16 16 vp viewport.Model 17 17 header string 18 18 preview bool ··· 46 46 return headerStyle.Render(header) 47 47 } 48 48 49 - func (rv *RecordView) SetRecord(record *agnostic.RepoListRecords_Record) { 49 + func (rv *RecordView) SetRecord(record *at.Record) { 50 50 rv.record = record 51 51 if rv.record == nil || rv.record.Value == nil { 52 52 rv.vp.SetContent("") 53 + rv.header = "" 53 54 return 54 55 } 55 56 data, err := json.MarshalIndent(rv.record.Value, "", " ")