From d581aed7f7c96704537350ac6168bf3972cd8e73 Mon Sep 17 00:00:00 2001 From: FromSi Date: Mon, 11 May 2026 01:09:50 +0500 Subject: [PATCH] feat: select composer attachments (#1259) ## What? Added selectable attachment navigation in the composer. Attachments are now shown as a focused list with a cursor, and `backspace`, `delete`, or `d` removes the selected attachment instead of always removing the last one. ## Why? Previously, when multiple files were attached, the composer only allowed removing the most recently added attachment. This makes it possible to choose and remove any specific attachment without deleting newer ones first. Closes #543 --- config/default_keybinds.json | 3 +- config/keybinds.go | 4 +- tui/composer.go | 108 ++++++++++++++++++++++++++--------- tui/composer_test.go | 42 ++++++++++++++ 4 files changed, 128 insertions(+), 29 deletions(-) diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 6a325ef617dff949cd449f5fa2c710e5491bc628..19b4ec092ff772e578e30afcddb1eba888e14e8f 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -31,7 +31,8 @@ "composer": { "external_editor": "ctrl+e", "next_field": "tab", - "prev_field": "shift+tab" + "prev_field": "shift+tab", + "delete": "d" }, "folder": { "next_folder": "tab", diff --git a/config/keybinds.go b/config/keybinds.go index a9053f56b932b8b62742da07a21f2ada7d758a74..945f184043d078717e177d985a96cd5c88a573c6 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -61,6 +61,7 @@ type ComposerKeys struct { ExternalEditor string `json:"external_editor"` NextField string `json:"next_field"` PrevField string `json:"prev_field"` + Delete string `json:"delete"` } type FolderKeys struct { @@ -105,7 +106,7 @@ func LoadKeybindsFromDir(cfgDir string) error { return nil } - var kb KeybindsConfig + kb := defaultKeybinds() if err := json.Unmarshal(data, &kb); err != nil { return fmt.Errorf("keybinds: parse %s: %w", path, err) } @@ -167,6 +168,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "external_editor": kb.Composer.ExternalEditor, "next_field": kb.Composer.NextField, "prev_field": kb.Composer.PrevField, + "delete": kb.Composer.Delete, }) check("folder", map[string]string{ "next_folder": kb.Folder.NextFolder, diff --git a/tui/composer.go b/tui/composer.go index 8614cb1d79a90740d8719d1c7fa0947af63b59c2..74ae6b2716c30b96df95be148796e0c9177ae025 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -47,20 +47,21 @@ const ( // Composer model holds the state of the email composition UI. type Composer struct { - focusIndex int - toInput textinput.Model - ccInput textinput.Model - bccInput textinput.Model - subjectInput textinput.Model - bodyInput textarea.Model - signatureInput textarea.Model - attachmentPaths []string - attachmentNames map[string]string - encryptSMIME bool - width int - height int - confirmingExit bool - hideTips bool + focusIndex int + toInput textinput.Model + ccInput textinput.Model + bccInput textinput.Model + subjectInput textinput.Model + bodyInput textarea.Model + signatureInput textarea.Model + attachmentPaths []string + attachmentNames map[string]string + attachmentCursor int + encryptSMIME bool + width int + height int + confirmingExit bool + hideTips bool // Multi-account support accounts []config.Account @@ -249,6 +250,31 @@ func (m *Composer) attachmentDisplayName(path string) string { return filepath.Base(path) } +func (m *Composer) clampAttachmentCursor() { + if len(m.attachmentPaths) == 0 { + m.attachmentCursor = 0 + return + } + if m.attachmentCursor < 0 { + m.attachmentCursor = 0 + } + if m.attachmentCursor >= len(m.attachmentPaths) { + m.attachmentCursor = len(m.attachmentPaths) - 1 + } +} + +func (m *Composer) removeSelectedAttachment() { + if len(m.attachmentPaths) == 0 { + return + } + + m.clampAttachmentCursor() + idx := m.attachmentCursor + delete(m.attachmentNames, m.attachmentPaths[idx]) + m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...) + m.clampAttachmentCursor() +} + func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd @@ -299,6 +325,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.attachmentNames[newPath] = formatAttachmentName(newPath) } } + m.clampAttachmentCursor() return m, nil case tea.KeyPressMsg: @@ -413,6 +440,18 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } kb := config.Keybinds + attachmentPathSize := len(m.attachmentPaths) + if m.focusIndex == focusAttachment && attachmentPathSize > 0 { + switch msg.String() { + case "up", kb.Global.NavUp: + m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize + return m, nil + case "down", kb.Global.NavDown: + m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize + return m, nil + } + } + switch msg.String() { case kb.Global.Quit: return m, tea.Quit @@ -470,10 +509,9 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) - case "backspace", "delete", "d": + case kb.Composer.Delete: if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 { - delete(m.attachmentNames, m.attachmentPaths[len(m.attachmentPaths)-1]) - m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1] + m.removeSelectedAttachment() return m, nil } @@ -584,6 +622,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Composer) View() tea.View { var composerView strings.Builder var button string + ck := config.Keybinds.Composer if m.focusIndex == focusSend { button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]") @@ -626,16 +665,25 @@ func (m *Composer) View() tea.View { attachmentField = blurredStyle.Render(fmt.Sprintf(" %s %s", t("composer.attachments"), attachmentText)) } } else { - var names []string - for _, p := range m.attachmentPaths { - names = append(names, m.attachmentDisplayName(p)) - } - attachmentText := strings.Join(names, ", ") + var b strings.Builder + headerPrefix := " " + headerStyle := blurredStyle if m.focusIndex == focusAttachment { - attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText)) - } else { - attachmentField = blurredStyle.Render(fmt.Sprintf(" %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText)) + headerPrefix = "> " + headerStyle = focusedStyle + } + b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths)))) + for i, p := range m.attachmentPaths { + cursor := " " + style := blurredStyle + if m.focusIndex == focusAttachment && i == m.attachmentCursor { + cursor = " > " + style = focusedStyle + } + b.WriteString("\n") + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p)))) } + attachmentField = b.String() } encToggle := "[ ]" @@ -690,7 +738,7 @@ func (m *Composer) View() tea.View { case focusSignature: tip = "Your email signature. This will be appended to the end of the email." case focusAttachment: - tip = "Enter: add file • backspace/d: remove last attachment" + tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete) case focusEncryptSMIME: tip = "Press Space or Enter to toggle S/MIME encryption on or off." case focusSend: @@ -708,10 +756,15 @@ func (m *Composer) View() tea.View { signatureLabel, m.signatureInput.View(), attachmentStyle.Render(attachmentField), + } + if len(m.attachmentPaths) > 0 { + composerViewElements = append(composerViewElements, "") + } + composerViewElements = append(composerViewElements, smimeToggleStyle.Render(encField), button, "", - } + ) if !m.hideTips && tip != "" { composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip)) @@ -971,6 +1024,7 @@ func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTip for _, path := range m.attachmentPaths { m.attachmentNames[path] = formatAttachmentName(path) } + m.clampAttachmentCursor() if m.isCatchAllAccount() && draft.FromOverride != "" { m.fromInput.SetValue(draft.FromOverride) } diff --git a/tui/composer_test.go b/tui/composer_test.go index d08c51e64230b7c9fb32d73576340429d8ff1079..ffc10e0be32663a85e82533b069ed858cd17e705 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -249,6 +249,48 @@ func TestFormatAttachmentNameMissingFile(t *testing.T) { } } +func TestComposerAttachmentSelectionAndRemoval(t *testing.T) { + composer := NewComposer("", "", "", "", false) + composer.focusIndex = focusAttachment + composer.attachmentPaths = []string{"/tmp/a.txt", "/tmp/b.txt", "/tmp/c.txt"} + composer.attachmentNames = map[string]string{ + "/tmp/a.txt": "a.txt", + "/tmp/b.txt": "b.txt", + "/tmp/c.txt": "c.txt", + } + + model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + composer = model.(*Composer) + if composer.attachmentCursor != 1 { + t.Fatalf("Expected attachmentCursor 1 after Down, got %d", composer.attachmentCursor) + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + composer = model.(*Composer) + + want := []string{"/tmp/a.txt", "/tmp/c.txt"} + if len(composer.attachmentPaths) != len(want) { + t.Fatalf("Expected %d attachments after removal, got %d", len(want), len(composer.attachmentPaths)) + } + for i, path := range want { + if composer.attachmentPaths[i] != path { + t.Fatalf("attachmentPaths[%d] = %q, want %q", i, composer.attachmentPaths[i], path) + } + } + if _, ok := composer.attachmentNames["/tmp/b.txt"]; ok { + t.Fatal("Expected removed attachment display name to be deleted") + } + if composer.attachmentCursor != 1 { + t.Fatalf("Expected cursor to stay on the next attachment, got %d", composer.attachmentCursor) + } + + model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + composer = model.(*Composer) + if composer.attachmentCursor != 0 { + t.Fatalf("Expected attachmentCursor to wrap to 0 after Down, got %d", composer.attachmentCursor) + } +} + // TestComposerGetFromAddress verifies the from address formatting. func TestComposerGetFromAddress(t *testing.T) { t.Run("With name", func(t *testing.T) {