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:
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"