From eb3c529fb9d193b19de41c1b19f1e7c46210afb6 Mon Sep 17 00:00:00 2001 From: Pavel Mikhailov Date: Thu, 27 Aug 2026 23:18:58 +0400 Subject: [PATCH 1/2] Trash the thread you are reading with t The trash key only worked on a list, so a thread you had opened could not be trashed without leaving it first, and the help bar in a thread never offered the key at all. t and T now trash the open thread and return to the list it came from. The posting is the one the thread was opened from rather than the list's selection: a thread opened out of search results or a bundle leaves the box list's cursor on a different email. A topic opened by its id carries no posting, and HEY trashes postings, so the key says so instead. Closes #339 --- internal/tui/mail.go | 20 ++++++ internal/tui/mail_test.go | 125 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index ac9482a8..f83d1169 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -254,6 +254,7 @@ type mailView struct { topicViewport viewport.Model topicContent string topicID int64 + topicPostingID int64 topicName string entries []mail.Entry attachments []messageAttachment @@ -515,6 +516,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.inThread = true v.topicID = msg.topicID + v.topicPostingID = msg.postingID v.topicName = msg.title v.entries = msg.entries v.attachments = msg.attachments @@ -929,6 +931,9 @@ func (v *mailView) HelpBindings() []helpBinding { } if v.inThread { bindings := []helpBinding{{"r", "reply"}, {"f", "forward"}} + if v.topicPostingID != 0 { + bindings = append(bindings, helpBinding{"t", "trash"}) + } if len(v.entries) > 1 { bindings = append(bindings, helpBinding{"j/k", "next/previous message"}) } @@ -1240,6 +1245,8 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return v.saveSelectedAttachment() case "o": return v.openSelectedAttachment() + case "t", "T": + return v.trashOpenThread() case "j": if len(v.entryOffsets) > 1 { v.jumpEntry(1) @@ -2350,6 +2357,19 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { return nil } +func (v *mailView) trashOpenThread() tea.Cmd { + if v.topicPostingID == 0 { + v.notice = "Open this thread from a box to trash it" + return nil + } + postingID := v.topicPostingID + cmd := v.doPostingAction("Thread moved to Trash", postingActionRemove, v.currentBoxID(), postingID, func() error { + return v.vc.sdk.Postings().MoveToTrash(v.vc.ctx, postingID) + }) + v.ExitThread() + return cmd +} + func (v *mailView) moveSelectedToImbox(boxID, postingID int64) tea.Cmd { if source := v.imboxSource(); source != nil { imboxID := source.ID diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 8d1ea5c4..3e9472ac 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -2158,6 +2158,118 @@ func TestMailViewContentKeyInThread(t *testing.T) { v.HandleContentKey(keyPress("up")) } +func TestMailViewTrashesTheOpenThreadAndReturnsToTheList(t *testing.T) { + for _, key := range []string{"t", "T"} { + t.Run(key, func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread || v.topicPostingID != 100 { + t.Fatalf("thread state = open:%v posting:%d", v.inThread, v.topicPostingID) + } + + cmd := v.HandleContentKey(keyPress(key)) + if v.inThread { + t.Error("trashing the open thread should return to the list") + } + done, ok := runCmd(cmd).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("trash command returned %#v", done) + } + if done.postingID != 100 || done.effect != postingActionRemove { + t.Errorf("action = posting %d effect %v, want posting 100 effect %v", done.postingID, done.effect, postingActionRemove) + } + if recorded.method != http.MethodPost || recorded.path != "/postings/trash.json" { + t.Errorf("request = %s %s, want POST /postings/trash.json", recorded.method, recorded.path) + } + if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { + t.Errorf("posting_ids = %v, want [100]", recorded.body.PostingIDs) + } + + answer, _ := v.Update(done) + if toast := deliverToView(v, answer); toast != "Thread moved to Trash" { + t.Errorf("toast = %q, want %q", toast, "Thread moved to Trash") + } + if len(v.postingList.postings) != 1 || v.postingList.postings[0].ID != 101 { + t.Errorf("postings after trashing = %v, want the other thread alone", v.postingList.postings) + } + }) + } +} + +func TestMailViewTrashesTheThreadOnScreenRatherThanTheListSelection(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.searchActive = true + v.searchQuery = "quarterly planning" + v.searchList.setPostings([]mail.Posting{{ID: 10, TopicID: 100, Name: "Hello world"}}) + + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread || v.topicPostingID != 10 { + t.Fatalf("thread state = open:%v posting:%d", v.inThread, v.topicPostingID) + } + + done, ok := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("trash command returned %#v", done) + } + if done.postingID != 10 { + t.Errorf("trashed posting %d, want the searched thread's posting 10", done.postingID) + } + if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 10 { + t.Errorf("posting_ids = %v, want [10]", recorded.body.PostingIDs) + } + if v.inThread || !v.searchActive { + t.Errorf("trashing a searched thread landed on open:%v search:%v, want the results", v.inThread, v.searchActive) + } +} + +func TestMailViewCannotTrashAThreadOpenedWithoutItsPosting(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.Update(topicLoadedMsg{ + topicID: 100, + title: "Hello world", + entries: []mail.Entry{{Creator: mail.Contact{Name: "Alice"}, Body: htmlutil.ToMarkdown("

hello

")}}, + }) + + if cmd := v.HandleContentKey(keyPress("t")); cmd != nil { + t.Errorf("trash without a posting returned %#v, want nothing", runCmd(cmd)) + } + if !v.inThread { + t.Error("a refused trash should leave the thread open") + } + if v.notice == "" { + t.Error("a refused trash should say why") + } + if recorded.method != "" { + t.Errorf("a refused trash sent %s %s", recorded.method, recorded.path) + } +} + +func TestMailViewTrashesAThreadOpenedFromPreviouslySeen(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + loaded, _ := runCmd(v.handleBoxShortcut("9")).(seenLoadedMsg) + v.Update(loaded) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread || !v.seenActive || v.topicPostingID != 611 { + t.Fatalf("thread state = open:%v seen:%v posting:%d", v.inThread, v.seenActive, v.topicPostingID) + } + + done, ok := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg) + if !ok || done.err != nil || !done.seen { + t.Fatalf("trash command returned %#v", done) + } + if v.inThread || !v.seenActive { + t.Errorf("trashing landed on open:%v seen:%v, want the Previously Seen list", v.inThread, v.seenActive) + } + v.Update(done) + if len(v.seenList.postings) != 0 { + t.Errorf("seen postings after trashing = %+v, want the row gone", v.seenList.postings) + } + if len(v.postingList.postings) != 2 { + t.Errorf("a seen-screen trash landed on the box list: %+v", v.postingList.postings) + } +} + // --- Subnav --- func TestMailViewSubnavItems(t *testing.T) { @@ -2829,6 +2941,19 @@ func TestMailViewHelpBindings(t *testing.T) { } } +func TestMailViewOpenThreadHelpOffersTrashOnlyWithAPosting(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !hasHelpBinding(v.HelpBindings(), "t") { + t.Errorf("help bindings = %v, want trash among them", v.HelpBindings()) + } + + v.topicPostingID = 0 + if hasHelpBinding(v.HelpBindings(), "t") { + t.Errorf("help bindings = %v, want no trash without a posting", v.HelpBindings()) + } +} + // A label scrolls rather than paging, so it advertises no page keys and p keeps meaning // paper trail. func TestMailViewLabelHelpOffersNoPageKeys(t *testing.T) { From dabb87478de979bc34df2d89823d42626e763b9c Mon Sep 17 00:00:00 2001 From: Pavel Mikhailov Date: Thu, 27 Aug 2026 23:45:13 +0400 Subject: [PATCH 2/2] Drop a trashed thread's row from the results that found it A thread trashed from inside itself only left the box list and the Previously Seen screen: postingActionDoneMsg lands on postingList, so a thread opened out of search results or a bundle stayed listed there and could be reopened or trashed again. --- internal/tui/mail.go | 11 +++++++++++ internal/tui/mail_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index f83d1169..7490ac97 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -715,6 +715,9 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.postingList.postings[idx].Muted = false } } + if msg.effect == postingActionRemove { + v.removeFromOverlaidLists(msg.postingID) + } if v.requests.kind == mailRequestPostings { if source := v.currentSource(); source != nil { return tea.Batch(done, v.requestPostings(*source)), true @@ -1903,6 +1906,14 @@ func (v *mailView) removePostingAt(index int) { v.postingList.removeAt(index) } +func (v *mailView) removeFromOverlaidLists(postingID int64) { + for _, list := range []*contentList{&v.searchList, &v.bundleList} { + if index := postingIndexIn(list.postings, postingID); index >= 0 { + list.removeAt(index) + } + } +} + func (v *mailView) moveAttachmentCursor(delta int) { if len(v.attachments) == 0 { return diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 3e9472ac..f0d678f4 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -2221,6 +2221,44 @@ func TestMailViewTrashesTheThreadOnScreenRatherThanTheListSelection(t *testing.T if v.inThread || !v.searchActive { t.Errorf("trashing a searched thread landed on open:%v search:%v, want the results", v.inThread, v.searchActive) } + + v.Update(done) + if len(v.searchList.postings) != 0 { + t.Errorf("search results after trashing = %+v, want the row gone", v.searchList.postings) + } + if len(v.postingList.postings) != 2 { + t.Errorf("a searched thread's trash landed on the box list: %+v", v.postingList.postings) + } +} + +func TestMailViewTrashesAThreadOpenedFromABundle(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.postingList.postings[0] = bundleRow() + loaded, _ := runCmd(v.HandleContentKey(keyPress("enter"))).(bundleLoadedMsg) + more, _ := v.Update(loaded) + appended, _ := runCmd(more).(bundleAppendedMsg) + v.Update(appended) + if len(v.bundleList.postings) != 2 { + t.Fatalf("bundle postings = %+v", v.bundleList.postings) + } + + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread || v.topicPostingID != 511 { + t.Fatalf("thread state = open:%v posting:%d", v.inThread, v.topicPostingID) + } + + done, ok := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg) + if !ok || done.err != nil || done.postingID != 511 { + t.Fatalf("trash command returned %#v", done) + } + if v.inThread || !v.bundleActive { + t.Errorf("trashing landed on open:%v bundle:%v, want the bundle", v.inThread, v.bundleActive) + } + + v.Update(done) + if len(v.bundleList.postings) != 1 || v.bundleList.postings[0].ID != 512 { + t.Errorf("bundle postings after trashing = %+v, want the other thread alone", v.bundleList.postings) + } } func TestMailViewCannotTrashAThreadOpenedWithoutItsPosting(t *testing.T) {