Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 55 additions & 5 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions internal/tui/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading