From 78b3cde21ff915b5e9c749a69bf586aa83a78325 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 8 Sep 2026 07:50:06 +0200 Subject: [PATCH] File the open thread with the rest of the web's keys A thread on screen answered to a, l and t. The web app's topic toolbar keeps every filing key live while a topic is open, so u, i, d, p, b and v now work there too. The moves needed wiring rather than writing: postingAction has handled all four since the list gained them, and the thread's key switch simply never named them. It has no default the way the list's does, so anything unnamed reaches the viewport and scrolls. The two pickers needed more. Both read the list's selection, which is not the thread's row once the automatic mark-seen has resorted it under the cover, so they take the posting from actionPosting and answer to fileablePosting's rule: a thread with no row to file gets the notice the filing keys already give rather than a picker aimed at whatever the cursor has landed on. Marking unseen needed the snapshot kept in step. Opening an unseen thread marks it seen, and the snapshot is taken before that lands, so u measured against a stale row and said the thread it had just marked seen was already unseen. Left out, because the web leaves them out of an open thread too: spam, ignore, and collections. Mark seen is left out because opening the thread has already done it. Trash landed separately in #349. Closes #358 --- docs/tui.md | 7 +++ internal/tui/mail.go | 60 ++++++++++++++++++-- internal/tui/mail_test.go | 115 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 5 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 69de03af..7893358b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -61,6 +61,13 @@ uppercase belongs to Labels: | Ctrl+A | switch linked account | | Ctrl+V | choose an Imbox cover | +Most of those keep working while you are reading a thread, the way the web app's topic +toolbar stays live: `r`, `f`, `v`, `b`, `u`, `i`, `l`, `a`, `d`, `p` and `t` all act on +the thread on screen rather than on the list behind it. Filing a thread leaves it open in +the box it landed in, so the next key files it on from there; `t` closes it, because a +trashed thread is not somewhere you file out of. A thread opened from search results, from +a bundle, or by its id has no row to file and says so instead. + While writing a new message, reply or forward, Ctrl+T opens the searchable Snippets picker. HEY never chooses a default: Enter inserts the selected snippet at the body cursor, Escape returns without changing the draft, and the picker can be reopened to diff --git a/internal/tui/mail.go b/internal/tui/mail.go index be989410..7e2c3937 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -755,6 +755,11 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.noteFailure("Could not mark thread as seen", msg.err) return nil, true } + // The snapshot the open thread files on was taken before this landed, so it + // still reports the thread unseen — which is what u measures against. + if msg.postingID == v.threadPosting.ID { + v.threadPosting.Seen = true + } if msg.boxID == v.currentBoxID() && msg.sourceKind == v.currentSourceKind() { if idx := v.postingIndex(msg.postingID); idx >= 0 { v.postingList.markSeen(idx) @@ -957,7 +962,21 @@ 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"}, helpBinding{"t", "trash"}) + folderBinding := helpBinding{"b", "labels"} + if v.folderDiscoveryErr != "" { + folderBinding = helpBinding{"b", "retry labels"} + } + bindings = append(bindings, + helpBinding{"v", "move"}, + folderBinding, + helpBinding{"u", "unseen"}, + helpBinding{"i", "imbox"}, + helpBinding{"l", "reply later"}, + helpBinding{"a", "set aside"}, + helpBinding{"d", "feed"}, + helpBinding{"p", "paper trail"}, + helpBinding{"t", "trash"}, + ) } if len(v.entries) > 1 { bindings = append(bindings, helpBinding{"j/k", "next/previous message"}) @@ -1266,8 +1285,10 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if v.topicID != 0 { return v.loadForwardContext(v.topicID, v.topicName) } - case "a", "A", "l", "t", "T": + case "a", "A", "l", "t", "T", "u", "U", "i", "I", "d", "D", "p", "P": return v.fileOpenThread(msg.String()) + case "b", "B", "v", "V": + return v.openThreadPicker(msg.String()) case "[": v.moveAttachmentCursor(-1) return nil @@ -2119,7 +2140,7 @@ func (v *mailView) openedPosting(postingID int64) *mail.Posting { // --- Posting actions --- func (v *mailView) startMove() { - selected := v.actionList().selectedPosting() + selected := v.actionPosting() currentSource := v.actionSource() if selected == nil || currentSource == nil { return @@ -2161,7 +2182,7 @@ func (v *mailView) startFolderPicker() tea.Cmd { v.notice = "Retrying labels…" return v.requestSources() } - selected := v.actionList().selectedPosting() + selected := v.actionPosting() if selected == nil { return nil } @@ -2292,6 +2313,18 @@ func (v *mailView) actionList() *contentList { return &v.postingList } +// actionPosting is the posting a key acts on: the open thread's own while one is on +// screen, and the list's selection otherwise. Reading a thread moves the cursor off +// the row it was opened from — the automatic mark-seen resorts it under the cover — +// so a picker opened from a thread has to be told which posting it is for rather +// than reading the list underneath. +func (v *mailView) actionPosting() *mail.Posting { + if v.inThread { + return v.fileablePosting() + } + return v.actionList().selectedPosting() +} + // actionSource is the box a thread action files out of: the Imbox while the Previously // Seen screen is open — its threads are the Imbox's whatever source the screen was // opened over — and the source on screen otherwise. @@ -2311,6 +2344,8 @@ func (v *mailView) imboxSource() *mail.Source { return nil } +const unfileableThreadNotice = "Can't file this thread from here" + // fileOpenThread files the thread on screen the way the same key files it on the // list, matching the web app's topic toolbar keeping its hotkeys live while a // thread is open. Only a thread opened from a filing list — a box or Previously @@ -2319,7 +2354,7 @@ func (v *mailView) imboxSource() *mail.Source { func (v *mailView) fileOpenThread(key string) tea.Cmd { posting := v.fileablePosting() if posting == nil { - v.notice = "Can't file this thread from here" + v.notice = unfileableThreadNotice return nil } move := v.postingAction(key, *posting, v.threadBoxKind) @@ -2347,6 +2382,21 @@ func (v *mailView) fileOpenThread(key string) tea.Cmd { } } +// openThreadPicker opens the label or move picker over the thread on screen. Both +// pickers file the posting they are given, so they answer to the same rule the +// filing keys do rather than opening over a thread there is nothing to file. +func (v *mailView) openThreadPicker(key string) tea.Cmd { + if v.fileablePosting() == nil { + v.notice = unfileableThreadNotice + return nil + } + if key == "b" || key == "B" { + return v.startFolderPicker() + } + v.startMove() + return nil +} + // fileablePosting is the posting the open thread files on: the snapshot taken when // the thread opened, standing in for a row the list may no longer hold — the // automatic mark-seen resorts it under the cover and clamps the cursor away, and a diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index b500a5c8..349246d8 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -616,6 +616,10 @@ func TestMailViewFilesOpenThread(t *testing.T) { {"reply later", "l", 4, "Thread moved to Reply Later"}, {"set aside", "a", 3, "Thread moved to Set Aside"}, {"set aside uppercase", "A", 3, "Thread moved to Set Aside"}, + {"feed", "d", 2, "Thread moved to The Feed"}, + {"feed uppercase", "D", 2, "Thread moved to The Feed"}, + {"paper trail", "p", 5, "Thread moved to Paper Trail"}, + {"paper trail uppercase", "P", 5, "Thread moved to Paper Trail"}, } for _, tt := range tests { @@ -654,6 +658,117 @@ func TestMailViewFilesOpenThread(t *testing.T) { } } +// Opening an unseen thread marks it seen, so u has something to undo. The snapshot +// the thread files on is taken before that lands and has to follow it, or the key +// answers that the thread it just marked seen is already unseen. +func TestMailViewMarksTheOpenThreadUnseen(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + marking, _ := v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + v.Update(runCmd(marking)) + + done, ok := runCmd(v.HandleContentKey(keyPress("u"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("unseen command returned %#v", done) + } + if recorded.path != "/postings/unseen.json" { + t.Errorf("request = %s %s, want POST /postings/unseen.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) + } + if !v.inThread { + t.Error("marking unseen should leave the thread open: it has not gone anywhere") + } +} + +// The same-box guard reaches the open thread, so a key naming the box the thread is +// already in says so rather than sending a move that would do nothing. +func TestMailViewRefusesToFileTheOpenThreadIntoItsOwnBox(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + + if cmd := v.HandleContentKey(keyPress("i")); cmd != nil { + t.Errorf("moving to the box it is in returned %#v, want nothing", runCmd(cmd)) + } + if v.notice != "Already in Imbox" { + t.Errorf("notice = %q, want the already-there explanation", v.notice) + } + if recorded.path == "/postings/moves.json" { + t.Error("a refused move still asked the server to move something") + } +} + +func TestMailViewOpensThePickersOverTheOpenThread(t *testing.T) { + t.Run("labels", func(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + + v.HandleContentKey(keyPress("b")) + picker, ok := v.modal.(*folderPicker) + if !ok { + t.Fatalf("modal = %#v, want the label picker", v.modal) + } + if picker.posting.ID != 100 { + t.Errorf("picker posting = %d, want the thread's own 100", picker.posting.ID) + } + }) + + t.Run("move", func(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + + v.HandleContentKey(keyPress("v")) + picker, ok := v.modal.(*movePicker) + if !ok { + t.Fatalf("modal = %#v, want the move picker", v.modal) + } + if picker.postingID != 100 { + t.Errorf("picker posting = %d, want the thread's own 100", picker.postingID) + } + }) +} + +// A picker aims at the thread on screen, not at the row the list's cursor has moved +// on to: opening a thread marks it seen, which resorts it under the cover. +func TestMailViewPickersAimAtTheThreadNotTheListCursor(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + v.postingList.moveDown() + + v.HandleContentKey(keyPress("b")) + picker, ok := v.modal.(*folderPicker) + if !ok { + t.Fatalf("modal = %#v, want the label picker", v.modal) + } + if picker.posting.ID != 100 { + t.Errorf("picker posting = %d, want the open thread's 100 rather than the moved cursor", picker.posting.ID) + } +} + +func TestMailViewRefusesThePickersWithoutAFileableThread(t *testing.T) { + for _, key := range []string{"b", "v"} { + t.Run(key, func(t *testing.T) { + v := mailWithPostings() + v.searchActive = true + v.searchList.setPostings([]mail.Posting{{ID: 10, TopicID: 100, Name: "Hello world"}}) + v.inThread = true + v.topicID = 100 + v.threadPosting = mail.Posting{ID: 10, TopicID: 100} + + v.HandleContentKey(keyPress(key)) + if v.modal != nil { + t.Errorf("modal = %#v, want no picker over a thread with no row to file", v.modal) + } + if v.notice != "Can't file this thread from here" { + t.Errorf("notice = %q, want the filing explanation", v.notice) + } + }) + } +} + func TestMailViewFilesOpenThreadAfterMarkSeenCoversItsRow(t *testing.T) { v, recorded := mailWithTestServer(t, http.StatusNoContent) v.postingList.setCover(coverTopo)