From 8939779ec2822641591f054b1861413183fae3bd Mon Sep 17 00:00:00 2001 From: Pavel Mikhailov Date: Thu, 27 Aug 2026 23:18:58 +0400 Subject: [PATCH] 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. The web app binds t directly on the topic toolbar, so it trashes from an open thread. t and T now file the open thread the way a and l already do, through the same posting action the list uses. The key acts on the posting the thread was opened from rather than wherever the list's cursor has since landed. Over search results, bundles and topics opened by id there is no row to file and the key says so, which is the rule the other filing keys follow. Trash also closes the thread, where Set Aside and Reply Later leave it open in the box it landed in. The Trash is not a box you file out of, and the web app returns to the list too. Closes #339 --- internal/tui/mail.go | 10 ++- internal/tui/mail_test.go | 127 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 6f553bbb..be989410 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -957,7 +957,7 @@ func (v *mailView) HelpBindings() []helpBinding { if v.inThread { bindings := []helpBinding{{"r", "reply"}, {"f", "forward"}} if v.fileablePosting() != nil { - bindings = append(bindings, helpBinding{"l", "reply later"}, helpBinding{"a", "set aside"}) + bindings = append(bindings, helpBinding{"l", "reply later"}, helpBinding{"a", "set aside"}, helpBinding{"t", "trash"}) } if len(v.entries) > 1 { bindings = append(bindings, helpBinding{"j/k", "next/previous message"}) @@ -1266,7 +1266,7 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if v.topicID != 0 { return v.loadForwardContext(v.topicID, v.topicName) } - case "a", "A", "l": + case "a", "A", "l", "t", "T": return v.fileOpenThread(msg.String()) case "[": v.moveAttachmentCursor(-1) @@ -2326,6 +2326,12 @@ func (v *mailView) fileOpenThread(key string) tea.Cmd { if move == nil { return nil } + // Set Aside and Reply Later leave the thread on screen, in the box it landed + // in, so the next filing key can act on it there. Trash closes it: the Trash + // is not a box you file out of, and the web app returns to the list too. + if key == "t" || key == "T" { + v.ExitThread() + } // Filing keys pressed faster than their requests answer can complete out of // order, so each dispatch takes a sequence number and only the latest one // records where the thread landed. diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 27cb6956..b500a5c8 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -2409,6 +2409,120 @@ 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.threadPosting.ID != 100 { + t.Fatalf("thread state = open:%v posting:%d", v.inThread, v.threadPosting.ID) + } + + 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) + } + }) + } +} + +// Trash keeps to the same rule as the other filing keys: a thread opened over +// search results or a bundle has no row to file, so the key says so rather than +// trashing whatever the box list's cursor happens to be sitting on. +func TestMailViewRefusesToTrashAThreadOpenedFromSearchResults(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 { + t.Fatal("the searched thread should have opened") + } + + if cmd := v.HandleContentKey(keyPress("t")); cmd != nil { + t.Errorf("trash over search results 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.path == "/postings/trash.json" { + t.Error("a refused trash still asked the server to trash something") + } +} + +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.threadPosting.ID != 611 { + t.Fatalf("thread state = open:%v seen:%v posting:%d", v.inThread, v.seenActive, v.threadPosting.ID) + } + + 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) { @@ -3080,6 +3194,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.threadPosting = mail.Posting{} + 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) {