From dcd5a450dec8dac8fb4ae034cd624fef1819f1ae Mon Sep 17 00:00:00 2001 From: Jeroen Bulters Date: Tue, 8 Sep 2026 14:57:36 +0200 Subject: [PATCH] Trash every selected thread with t in the TUI Space selects threads, but only Ctrl+B acted on the selection; t and every other filing key took the row under the cursor. Trashing a handful of threads meant a keypress per row. With a selection, t now trashes every selected thread in one request, the way the web app's toolbar acts on a selection. The done message carries every posting the action took, so the list, the Previously Seen screen and the overlaid search and bundle lists drop each row as they already do for one. Removing a row also takes it out of the selection, so a failed request leaves the selection standing for another try and a successful one clears it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LZgWZQHxm7KdZNLNjxCtQG --- docs/tui.md | 4 +- internal/tui/content.go | 1 + internal/tui/mail.go | 79 ++++++++++++++++++++++++++++++--------- internal/tui/mail_test.go | 42 +++++++++++++++++++++ 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index d1ada41b..21a85b38 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -50,10 +50,10 @@ uppercase belongs to Labels: | `a` | move to Set Aside | | `d` | move to The Feed | | `p` | move to Paper Trail | -| `t` | trash | +| `t` | trash — every selected thread when any are selected, otherwise the one under the cursor | | `!` | mark as spam | | `-` / `+` | ignore / stop ignoring | -| Space | select the thread for a bulk action | +| Space | select the thread for a bulk action (`t` or Ctrl+B) | | Ctrl+B | preview every bulk-reply recipient, then write one reply to every selected thread | | Ctrl+U | recall a delayed bulk reply while HEY's undo window is open | | Ctrl+S | open The Screener | diff --git a/internal/tui/content.go b/internal/tui/content.go index 84d2c38d..cbd2d009 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -139,6 +139,7 @@ func (c *contentList) removeAt(index int) { if c.cursor > index { c.cursor-- } + c.keepSelected() c.settleCover() } diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 2a634ade..b72022f7 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -198,6 +198,7 @@ type postingActionDoneMsg struct { boxID int64 sourceKind mail.Kind postingID int64 + postingIDs []int64 // every posting a bulk action took, empty for a single row's effect postingActionEffect destinationKind string // the box kind a move filed into, empty for every other action filingSeq uint64 // which open-thread filing dispatched the move, zero for a list row's @@ -205,6 +206,15 @@ type postingActionDoneMsg struct { err error } +// postings is every posting the action took: the bulk selection when there was one, +// otherwise the single row. +func (msg postingActionDoneMsg) postings() []int64 { + if len(msg.postingIDs) > 0 { + return msg.postingIDs + } + return []int64{msg.postingID} +} + // postingSeenMsg reports the mark-seen that opening a thread triggers on its // own, as the web app does out of band once it has rendered the topic. type postingSeenMsg struct { @@ -718,24 +728,26 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return func() tea.Msg { return errMsg{msg.err} }, true } done := notify(msg.action) - idx := v.postingIndex(msg.postingID) - if idx >= 0 { - switch msg.effect { - case postingActionNone: - case postingActionRemove: - v.removePostingAt(idx) - case postingActionSeen: - v.postingList.markSeen(idx) - case postingActionUnseen: - v.postingList.markUnseen(idx) - case postingActionIgnore: - v.postingList.postings[idx].Muted = true - case postingActionStopIgnoring: - v.postingList.postings[idx].Muted = false + for _, postingID := range msg.postings() { + idx := v.postingIndex(postingID) + if idx >= 0 { + switch msg.effect { + case postingActionNone: + case postingActionRemove: + v.removePostingAt(idx) + case postingActionSeen: + v.postingList.markSeen(idx) + case postingActionUnseen: + v.postingList.markUnseen(idx) + case postingActionIgnore: + v.postingList.postings[idx].Muted = true + case postingActionStopIgnoring: + v.postingList.postings[idx].Muted = false + } + } + if msg.effect == postingActionRemove { + v.removeFromOverlaidLists(postingID) } - } - if msg.effect == postingActionRemove { - v.removeFromOverlaidLists(msg.postingID) } // The open thread can file back into the box on screen — out and back while // it stays open — and its row was removed when it first filed away, so the @@ -1532,7 +1544,11 @@ func (v *mailView) applySeenPostingAction(msg postingActionDoneMsg) tea.Cmd { if msg.err != nil { return func() tea.Msg { return errMsg{msg.err} } } - if idx := postingIndexIn(v.seenList.postings, msg.postingID); idx >= 0 { + for _, postingID := range msg.postings() { + idx := postingIndexIn(v.seenList.postings, postingID) + if idx < 0 { + continue + } switch msg.effect { case postingActionNone: case postingActionRemove, postingActionUnseen: @@ -2428,6 +2444,11 @@ func (v *mailView) fileablePosting() *mail.Posting { } func (v *mailView) handlePostingAction(key string) tea.Cmd { + if key == "t" || key == "T" { + if ids := v.actionList().selectedIDs(); len(ids) > 0 { + return v.trashSelected(ids) + } + } selected := v.actionList().selectedPosting() if selected == nil { return nil @@ -2435,6 +2456,28 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { return v.postingAction(key, *selected, v.postingBoxKind(*selected)) } +// trashSelected trashes every selected thread in one request, the way the web app's +// toolbar acts on a selection rather than on the row under the cursor. The rows leave +// the list when HEY answers and take their selection with them; a failure leaves the +// selection standing for another try. +func (v *mailView) trashSelected(ids []int64) tea.Cmd { + label := "Thread moved to Trash" + if len(ids) > 1 { + label = fmt.Sprintf("%d threads moved to Trash", len(ids)) + } + trash := v.doPostingAction(label, postingActionRemove, v.currentBoxID(), ids[0], func() error { + return v.vc.sdk.Postings().MoveToTrash(v.vc.ctx, ids...) + }) + return func() tea.Msg { + done, ok := trash().(postingActionDoneMsg) + if !ok { + return nil + } + done.postingIDs = ids + return done + } +} + // actionBoxKind is the box kind a list row files out of, empty over a source that // is not one of HEY's own boxes. func (v *mailView) actionBoxKind() string { diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 9ff6a482..28950efb 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "slices" "strconv" "strings" "sync/atomic" @@ -4303,3 +4304,44 @@ func TestMailViewBundleSurvivesAStaleAppend(t *testing.T) { } } } + +func TestMailViewTrashesSelectedThreadsInOneRequest(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + selectTwoThreads(v) + + done, ok := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("bulk trash returned %#v", done) + } + if recorded.path != "/postings/trash.json" || !slices.Equal(recorded.body.PostingIDs, []int64{100, 101}) { + t.Fatalf("request = %s %v, want one POST /postings/trash.json with [100 101]", recorded.path, recorded.body.PostingIDs) + } + + answer, _ := v.Update(done) + if toast := deliverToView(v, answer); toast != "2 threads moved to Trash" { + t.Errorf("toast = %q", toast) + } + if len(v.postingList.postings) != 0 { + t.Errorf("postings left = %d, want every selected row gone", len(v.postingList.postings)) + } + if ids := v.postingList.selectedIDs(); len(ids) != 0 { + t.Errorf("selection left = %v, want the trashed rows out of it", ids) + } + if v.AccountSwitchBlocked() { + t.Error("completed bulk trash still blocks account switching") + } +} + +func TestMailViewTrashFailureKeepsSelection(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusInternalServerError) + selectTwoThreads(v) + + done := runCmd(v.HandleContentKey(keyPress("t"))).(postingActionDoneMsg) + if done.err == nil { + t.Fatal("a failed bulk trash should carry its error") + } + v.Update(done) + if len(v.postingList.postings) != 2 || !slices.Equal(v.postingList.selectedIDs(), []int64{100, 101}) { + t.Errorf("postings = %d selected = %v, want both rows still selected", len(v.postingList.postings), v.postingList.selectedIDs()) + } +}