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()) + } +}