From 205e35f4193b825b70dd7be54f54e35345a2f11f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:19:22 +0200 Subject: [PATCH] Let a box's number leave a thread opened from that box Co-Authored-By: Claude Opus 5.5 --- internal/tui/mail.go | 32 ++++++++++++++++++++---------- internal/tui/tui_test.go | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 4bf80774a..8e7d9a230 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -1799,14 +1799,31 @@ func (v *mailView) switchBox(index int) tea.Cmd { return nil } if index == v.boxIndex { - // The box under the seen screen: its number or tab closes the screen the - // way esc does, landing on the list that is already there. - if v.seenActive { - v.clearSeen() + // The box already under a thread, a search, a bundle or the seen screen: its + // number closes whatever is open over it and lands on the list that is already + // there. Answering with nothing would let the key fall through to the thread, + // which swallows it, so 1 from a thread opened in the Imbox did nothing at all. + if !v.inThread && !v.searchActive && !v.bundleActive && !v.seenActive { + return nil + } + v.closeOverlays() + if v.requests.kind != mailRequestPostings { v.requests.cancel() } - return nil + v.notice = "" + return func() tea.Msg { return nil } } + v.closeOverlays() + v.requests.cancel() + v.notice = "" + v.postingList.setPostings(nil) + v.boxIndex = index + return v.requestPostings(v.boxes[index]) +} + +// closeOverlays puts away everything a box's list can have open over it — a thread, a +// search, a bundle, the Previously Seen screen — leaving the list itself. +func (v *mailView) closeOverlays() { v.inThread = false v.threadNotice = "" v.threadPosting = mail.Posting{} @@ -1816,11 +1833,6 @@ func (v *mailView) switchBox(index int) tea.Cmd { v.clearSearch() v.clearBundle() v.clearSeen() - v.requests.cancel() - v.notice = "" - v.postingList.setPostings(nil) - v.boxIndex = index - return v.requestPostings(v.boxes[index]) } // openPreviouslySeen jumps to the Imbox's Previously Seen threads on their own screen, diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 9ec0c5b25..ddb60002b 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1355,6 +1355,49 @@ func openLinkThreadThroughModel(t *testing.T) model { return m } +// A box's number works from inside a thread, including the box the thread was opened +// from: 1 in a thread read from the Imbox lands on the Imbox's list rather than falling +// through to the thread, which swallowed it. +func TestBoxNumbersLeaveAThread(t *testing.T) { + for _, testCase := range []struct { + key string + wantBox int + }{ + {key: "1", wantBox: 0}, + {key: "2", wantBox: 1}, + } { + t.Run(testCase.key, func(t *testing.T) { + m := openLinkThreadThroughModel(t) + + updated, _ := m.Update(keyPress(testCase.key)) + m = updated.(model) + + if m.mailView.InThread() { + t.Errorf("%s left the thread open", testCase.key) + } + if m.mailView.boxIndex != testCase.wantBox { + t.Errorf("%s landed on box %d, want %d", testCase.key, m.mailView.boxIndex, testCase.wantBox) + } + if testCase.key == "1" && !strings.Contains(stripANSI(m.View().Content), "Meeting notes") { + t.Errorf("the Imbox's list is not on screen:\n%s", stripANSI(m.View().Content)) + } + }) + } +} + +func TestBoxNumberForTheCurrentBoxClosesASearch(t *testing.T) { + v := mailWithPostings() + v.searchActive = true + v.searchQuery = "invoice" + + if cmd := v.handleBoxShortcut("1"); cmd == nil { + t.Fatal("1 over a search in the Imbox should be handled") + } + if v.searchActive || v.boxIndex != 0 { + t.Errorf("searchActive=%v box=%d, want the Imbox's list", v.searchActive, v.boxIndex) + } +} + func TestRootModelKeepsGlobalTabForALinklessThread(t *testing.T) { m := modelWithBoxes() m.mailView.inThread = true