AT Protocol Terminal Interface Explorer
5
fork

Configure Feed

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

add records list view

+298 -31
+26 -14
at/client.go
··· 20 20 21 21 func NewClient(service string) *Client { 22 22 dir := &identity.BaseDirectory{} 23 + cacheDir := identity.NewCacheDirectory(dir, 0, 0, 0, 0) 23 24 if service == "" { 24 25 service = "https://bsky.social" 25 26 } 26 27 client := atclient.NewAPIClient(service) 27 28 return &Client{ 28 - dir: dir, 29 + dir: cacheDir, 29 30 c: client, 30 31 } 31 32 } 32 33 33 - func (c *Client) withIdentifier(ctx context.Context, id syntax.AtIdentifier) (*atclient.APIClient, error) { 34 + func (c *Client) withIdentifier(ctx context.Context, raw string) (*atclient.APIClient, error) { 35 + id, err := syntax.ParseAtIdentifier(raw) 36 + if err != nil { 37 + return nil, fmt.Errorf("failed to parse repo identifier: %w", err) 38 + } 34 39 idd, err := c.dir.Lookup(ctx, id) 35 40 if err != nil { 36 41 return nil, fmt.Errorf("failed to lookup identifier: %w", err) ··· 47 52 "repo": repo, 48 53 }).Info("describe repo") 49 54 50 - ri, err := syntax.ParseAtIdentifier(repo) 51 - if err != nil { 52 - return nil, fmt.Errorf("failed to parse repo identifier: %w", err) 53 - } 54 - 55 - client, err := c.withIdentifier(ctx, ri) 55 + client, err := c.withIdentifier(ctx, repo) 56 56 if err != nil { 57 57 return nil, fmt.Errorf("failed to get client with identifier: %w", err) 58 58 } ··· 64 64 return nil, fmt.Errorf("failed to describe repo: %w", err) 65 65 } 66 66 return resp, nil 67 + } 67 68 69 + func (c *Client) ListRecords(ctx context.Context, collection, repo string) ([]*agnostic.RepoListRecords_Record, error) { 70 + log.WithFields(log.Fields{ 71 + "collection": collection, 72 + "repo": repo, 73 + }).Info("list records") 74 + 75 + client, err := c.withIdentifier(ctx, repo) 76 + if err != nil { 77 + return nil, fmt.Errorf("failed to get client with identifier: %w", err) 78 + } 79 + 80 + resp, err := agnostic.RepoListRecords(ctx, client, collection, "", 100, repo, false) 81 + if err != nil { 82 + return nil, fmt.Errorf("failed to list records: %w", err) 83 + } 84 + return resp.Records, nil 68 85 } 69 86 70 87 func (c *Client) GetRecord(ctx context.Context, collection, repo, rkey string) (*agnostic.RepoGetRecord_Output, error) { ··· 74 91 "rkey": rkey, 75 92 }).Info("get record") 76 93 77 - ri, err := syntax.ParseAtIdentifier(repo) 78 - if err != nil { 79 - return nil, fmt.Errorf("failed to parse repo identifier: %w", err) 80 - } 81 - 82 - client, err := c.withIdentifier(ctx, ri) 94 + client, err := c.withIdentifier(ctx, repo) 83 95 if err != nil { 84 96 return nil, fmt.Errorf("failed to get client with identifier: %w", err) 85 97 }
+61 -4
ui/app.go
··· 6 6 7 7 log "github.com/sirupsen/logrus" 8 8 9 + "github.com/bluesky-social/indigo/api/agnostic" 9 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 11 12 "github.com/charmbracelet/bubbles/spinner" ··· 18 19 client *at.Client 19 20 search *CommandPallete 20 21 repoView *RepoView 22 + rlist *RecordsList 21 23 active tea.Model 22 24 err string 25 + w, h int 23 26 } 24 27 25 28 func NewApp() *App { ··· 29 32 client: at.NewClient(""), 30 33 search: search, 31 34 repoView: repoView, 35 + rlist: NewRecordsList(nil), 32 36 active: search, 33 37 } 34 38 } ··· 37 41 return a.active.Init() 38 42 } 39 43 44 + func (a *App) resizeChildren() tea.Cmd { 45 + cmds := []tea.Cmd{} 46 + a.search.SetSize(a.w, a.h) 47 + a.repoView.SetSize(a.w, a.h) 48 + if a.rlist != nil { 49 + a.rlist.SetSize(a.w, a.h) 50 + } 51 + return tea.Batch(cmds...) 52 + } 53 + 54 + 40 55 func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 41 56 switch msg := msg.(type) { 42 57 // top level always handle ctrl-c 43 58 case tea.WindowSizeMsg: 44 - a.active, _ = a.active.Update(msg) 45 - return a, nil 59 + a.w = msg.Width 60 + a.h = msg.Height 61 + return a, a.resizeChildren() 46 62 case tea.KeyMsg: 47 63 switch msg.String() { 48 64 case "ctrl+c", "q": 49 65 return a, tea.Quit 50 66 case "esc": 51 - // Go back to search from repo view 52 - if a.active == a.repoView { 67 + switch a.active { 68 + case a.repoView: 53 69 a.active = a.search 54 70 a.search.loading = false 71 + return a, nil 72 + case a.rlist: 73 + a.active = a.repoView 55 74 return a, nil 56 75 } 57 76 } ··· 74 93 a.search.loading = false 75 94 return a, cmd 76 95 96 + case selectCollectionMsg: 97 + log.Printf("Collection selected: %s", msg.collection) 98 + return a, a.fetchRecords(msg.collection, a.repoView.repo.Handle) 99 + 100 + case recordsLoadedMsg: 101 + cmd := a.rlist.SetRecords(msg.records) 102 + a.active = a.rlist 103 + a.search.loading = false 104 + return a, cmd 105 + 77 106 case repoErrorMsg: 78 107 a.search.err = msg.err.Error() 79 108 a.search.loading = false ··· 99 128 } 100 129 } 101 130 131 + func (a *App) fetchRecords(collection, repo string) tea.Cmd { 132 + return func() tea.Msg { 133 + recs, err := a.client.ListRecords(context.Background(), collection, repo) 134 + if err != nil { 135 + log.Printf("Failed to list records: %s", err.Error()) 136 + return repoErrorMsg{err: err} 137 + } 138 + log.WithFields(log.Fields{ 139 + "repo": repo, 140 + "collection": collection, 141 + "numRecords": len(recs), 142 + }).Info("Records loaded") 143 + return recordsLoadedMsg{records: recs} 144 + } 145 + } 146 + 102 147 func (a *App) View() string { 103 148 return a.active.View() 104 149 } ··· 112 157 repo *comatproto.RepoDescribeRepo_Output 113 158 } 114 159 160 + type selectCollectionMsg struct { 161 + collection string 162 + } 163 + 164 + type recordsLoadedMsg struct { 165 + records []*agnostic.RepoListRecords_Record 166 + } 167 + 115 168 type repoErrorMsg struct { 116 169 err error 117 170 } ··· 130 183 c.spinner = spinner.New() 131 184 c.spinner.Spinner = spinner.Dot 132 185 return textinput.Blink 186 + } 187 + 188 + func (c *CommandPallete) SetSize(w, h int) { 189 + c.ti.Width = w - 2 133 190 } 134 191 135 192 func (c *CommandPallete) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+186
ui/records.go
··· 1 + package ui 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/api/agnostic" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/charmbracelet/bubbles/list" 11 + "github.com/charmbracelet/bubbles/viewport" 12 + tea "github.com/charmbracelet/bubbletea" 13 + "github.com/charmbracelet/lipgloss" 14 + ) 15 + 16 + type RecordView struct { 17 + record *agnostic.RepoListRecords_Record 18 + vp viewport.Model 19 + } 20 + 21 + func NewRecordView() *RecordView { 22 + vp := viewport.New(80, 20) 23 + return &RecordView{ 24 + vp: vp, 25 + } 26 + } 27 + 28 + func (rv *RecordView) SetSize(w, h int) { 29 + rv.vp.Width = w 30 + rv.vp.Height = h 31 + } 32 + 33 + func (rv *RecordView) SetRecord(record *agnostic.RepoListRecords_Record) { 34 + rv.record = record 35 + if rv.record == nil || rv.record.Value == nil { 36 + rv.vp.SetContent("") 37 + return 38 + } 39 + data, err := json.MarshalIndent(rv.record.Value, "", " ") 40 + if err != nil { 41 + data = fmt.Appendf([]byte{}, "error marshaling record: %v", err) 42 + } 43 + rv.vp.SetContent(string(data)) 44 + } 45 + 46 + func (rv *RecordView) Init() tea.Cmd { 47 + return nil 48 + } 49 + 50 + func (rv *RecordView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 51 + var cmd tea.Cmd 52 + rv.vp, cmd = rv.vp.Update(msg) 53 + return rv, cmd 54 + } 55 + 56 + func (rv *RecordView) View() string { 57 + return rv.vp.View() 58 + } 59 + 60 + type RecordsList struct { 61 + rlist list.Model 62 + preview RecordView 63 + header string 64 + w, h int 65 + } 66 + 67 + type RecordListItem struct { 68 + r *agnostic.RepoListRecords_Record 69 + parsed syntax.ATURI 70 + } 71 + 72 + func NewRecordListItem(r *agnostic.RepoListRecords_Record) RecordListItem { 73 + uri, _ := syntax.ParseATURI(r.Uri) 74 + return RecordListItem{ 75 + r: r, 76 + parsed: uri, 77 + } 78 + } 79 + 80 + func (r RecordListItem) FilterValue() string { 81 + return r.parsed.RecordKey().String() 82 + } 83 + func (r RecordListItem) Title() string { 84 + return r.parsed.RecordKey().String() 85 + } 86 + func (r RecordListItem) Description() string { 87 + return truncMiddle(r.r.Cid, 24) 88 + } 89 + 90 + func truncMiddle(s string, max int) string { 91 + if len(s) <= max { 92 + return s 93 + } 94 + half := max / 2 95 + return s[:half] + "..." + s[len(s)-half:] 96 + } 97 + 98 + func NewRecordsList(records []*agnostic.RepoListRecords_Record) *RecordsList { 99 + del := list.DefaultDelegate{ 100 + ShowDescription: true, 101 + Styles: list.NewDefaultItemStyles(), 102 + } 103 + del.SetHeight(2) 104 + 105 + l := list.New(nil, del, 80, 20) 106 + l.SetShowTitle(false) 107 + l.SetShowStatusBar(false) 108 + l.SetFilteringEnabled(true) 109 + rl := &RecordsList{ 110 + rlist: l, 111 + preview: RecordView{}, 112 + } 113 + rl.SetRecords(records) 114 + return rl 115 + } 116 + 117 + func (rl *RecordsList) SetRecords(records []*agnostic.RepoListRecords_Record) tea.Cmd { 118 + rl.preview.SetRecord(nil) 119 + rl.rlist.SetItems(nil) 120 + items := make([]list.Item, len(records)) 121 + for i, rec := range records { 122 + ci := NewRecordListItem(rec) 123 + items[i] = list.Item(ci) 124 + } 125 + cmd := rl.rlist.SetItems(items) 126 + rl.header = rl.buildHeader() 127 + return cmd 128 + } 129 + 130 + func (rl *RecordsList) buildHeader() string { 131 + // TODO pass collection into model and fetch in init 132 + // for now just use the first record's collection for header 133 + if len(rl.rlist.Items()) == 0 { 134 + return "No Records" 135 + } 136 + rec, ok := rl.rlist.Items()[0].(RecordListItem) 137 + if !ok { 138 + return "Records" 139 + } 140 + uri, err := syntax.ParseATURI(rec.r.Uri) 141 + if err != nil { 142 + return "Records" 143 + } 144 + s := strings.Builder{} 145 + s.WriteString(uri.Collection().String()) 146 + s.WriteString(" - ") 147 + s.WriteString(fmt.Sprintf("%d records", len(rl.rlist.Items()))) 148 + return lipgloss.NewStyle().Bold(true).Render(s.String()) 149 + } 150 + 151 + func (rl *RecordsList) SetSize(w, h int) { 152 + rl.w = w 153 + rl.h = h 154 + headerHeight := lipgloss.Height(rl.header) 155 + if rl.w > 100 { 156 + rl.rlist.SetSize(rl.w/2, rl.h-headerHeight) 157 + rl.preview.SetSize(rl.w/2, rl.h-headerHeight) 158 + return 159 + } 160 + rl.rlist.SetSize(rl.w, rl.h-headerHeight) 161 + rl.preview.SetSize(0, 0) 162 + } 163 + 164 + func (m *RecordsList) Init() tea.Cmd { 165 + return nil 166 + } 167 + 168 + func (rl *RecordsList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 169 + var cmd tea.Cmd 170 + rl.rlist, cmd = rl.rlist.Update(msg) 171 + if item, ok := rl.rlist.SelectedItem().(RecordListItem); ok { 172 + rl.preview.SetRecord(item.r) 173 + } 174 + return rl, cmd 175 + } 176 + 177 + func (rl *RecordsList) View() string { 178 + if rl.w > 100 { 179 + return lipgloss.JoinVertical(lipgloss.Left, rl.header, lipgloss.JoinHorizontal(lipgloss.Top, 180 + lipgloss.JoinVertical(lipgloss.Left, rl.header, rl.rlist.View()), 181 + rl.preview.View(), 182 + )) 183 + } 184 + 185 + return lipgloss.JoinVertical(lipgloss.Left, rl.header, rl.rlist.View()) 186 + }
+25 -13
ui/repo.go
··· 30 30 return c.Name 31 31 } 32 32 func (c CollectionListItem) Title() string { 33 - return collectionStyle.Render(c.Name) 33 + return c.Name 34 + // return collectionStyle.Render(c.Name) 34 35 } 35 36 func (c CollectionListItem) Description() string { 36 37 return "" ··· 51 52 l := list.New(items, del, 80, 20) 52 53 l.SetShowTitle(false) 53 54 l.SetShowStatusBar(false) 54 - l.SetFilteringEnabled(false) 55 + l.SetFilteringEnabled(true) 55 56 return &CollectionList{ 56 57 list: l, 57 58 } ··· 62 63 } 63 64 64 65 func (cl *CollectionList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 66 + 67 + if !cl.list.SettingFilter() { 68 + switch msg := msg.(type) { 69 + case tea.KeyMsg: 70 + switch msg.String() { 71 + case "enter": 72 + if item, ok := cl.list.SelectedItem().(CollectionListItem); ok { 73 + return cl, func() tea.Msg { 74 + return selectCollectionMsg{collection: item.Name} 75 + } 76 + } 77 + } 78 + } 79 + } 80 + 65 81 var cmd tea.Cmd 66 82 cl.list, cmd = cl.list.Update(msg) 67 83 return cl, cmd ··· 96 112 } 97 113 var s strings.Builder 98 114 99 - // Title 100 115 s.WriteString(headerStyle.Render("📦 Repository")) 101 116 s.WriteString("\n\n") 102 117 ··· 120 135 s.WriteString(dimStyle.Render(fmt.Sprintf("(%d)", len(r.repo.Collections)))) 121 136 s.WriteString("\n") 122 137 123 - return s.String() 138 + // add bottom border 139 + return lipgloss.NewStyle().BorderBottom(true).Render(s.String()) 140 + 124 141 } 125 142 126 143 func (r *RepoView) SetRepo(repo *comatproto.RepoDescribeRepo_Output) tea.Cmd { 127 144 r.repo = repo 128 145 r.header = r.buildHeader() 129 146 r.clist = NewCollectionList(repo.Collections) 130 - r.updateListSize() 131 147 return r.clist.Init() 132 148 } 133 149 ··· 136 152 } 137 153 138 154 func (r *RepoView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 139 - switch msg := msg.(type) { 140 - case tea.WindowSizeMsg: 141 - r.width = msg.Width 142 - r.height = msg.Height 143 - r.updateListSize() 144 - } 145 155 clist, cmd := r.clist.Update(msg) 146 156 r.clist = clist.(*CollectionList) 147 157 return r, cmd 148 158 } 149 159 150 160 // updateListSize calculates and sets the list size to fill remaining space 151 - func (r *RepoView) updateListSize() { 161 + func (r *RepoView) SetSize(w, h int) { 162 + r.width = w 163 + r.height = h 152 164 if r.clist == nil { 153 165 return 154 166 } ··· 158 170 // List gets all remaining space 159 171 listHeight := r.height - headerHeight - footerHeight 160 172 if listHeight < 5 { 161 - listHeight = 5 173 + listHeight = 5 162 174 } 163 175 164 176 r.clist.list.SetSize(r.width, listHeight)