gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs

commit 0d8c68c1cc2088aefbb213dd59fb2d8a2d2fd1dd
parent 097b12b2e59fb8bad32909ddd8a7d8c6091ba639
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 25 Aug 2026 22:29:11 -0700

Improve thread loading and folding

Diffstat:
Minternal/domain/domain.go | 86+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Minternal/domain/domain_test.go | 2+-
Minternal/tui/backend.go | 46++++++++++++++++++++++++++++++++++++----------
Ainternal/tui/backend_test.go | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/model.go | 144++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Minternal/tui/model_test.go | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/view.go | 25++++++++++++++++++++++---
7 files changed, 383 insertions(+), 70 deletions(-)

diff --git a/internal/domain/domain.go b/internal/domain/domain.go @@ -3,6 +3,7 @@ package domain import ( "regexp" + "sort" "strings" "time" "unicode" @@ -236,52 +237,71 @@ type ThreadItem struct { MoreCount int } -// BuildReplyThread orders replies depth-first. Orphans remain visible, while -// duplicate IDs and cycles cannot duplicate posts or recurse indefinitely. +// BuildReplyThread orders each set of siblings from oldest to newest and +// places a reply's descendants immediately after it. Orphans remain visible, +// and duplicate IDs and cycles cannot duplicate posts or recurse indefinitely. func BuildReplyThread(root textlog.Post, replies []textlog.Reply) []ThreadItem { - children := make(map[int][]textlog.Reply) - for _, reply := range replies { + type candidate struct { + reply textlog.Reply + index int + } + byParent := make(map[int][]candidate) + unique := make([]candidate, 0, len(replies)) + known := map[int]bool{root.ID: true} + for i, reply := range replies { + if known[reply.ID] { + continue + } + known[reply.ID] = true + item := candidate{reply: reply, index: i} + unique = append(unique, item) if reply.ParentID != nil { - children[*reply.ParentID] = append(children[*reply.ParentID], reply) + byParent[*reply.ParentID] = append(byParent[*reply.ParentID], item) } } - - thread := make([]ThreadItem, 0, len(replies)+1) - thread = append(thread, ThreadItem{Post: root}) - visited := map[int]bool{root.ID: true} - - var appendChildren func(parentID, depth int) - appendChildren = func(parentID, depth int) { - for _, reply := range children[parentID] { - if visited[reply.ID] { - continue - } - visited[reply.ID] = true - moreCount := 0 - if len(children[reply.ID]) == 0 { - moreCount = reply.ReplyCount - } - thread = append(thread, ThreadItem{Post: reply.Post, Depth: depth, MoreCount: moreCount}) - appendChildren(reply.ID, depth+1) + older := func(left, right candidate) bool { + leftTime, rightTime := left.reply.CreatedAt, right.reply.CreatedAt + if leftTime.Equal(rightTime) { + return left.index < right.index } + return leftTime.Before(rightTime) + } + sort.SliceStable(unique, func(i, j int) bool { return older(unique[i], unique[j]) }) + for parentID := range byParent { + children := byParent[parentID] + sort.SliceStable(children, func(i, j int) bool { return older(children[i], children[j]) }) + byParent[parentID] = children } - appendChildren(root.ID, 1) - for _, reply := range replies { - if visited[reply.ID] { - continue + thread := []ThreadItem{{Post: root}} + emitted := map[int]bool{root.ID: true} + var appendReply func(candidate, int) + appendReply = func(item candidate, depth int) { + if emitted[item.reply.ID] { + return } - visited[reply.ID] = true - depth := reply.Depth + emitted[item.reply.ID] = true if depth < 1 { depth = 1 } moreCount := 0 - if len(children[reply.ID]) == 0 { - moreCount = reply.ReplyCount + if len(byParent[item.reply.ID]) == 0 { + moreCount = item.reply.ReplyCount + } + thread = append(thread, ThreadItem{Post: item.reply.Post, Depth: depth, MoreCount: moreCount}) + for _, child := range byParent[item.reply.ID] { + appendReply(child, depth+1) + } + } + for _, child := range byParent[root.ID] { + appendReply(child, 1) + } + for _, item := range unique { + depth := item.reply.Depth + if depth < 1 { + depth = 1 } - thread = append(thread, ThreadItem{Post: reply.Post, Depth: depth, MoreCount: moreCount}) - appendChildren(reply.ID, depth+1) + appendReply(item, depth) } return thread } diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go @@ -107,7 +107,7 @@ func TestRichTextAndTerminalSafety(t *testing.T) { } } -func TestBuildReplyThreadDepthFirstOrphansAndCycles(t *testing.T) { +func TestBuildReplyThreadDepthFirstWithChronologicalSiblingsOrphansAndCycles(t *testing.T) { root := post(1, nil, 0) replies := []textlog.Reply{ reply(2, intPtr(1), 3, 1), diff --git a/internal/tui/backend.go b/internal/tui/backend.go @@ -52,7 +52,7 @@ type Result struct { Entries []ActivityEntry Replies []textlog.Reply Post *textlog.Post - Parent *textlog.Post + Root *textlog.Post Profile *textlog.Profile Users []textlog.UserReference Tags []textlog.TagReference @@ -125,18 +125,44 @@ func (b *clientBackend) Execute(ctx context.Context, op Operation) (Result, erro if err != nil { return Result{}, err } - replies, err := b.client.Replies(ctx, op.ID, 5, 100, "") - if err != nil { - return Result{}, err + focused := postEnvelope.Data + root := focused + seen := map[int]bool{focused.ID: true} + for root.ParentID != nil { + ancestorID := *root.ParentID + if root.TopID != nil { + ancestorID = *root.TopID + } + if seen[ancestorID] { + break + } + seen[ancestorID] = true + ancestor, ancestorErr := b.client.Post(ctx, ancestorID) + if ancestorErr != nil { + return Result{}, ancestorErr + } + root = ancestor.Data } - result := Result{Post: &postEnvelope.Data, Replies: replies.Data} - if postEnvelope.Data.ParentID != nil { - parent, parentErr := b.client.Post(ctx, *postEnvelope.Data.ParentID) - if parentErr == nil { - result.Parent = &parent.Data + + var allReplies []textlog.Reply + cursor := "" + seenCursors := make(map[string]bool) + for { + replies, repliesErr := b.client.Replies(ctx, root.ID, 5, 100, cursor) + if repliesErr != nil { + return Result{}, repliesErr + } + allReplies = append(allReplies, replies.Data...) + if replies.Pagination.NextCursor == nil || *replies.Pagination.NextCursor == "" { + break } + cursor = *replies.Pagination.NextCursor + if seenCursors[cursor] { + return Result{}, fmt.Errorf("replies pagination repeated cursor %q", cursor) + } + seenCursors[cursor] = true } - return result, nil + return Result{Post: &focused, Root: &root, Replies: allReplies}, nil case OpProfile: profile, err := b.client.Profile(ctx, op.Handle) if err != nil { diff --git a/internal/tui/backend_test.go b/internal/tui/backend_test.go @@ -0,0 +1,61 @@ +package tui + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/ryan/gotextlog/internal/textlog" +) + +func TestThreadBackendLoadsRootAndAllReplyPagesForFocusedReply(t *testing.T) { + type request struct { + path string + query url.Values + } + var requests []request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, request{path: r.URL.Path, query: r.URL.Query()}) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/posts/3": + fmt.Fprint(w, `{"data":{"id":3,"top_id":1,"parent_id":2,"body":"focused","author":{"handle":"three"}}}`) + case "/api/v1/posts/1": + fmt.Fprint(w, `{"data":{"id":1,"body":"root","author":{"handle":"one"}}}`) + case "/api/v1/posts/1/replies": + if r.URL.Query().Get("cursor") == "next" { + fmt.Fprint(w, `{"data":[{"id":3,"top_id":1,"parent_id":2,"body":"focused","author":{"handle":"three"},"depth":2}],"pagination":{"next_cursor":null}}`) + } else { + fmt.Fprint(w, `{"data":[{"id":2,"top_id":1,"parent_id":1,"body":"parent","author":{"handle":"two"},"depth":1}],"pagination":{"next_cursor":"next"}}`) + } + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + backend := NewClientBackend(textlog.NewClient(server.URL, "", server.Client())) + result, err := backend.Execute(context.Background(), Operation{Kind: OpThread, ID: 3}) + if err != nil { + t.Fatal(err) + } + if result.Post == nil || result.Post.ID != 3 || result.Root == nil || result.Root.ID != 1 { + t.Fatalf("thread result = %#v", result) + } + if len(result.Replies) != 2 || result.Replies[0].ID != 2 || result.Replies[1].ID != 3 { + t.Fatalf("thread replies = %#v", result.Replies) + } + want := []request{ + {path: "/api/v1/posts/3", query: url.Values{}}, + {path: "/api/v1/posts/1", query: url.Values{}}, + {path: "/api/v1/posts/1/replies", query: url.Values{"depth": {"5"}, "limit": {"100"}}}, + {path: "/api/v1/posts/1/replies", query: url.Values{"cursor": {"next"}, "depth": {"5"}, "limit": {"100"}}}, + } + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests = %#v, want %#v", requests, want) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go @@ -43,6 +43,8 @@ type screen struct { posts, notes []textlog.Post entries []ActivityEntry depths, more []int + thread []domain.ThreadItem + folded map[int]bool selected, target, page int cursors []string nextCursor string @@ -252,7 +254,7 @@ func (m Model) applyLoaded(msg loadedMsg) (tea.Model, tea.Cmd) { } s.selected, s.target = 0, -1 case OpThread: - s.posts, s.depths, s.more = makeThread(msg.result) + m.setThread(s, msg.result) case OpProfile: s.profile, s.notes, s.posts, s.replies = msg.result.Profile, msg.result.Posts, msg.result.Posts, msg.result.Replies s.following, s.followingTags, s.followers, s.blocks = msg.result.Following, msg.result.FollowingTags, msg.result.Followers, msg.result.Blocks @@ -367,6 +369,10 @@ func (m *Model) screenKey(s *screen, key tea.KeyMsg) (tea.Cmd, bool) { return m.reloadList(s), true } case screenPost: + if k == " " { + m.toggleThreadFold(s) + return nil, true + } if cmd, ok := m.listKey(s, key); ok { return cmd, true } @@ -932,6 +938,10 @@ func edit(value *string, key tea.KeyMsg, limit int) { } return } + if key.Type == tea.KeySpace && utf8.RuneCountInString(*value) < limit { + *value += " " + return + } if key.Type == tea.KeyRunes && utf8.RuneCountInString(*value)+len(key.Runes) <= limit { *value += string(key.Runes) } @@ -965,29 +975,117 @@ func prependUnique(posts []textlog.Post, p textlog.Post, limit int) []textlog.Po return out } -func makeThread(result Result) ([]textlog.Post, []int, []int) { +func (m *Model) setThread(s *screen, result Result) { if result.Post == nil { - return nil, nil, nil - } - items := domain.BuildReplyThread(*result.Post, result.Replies) - var posts []textlog.Post - var depths, more []int - if result.Post.TopID != nil { - posts = append(posts, textlog.Post{ID: *result.Post.TopID, Body: "↑ top"}) - depths = append(depths, 0) - more = append(more, -1) - } - for i, item := range items { - posts = append(posts, item.Post) - depths = append(depths, item.Depth) - more = append(more, item.MoreCount) - if i == 0 && result.Parent != nil { - posts = append(posts, *result.Parent) - depths = append(depths, 1) - more = append(more, -2) - } - } - return posts, depths, more + s.posts, s.thread = nil, nil + return + } + root := result.Post + if result.Root != nil { + root = result.Root + } + replies := append([]textlog.Reply(nil), result.Replies...) + if result.Post.ID != root.ID { + found := false + for _, reply := range replies { + if reply.ID == result.Post.ID { + found = true + break + } + } + if !found { + replies = append(replies, textlog.Reply{Post: *result.Post}) + } + } + s.thread = domain.BuildReplyThread(*root, replies) + s.folded = make(map[int]bool) + m.rebuildThread(s, result.Post.ID) +} + +func (m *Model) toggleThreadFold(s *screen) { + post := selectedPost(s) + if post == nil || !threadHasChildren(s.thread, post.ID) { + return + } + s.folded[post.ID] = !s.folded[post.ID] + m.rebuildThread(s, post.ID) +} + +func (m *Model) rebuildThread(s *screen, selectedID int) { + byID := make(map[int]textlog.Post, len(s.thread)) + for _, item := range s.thread { + byID[item.Post.ID] = item.Post + } + s.posts, s.depths, s.more = nil, nil, nil + for _, item := range s.thread { + if threadItemHidden(item.Post, byID, s.folded) { + continue + } + s.posts = append(s.posts, item.Post) + s.depths = append(s.depths, item.Depth) + more := item.MoreCount + if s.folded[item.Post.ID] { + more = threadDescendantCount(s.thread, item.Post.ID) + } + s.more = append(s.more, more) + if item.Post.ID == selectedID { + s.selected = len(s.posts) - 1 + } + } + s.target = -1 +} + +func threadItemHidden(post textlog.Post, byID map[int]textlog.Post, folded map[int]bool) bool { + seen := make(map[int]bool) + for post.ParentID != nil { + parentID := *post.ParentID + if folded[parentID] { + return true + } + if seen[parentID] { + return false + } + seen[parentID] = true + parent, ok := byID[parentID] + if !ok { + return false + } + post = parent + } + return false +} + +func threadHasChildren(thread []domain.ThreadItem, id int) bool { + for _, item := range thread { + if item.Post.ParentID != nil && *item.Post.ParentID == id { + return true + } + } + return false +} + +func threadDescendantCount(thread []domain.ThreadItem, id int) int { + byParent := make(map[int][]int) + for _, item := range thread { + if item.Post.ParentID != nil { + byParent[*item.Post.ParentID] = append(byParent[*item.Post.ParentID], item.Post.ID) + } + } + count := 0 + seen := map[int]bool{id: true} + var visit func(int) + visit = func(parent int) { + for _, child := range byParent[parent] { + if seen[child] { + continue + } + seen[child] = true + count++ + visit(child) + } + } + visit(id) + return count } func (m *Model) profileTabCount(s *screen) int { if m.me != nil && strings.EqualFold(m.me.Handle, s.handle) { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go @@ -130,6 +130,95 @@ func TestCompleteFeedThreadReplyFlow(t *testing.T) { t.Fatalf("calls=%v", backend.calls) } } +func TestReplyEditorAcceptsSpace(t *testing.T) { + model := New(&acceptanceBackend{}, config.Config{}, Options{}) + parent := textlog.Post{ID: 1, Author: textlog.UserReference{Handle: "alice"}} + model.openCompose(&parent, nil) + + model, _ = key(model, "hello") + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + model, _ = key(model, "world") + + if got := model.current().input; got != "hello world" { + t.Fatalf("reply editor input = %q, want %q", got, "hello world") + } +} + +func TestTopNavigationRendersWithoutNoteMetadata(t *testing.T) { + backend := &acceptanceBackend{} + model := New(backend, config.Config{NoColor: true}, Options{}) + s := model.newScreen(screenPost) + s.posts = []textlog.Post{{ID: 1, Body: "↑ top"}} + model.stack = []*screen{s} + + view := model.View() + if !strings.Contains(view, "↑ top") { + t.Fatalf("top navigation missing:\n%s", view) + } + for _, metadata := range []string{"@ now", "0 replies"} { + if strings.Contains(view, metadata) { + t.Fatalf("top navigation includes %q metadata:\n%s", metadata, view) + } + } + + model, cmd := key(model, "enter") + model = settle(t, model, cmd) + if len(backend.calls) != 1 || backend.calls[0].Kind != OpThread || backend.calls[0].ID != 1 { + t.Fatalf("top navigation calls = %#v", backend.calls) + } +} + +func TestReplyFocusShowsDepthFirstFoldableWholeThread(t *testing.T) { + now := time.Now() + root := textlog.Post{ID: 1, Body: "root", CreatedAt: now, ReplyCount: 2, Author: textlog.UserReference{Handle: "root"}} + focused := textlog.Post{ID: 3, TopID: intPtr(1), ParentID: intPtr(2), Body: "focused", CreatedAt: now.Add(2 * time.Minute), ReplyCount: 1, Author: textlog.UserReference{Handle: "focused"}} + result := Result{Post: &focused, Root: &root, Replies: []textlog.Reply{ + {Post: textlog.Post{ID: 5, TopID: intPtr(1), ParentID: intPtr(3), Body: "newest", CreatedAt: now.Add(4 * time.Minute), Author: textlog.UserReference{Handle: "five"}}, Depth: 3}, + {Post: textlog.Post{ID: 4, TopID: intPtr(1), ParentID: intPtr(1), Body: "later sibling", CreatedAt: now.Add(3 * time.Minute), Author: textlog.UserReference{Handle: "four"}}, Depth: 1}, + {Post: focused, Depth: 2}, + {Post: textlog.Post{ID: 2, TopID: intPtr(1), ParentID: intPtr(1), Body: "parent", CreatedAt: now.Add(time.Minute), Author: textlog.UserReference{Handle: "two"}}, Depth: 1}, + }} + + model := New(&acceptanceBackend{}, config.Config{Theme: config.ThemeDark}, Options{}) + s := model.newScreen(screenPost) + s.id = focused.ID + model.stack = []*screen{s} + updated, _ := model.applyLoaded(loadedMsg{serial: s.serial, op: Operation{Kind: OpThread, ID: focused.ID}, result: result}) + model = updated.(Model) + + assertPostIDs(t, model.current().posts, 1, 2, 3, 5, 4) + if model.current().selected != 2 { + t.Fatalf("focused selection = %d, want 2", model.current().selected) + } + + model, _ = key(model, " ") + assertPostIDs(t, model.current().posts, 1, 2, 3, 4) + if !strings.Contains(model.View(), "1 replies folded") { + t.Fatalf("folded thread did not show its state:\n%s", model.View()) + } + model, _ = key(model, " ") + assertPostIDs(t, model.current().posts, 1, 2, 3, 5, 4) + + model, _ = key(model, "g") + model, _ = key(model, " ") + assertPostIDs(t, model.current().posts, 1) + model, _ = key(model, " ") + assertPostIDs(t, model.current().posts, 1, 2, 3, 5, 4) +} + +func assertPostIDs(t *testing.T, posts []textlog.Post, want ...int) { + t.Helper() + if len(posts) != len(want) { + t.Fatalf("post count = %d, want %d: %#v", len(posts), len(want), posts) + } + for i := range want { + if posts[i].ID != want[i] { + t.Fatalf("post[%d].ID = %d, want %d", i, posts[i].ID, want[i]) + } + } +} + func TestFollowingTagNavigationAndSettingsMenu(t *testing.T) { backend := &acceptanceBackend{} model := New(backend, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark, Token: "token"}, Options{}) diff --git a/internal/tui/view.go b/internal/tui/view.go @@ -126,9 +126,9 @@ func (m *Model) footer(s *screen) string { } if s.kind == screenPost { if m.width < 90 { - return " Tab target · Enter open · r reply · u user · ! actions · q back" + return " j/k move · Space fold · Enter focus · r reply · u user · q back" } - return " Tab target · Enter open · r reply · u profile · f/F follow · b/B block · ! report · e edit · d delete" + return " j/k move · Space fold/unfold · Enter focus · r reply · u profile · ! actions · q back" } if s.kind == screenHelp { return " q back" @@ -194,6 +194,12 @@ func (m *Model) postsView(s *screen, p palette) string { if i < len(s.entries) { context = s.entries[i].Context } + if s.kind == screenPost && threadHasChildren(s.thread, s.posts[i].ID) { + context = "▾ expanded" + if s.folded[s.posts[i].ID] { + context = "▸ folded" + } + } bodyLines := 3 if s.kind == screenPost && i == s.selected { bodyLines = 8 @@ -207,7 +213,11 @@ func (m *Model) postsView(s *screen, p palette) string { note = strings.Join(lines, "\n") } if i < len(s.more) && s.more[i] > 0 { - note += "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(strings.Repeat(" ", depth+1)+fmt.Sprintf("more · %d replies", s.more[i])) + label := fmt.Sprintf("more · %d replies", s.more[i]) + if s.folded[s.posts[i].ID] { + label = fmt.Sprintf("%d replies folded", s.more[i]) + } + note += "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(strings.Repeat(" ", depth+1)+label) } rendered[i] = note } @@ -270,6 +280,15 @@ func (m *Model) postsView(s *screen, p palette) string { } func renderNote(post textlog.Post, selected bool, focused int, context string, width, maxBodyLines int, p palette) string { + if post.Body == "↑ top" && post.Author.Handle == "" && post.CreatedAt.IsZero() { + style := lipgloss.NewStyle().Foreground(p.accent).Width(max(10, width-2)) + if selected { + style = style.BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(p.accent).PaddingLeft(1) + } else { + style = style.PaddingLeft(2) + } + return style.Render(post.Body) + } replies := fmt.Sprintf("%d replies", post.ReplyCount) if post.ReplyCount == 1 { replies = "1 reply"