From 0ab333a8d139b6142e476c3daff0482431bc8351 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Tue, 14 Apr 2026 13:16:25 +0400 Subject: [PATCH] feat: visual mode (#495) --- backend/backend.go | 5 + backend/imap/imap.go | 12 +++ backend/jmap/jmap.go | 30 ++++++ backend/pop3/pop3.go | 18 ++++ docs/docs/usage.md | 24 +++++ fetcher/fetcher.go | 92 +++++++++++++++++ main.go | 203 ++++++++++++++++++++++++++++++++++++ tui/folder_inbox.go | 69 ++++++++++--- tui/inbox.go | 238 +++++++++++++++++++++++++++++++++++++++++-- tui/messages.go | 29 ++++++ tui/theme.go | 1 + 11 files changed, 699 insertions(+), 22 deletions(-) diff --git a/backend/backend.go b/backend/backend.go index 2018e31d72c58aae762e9d2bbd25c725e864653c..067a51215f8b323cac0d145e1c5c29155c834b17 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -33,6 +33,11 @@ type EmailWriter interface { DeleteEmail(ctx context.Context, folder string, uid uint32) error ArchiveEmail(ctx context.Context, folder string, uid uint32) error MoveEmail(ctx context.Context, uid uint32, srcFolder, dstFolder string) error + + // Batch operations + DeleteEmails(ctx context.Context, folder string, uids []uint32) error + ArchiveEmails(ctx context.Context, folder string, uids []uint32) error + MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error } // EmailSender sends outgoing email. diff --git a/backend/imap/imap.go b/backend/imap/imap.go index cce7fc32a55f8364d69a2e93804c311f2a9a1611..894db53fba4d64b1fb3a5c9975972e9294eb66b4 100644 --- a/backend/imap/imap.go +++ b/backend/imap/imap.go @@ -63,6 +63,18 @@ func (p *Provider) MoveEmail(_ context.Context, uid uint32, srcFolder, dstFolder return fetcher.MoveEmailToFolder(p.account, uid, srcFolder, dstFolder) } +func (p *Provider) DeleteEmails(_ context.Context, folder string, uids []uint32) error { + return fetcher.DeleteEmailsFromMailbox(p.account, folder, uids) +} + +func (p *Provider) ArchiveEmails(_ context.Context, folder string, uids []uint32) error { + return fetcher.ArchiveEmailsFromMailbox(p.account, folder, uids) +} + +func (p *Provider) MoveEmails(_ context.Context, uids []uint32, srcFolder, dstFolder string) error { + return fetcher.MoveEmailsToFolder(p.account, uids, srcFolder, dstFolder) +} + func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error { return sender.SendEmail( p.account, msg.To, msg.Cc, msg.Bcc, diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index 598a8fa4740a75e5681b4a58360ab25cad76d7ff..2dd45e12dd64370314a9bc0700b06fb6cbe97084 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -380,6 +380,36 @@ func (p *Provider) MoveEmail(_ context.Context, uid uint32, _, dstFolder string) return err } +func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error { + // JMAP can handle batch operations - loop through for now + for _, uid := range uids { + if err := p.DeleteEmail(ctx, folder, uid); err != nil { + return err + } + } + return nil +} + +func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error { + // JMAP can handle batch operations - loop through for now + for _, uid := range uids { + if err := p.ArchiveEmail(ctx, folder, uid); err != nil { + return err + } + } + return nil +} + +func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error { + // JMAP can handle batch operations - loop through for now + for _, uid := range uids { + if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil { + return err + } + } + return nil +} + func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error { // Build the email as a draft first toAddrs := make([]*mail.Address, len(msg.To)) diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index ff76b1e424f7b6093b58819b8735fe9dfb54838b..7283f75386e2532ad34b74e4926181ef35ee787f 100644 --- a/backend/pop3/pop3.go +++ b/backend/pop3/pop3.go @@ -205,6 +205,24 @@ func (p *Provider) MoveEmail(_ context.Context, _ uint32, _, _ string) error { return backend.ErrNotSupported } +func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error { + // POP3 doesn't support batch - loop through individual operations + for _, uid := range uids { + if err := p.DeleteEmail(ctx, folder, uid); err != nil { + return err + } + } + return nil +} + +func (p *Provider) ArchiveEmails(_ context.Context, _ string, _ []uint32) error { + return backend.ErrNotSupported +} + +func (p *Provider) MoveEmails(_ context.Context, _ []uint32, _, _ string) error { + return backend.ErrNotSupported +} + func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error { return sender.SendEmail( p.account, msg.To, msg.Cc, msg.Bcc, diff --git a/docs/docs/usage.md b/docs/docs/usage.md index 65d2d2ce8f1cd087818a42e4438e7e359e6d8dcd..47a811ec51e210120989e33ee93b03d499d360f2 100644 --- a/docs/docs/usage.md +++ b/docs/docs/usage.md @@ -31,8 +31,32 @@ On first launch, Matcha will prompt you to configure an email account. You'll ne - `r` - Refresh inbox - `d` - Delete selected email - `a` - Archive selected email +- `v` - Enter visual mode (multi-select) - `Esc` - Back to main menu +#### Visual Mode (Multi-Select) + +Visual mode allows you to select multiple emails and perform batch operations, similar to Vim's visual mode: + +- `v` - Enter visual mode (selects current email) +- `↑/↓` or `j/k` - Expand/contract selection +- `d` - Delete all selected emails +- `a` - Archive all selected emails +- `m` - Move all selected emails to a folder +- `v` or `Esc` - Exit visual mode + +**Visual indicators:** +- `>` - Cursor position +- `*` - Selected email (not cursor) +- `>*` - Selected email with cursor +- Title shows: "Inbox - VISUAL (N selected)" + +**Features:** +- Efficient batch operations using single IMAP commands +- Automatic prevention of cross-account selections +- Works in both inbox and folder views +- Visual mode disabled during search/filtering for safety + ### Email View - `↑/↓` or `j/k` - Scroll email content diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index c477a04fe40a589448813d30ada5299b19f37534..7e0d386f72288bf5d0a1ec263a2ecf03a0304977 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -1140,6 +1140,98 @@ func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32 return c.UidMove(seqSet, archiveMailbox) } +// Batch operations for multiple emails + +// DeleteEmailsFromMailbox deletes multiple emails from a mailbox (batch operation) +func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error { + if len(uids) == 0 { + return nil + } + + c, err := connect(account) + if err != nil { + return err + } + defer c.Logout() + + if _, err := c.Select(mailbox, false); err != nil { + return err + } + + seqSet := new(imap.SeqSet) + for _, uid := range uids { + seqSet.AddNum(uid) + } + + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.DeletedFlag} + + if err := c.UidStore(seqSet, item, flags, nil); err != nil { + return err + } + + return c.Expunge(nil) +} + +// ArchiveEmailsFromMailbox archives multiple emails from a mailbox (batch operation) +func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error { + if len(uids) == 0 { + return nil + } + + c, err := connect(account) + if err != nil { + return err + } + defer c.Logout() + + var archiveMailbox string + switch account.ServiceProvider { + case "gmail": + archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr) + if err != nil { + archiveMailbox = "[Gmail]/All Mail" + } + default: + archiveMailbox = "Archive" + } + + if _, err := c.Select(mailbox, false); err != nil { + return err + } + + seqSet := new(imap.SeqSet) + for _, uid := range uids { + seqSet.AddNum(uid) + } + + return c.UidMove(seqSet, archiveMailbox) +} + +// MoveEmailsToFolder moves multiple emails to a different folder (batch operation) +func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, destFolder string) error { + if len(uids) == 0 { + return nil + } + + c, err := connect(account) + if err != nil { + return err + } + defer c.Logout() + + if _, err := c.Select(sourceFolder, false); err != nil { + return err + } + + seqSet := new(imap.SeqSet) + for _, uid := range uids { + seqSet.AddNum(uid) + } + + return c.UidMove(seqSet, destFolder) +} + // Convenience wrappers defaulting to INBOX for existing call sites. func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) { diff --git a/main.go b/main.go index c63d30601f28a85e94b2ca567cf62018860d0e43..7c0832d3b99907010af4983c67d88c8bcb62ddc7 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "context" "encoding/base64" "encoding/json" "flag" @@ -1268,6 +1269,94 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() + case tui.BatchDeleteEmailsMsg: + tui.ClearKittyGraphics() + m.previousModel = m.current + count := len(msg.UIDs) + m.current = tui.NewStatus(fmt.Sprintf("Deleting %d emails...", count)) + + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + if m.folderInbox != nil { + m.current = m.folderInbox + } + return m, nil + } + + folderName := "INBOX" + if m.folderInbox != nil { + folderName = m.folderInbox.GetCurrentFolder() + } + + return m, tea.Batch( + m.current.Init(), + m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count), + ) + + case tui.BatchArchiveEmailsMsg: + tui.ClearKittyGraphics() + m.previousModel = m.current + count := len(msg.UIDs) + m.current = tui.NewStatus(fmt.Sprintf("Archiving %d emails...", count)) + + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + if m.folderInbox != nil { + m.current = m.folderInbox + } + return m, nil + } + + folderName := "INBOX" + if m.folderInbox != nil { + folderName = m.folderInbox.GetCurrentFolder() + } + + return m, tea.Batch( + m.current.Init(), + m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count), + ) + + case tui.BatchMoveEmailsMsg: + if m.config == nil { + return m, nil + } + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + return m, nil + } + + count := len(msg.UIDs) + m.previousModel = m.current + m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count)) + + return m, tea.Batch( + m.current.Init(), + m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count), + ) + + case tui.BatchEmailActionDoneMsg: + if msg.Err != nil { + log.Printf("Batch %s failed: %v", msg.Action, msg.Err) + m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err)) + return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return tui.RestoreViewMsg{} + }) + } + + // Success - show brief confirmation + successMsg := fmt.Sprintf("%d emails %sd successfully", msg.SuccessCount, msg.Action) + if msg.FailureCount > 0 { + successMsg = fmt.Sprintf("%d of %d emails %sd (%d failed)", + msg.SuccessCount, msg.Count, msg.Action, msg.FailureCount) + } + + m.current = tui.NewStatus(successMsg) + + return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg { + return tui.RestoreViewMsg{} + }) + case tui.DownloadAttachmentMsg: m.previousModel = m.current m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename)) @@ -2159,6 +2248,120 @@ func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string } } +func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + p := m.getProvider(account) + if p == nil { + return tui.BatchEmailActionDoneMsg{ + Count: count, + Action: "delete", + Err: fmt.Errorf("provider not found"), + } + } + + err := p.DeleteEmails(ctx, folderName, uids) + + // Remove emails from local state on success + if err == nil && m.folderInbox != nil { + m.folderInbox.GetInbox().RemoveEmails(uids, accountID) + } + + successCount := count + failureCount := 0 + if err != nil { + failureCount = count + successCount = 0 + } + + return tui.BatchEmailActionDoneMsg{ + Count: count, + SuccessCount: successCount, + FailureCount: failureCount, + Action: "delete", + Mailbox: mailbox, + Err: err, + } + } +} + +func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + p := m.getProvider(account) + if p == nil { + return tui.BatchEmailActionDoneMsg{ + Count: count, + Action: "archive", + Err: fmt.Errorf("provider not found"), + } + } + + err := p.ArchiveEmails(ctx, folderName, uids) + + if err == nil && m.folderInbox != nil { + m.folderInbox.GetInbox().RemoveEmails(uids, accountID) + } + + successCount := count + failureCount := 0 + if err != nil { + failureCount = count + successCount = 0 + } + + return tui.BatchEmailActionDoneMsg{ + Count: count, + SuccessCount: successCount, + FailureCount: failureCount, + Action: "archive", + Mailbox: mailbox, + Err: err, + } + } +} + +func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + p := m.getProvider(account) + if p == nil { + return tui.BatchEmailActionDoneMsg{ + Count: count, + Action: "move", + Err: fmt.Errorf("provider not found"), + } + } + + err := p.MoveEmails(ctx, uids, sourceFolder, destFolder) + + if err == nil && m.folderInbox != nil { + m.folderInbox.GetInbox().RemoveEmails(uids, accountID) + } + + successCount := count + failureCount := 0 + if err != nil { + failureCount = count + successCount = 0 + } + + return tui.BatchEmailActionDoneMsg{ + Count: count, + SuccessCount: successCount, + FailureCount: failureCount, + Action: "move", + Err: err, + } + } +} + func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd { return func() tea.Msg { err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder) diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 5d9145422962446bbfbe165c95db230876ec95ef..e48b219f085234dd542fee969f74bb9af5fe9b40 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -72,7 +72,8 @@ type FolderInbox struct { // Move-to-folder overlay state movingEmail bool moveTargetIdx int - moveUID uint32 + moveUID uint32 // Legacy: single UID + moveUIDs []uint32 // Batch: multiple UIDs moveAccountID string moveSourceFolder string } @@ -151,14 +152,31 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.switchFolder() case "m": // Start move-to-folder flow - selectedItem, ok := m.inbox.list.SelectedItem().(item) - if ok { + if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 { + // Batch move m.movingEmail = true m.moveTargetIdx = 0 - m.moveUID = selectedItem.uid - m.moveAccountID = selectedItem.accountID + m.moveUIDs = make([]uint32, len(m.inbox.selectionOrder)) + copy(m.moveUIDs, m.inbox.selectionOrder) + m.moveAccountID = "" + for _, acctID := range m.inbox.selectedUIDs { + m.moveAccountID = acctID + break + } m.moveSourceFolder = m.currentFolder return m, nil + } else { + // Single move + selectedItem, ok := m.inbox.list.SelectedItem().(item) + if ok { + m.movingEmail = true + m.moveTargetIdx = 0 + m.moveUID = selectedItem.uid + m.moveUIDs = []uint32{selectedItem.uid} + m.moveAccountID = selectedItem.accountID + m.moveSourceFolder = m.currentFolder + return m, nil + } } } @@ -272,12 +290,35 @@ func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { if len(choices) > 0 && m.moveTargetIdx < len(choices) { destFolder := choices[m.moveTargetIdx] m.movingEmail = false - return m, func() tea.Msg { - return MoveEmailToFolderMsg{ - UID: m.moveUID, - AccountID: m.moveAccountID, - SourceFolder: m.moveSourceFolder, - DestFolder: destFolder, + + if len(m.moveUIDs) > 1 { + // Batch move + uids := m.moveUIDs + m.moveUIDs = nil + + // Exit visual mode in inbox + m.inbox.visualMode = false + m.inbox.selectedUIDs = make(map[uint32]string) + m.inbox.selectionOrder = []uint32{} + m.inbox.updateListTitle() + + return m, func() tea.Msg { + return BatchMoveEmailsMsg{ + UIDs: uids, + AccountID: m.moveAccountID, + SourceFolder: m.moveSourceFolder, + DestFolder: destFolder, + } + } + } else { + // Single move + return m, func() tea.Msg { + return MoveEmailToFolderMsg{ + UID: m.moveUID, + AccountID: m.moveAccountID, + SourceFolder: m.moveSourceFolder, + DestFolder: destFolder, + } } } } @@ -386,7 +427,11 @@ func (m *FolderInbox) renderWithMoveOverlay(content string) string { } var b strings.Builder - b.WriteString(moveOverlayTitleStyle.Render("Move to folder:")) + title := "Move to folder:" + if len(m.moveUIDs) > 1 { + title = fmt.Sprintf("Move %d emails to folder:", len(m.moveUIDs)) + } + b.WriteString(moveOverlayTitleStyle.Render(title)) b.WriteString("\n") for i, folder := range choices { diff --git a/tui/inbox.go b/tui/inbox.go index 28bc648cdd6ce04d21e6263b117dc9723544d4da..b9b20dbd607b910843b79eb7bfb3338c916b986d 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -27,6 +27,7 @@ var ( var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) +var visualSelectedStyle lipgloss.Style type item struct { title, desc string @@ -42,7 +43,9 @@ func (i item) Title() string { return i.title } func (i item) Description() string { return i.desc } func (i item) FilterValue() string { return i.title + " " + i.desc } -type itemDelegate struct{} +type itemDelegate struct { + inbox *Inbox +} func (d itemDelegate) Height() int { return 1 } func (d itemDelegate) Spacing() int { return 0 } @@ -119,10 +122,36 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list padding = 1 } + // Check if this item is in visual selection + inVisualSelection := false + if d.inbox != nil && d.inbox.visualMode { + _, inVisualSelection = d.inbox.selectedUIDs[i.uid] + } + fn := itemStyle.Render - if index == m.Index() { + if inVisualSelection && !isSelected { + // Item is in visual selection but not the cursor + fn = func(s ...string) string { + return visualSelectedStyle.Render("* " + s[0]) + } + cursorWidth = 2 // "* " prefix + padding = listWidth - lipgloss.Width(str) - dateWidth - cursorWidth + if padding < 1 { + padding = 1 + } + } else if isSelected { + // Cursor position (may also be in selection) + prefix := "> " + if inVisualSelection { + prefix = ">*" + } fn = func(s ...string) string { - return selectedItemStyle.Render("> " + s[0]) + return selectedItemStyle.Render(prefix + s[0]) + } + cursorWidth = len(prefix) + padding = listWidth - lipgloss.Width(str) - dateWidth - cursorWidth + if padding < 1 { + padding = 1 } } @@ -219,6 +248,12 @@ type Inbox struct { extraShortHelpKeys []key.Binding pluginStatus string // Persistent status text set by plugins pluginKeyBindings []PluginKeyBinding + + // Visual mode state (Vim-style multi-select) + visualMode bool // Whether visual mode is active + visualAnchor int // Index where visual selection started + selectedUIDs map[uint32]string // map[uid]accountID for selected emails + selectionOrder []uint32 // Ordered list of UIDs for display } func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { @@ -275,6 +310,9 @@ func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mail currentAccountID: "", emailCountByAcct: emailCountByAcct, mailbox: mailbox, + visualMode: false, + selectedUIDs: make(map[uint32]string), + selectionOrder: []uint32{}, } inbox.updateList() @@ -330,7 +368,7 @@ func (m *Inbox) updateList() { } } - l := list.New(items, itemDelegate{}, 20, 14) + l := list.New(items, itemDelegate{inbox: m}, 20, 14) l.Title = m.getTitle() l.SetShowStatusBar(true) l.SetFilteringEnabled(true) @@ -340,6 +378,7 @@ func (m *Inbox) updateList() { l.SetStatusBarItemName("email", "emails") l.AdditionalShortHelpKeys = func() []key.Binding { bindings := []key.Binding{ + key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual mode")), key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")), key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")), key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")), @@ -437,9 +476,55 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if m.list.FilterState() == list.Filtering { + // Don't allow visual mode while filtering + if m.visualMode { + m.visualMode = false + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} + m.updateListTitle() + } break } switch keypress := msg.String(); keypress { + case "v": + if !m.visualMode { + // Enter visual mode + m.visualMode = true + m.visualAnchor = m.list.Index() + selectedItem, ok := m.list.SelectedItem().(item) + if ok { + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} + m.selectedUIDs[selectedItem.uid] = selectedItem.accountID + m.selectionOrder = append(m.selectionOrder, selectedItem.uid) + } + m.updateListTitle() + } else { + // Exit visual mode + m.visualMode = false + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} + m.updateListTitle() + } + return m, nil + case "esc": + if m.visualMode { + // Exit visual mode on ESC + m.visualMode = false + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} + m.updateListTitle() + return m, nil + } + case "j", "down", "k", "up": + if m.visualMode { + // Let the list handle navigation first + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + // Then update selection + m.updateVisualSelection() + return m, cmd + } case "left", "h": if len(m.tabs) > 1 { m.activeTabIndex-- @@ -447,6 +532,10 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activeTabIndex = len(m.tabs) - 1 } m.currentAccountID = m.tabs[m.activeTabIndex].ID + // Exit visual mode when switching tabs + m.visualMode = false + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} m.updateList() return m, nil } @@ -457,21 +546,69 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activeTabIndex = 0 } m.currentAccountID = m.tabs[m.activeTabIndex].ID + // Exit visual mode when switching tabs + m.visualMode = false + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} m.updateList() return m, nil } case "d": - selectedItem, ok := m.list.SelectedItem().(item) - if ok { + if m.visualMode && len(m.selectedUIDs) > 0 { + // Batch delete + uids := make([]uint32, len(m.selectionOrder)) + copy(uids, m.selectionOrder) + accountID := "" + for _, aid := range m.selectedUIDs { + accountID = aid // Get any account (all should be same in single-account selection) + break + } + + // Exit visual mode + m.visualMode = false + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} + m.updateListTitle() + return m, func() tea.Msg { - return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} + return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox} + } + } else { + // Single delete + selectedItem, ok := m.list.SelectedItem().(item) + if ok { + return m, func() tea.Msg { + return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} + } } } case "a": - selectedItem, ok := m.list.SelectedItem().(item) - if ok { + if m.visualMode && len(m.selectedUIDs) > 0 { + // Batch archive + uids := make([]uint32, len(m.selectionOrder)) + copy(uids, m.selectionOrder) + accountID := "" + for _, aid := range m.selectedUIDs { + accountID = aid + break + } + + // Exit visual mode + m.visualMode = false + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} + m.updateListTitle() + return m, func() tea.Msg { - return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} + return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox} + } + } else { + // Single archive + selectedItem, ok := m.list.SelectedItem().(item) + if ok { + return m, func() tea.Msg { + return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} + } } } case "r": @@ -739,6 +876,87 @@ func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) { m.updateList() } +// updateVisualSelection updates the selected UIDs based on anchor and current index +func (m *Inbox) updateVisualSelection() { + if !m.visualMode { + return + } + + currentIdx := m.list.Index() + start := m.visualAnchor + end := currentIdx + + if start > end { + start, end = end, start + } + + // Clear and rebuild selection + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} + + items := m.list.Items() + firstAccountID := "" + for i := start; i <= end && i < len(items); i++ { + if itm, ok := items[i].(item); ok { + // Ensure all selected emails are from the same account (prevent cross-account batch ops) + if firstAccountID == "" { + firstAccountID = itm.accountID + } + if itm.accountID != firstAccountID { + // Don't add emails from different accounts + continue + } + + if _, exists := m.selectedUIDs[itm.uid]; !exists { + m.selectedUIDs[itm.uid] = itm.accountID + m.selectionOrder = append(m.selectionOrder, itm.uid) + } + } + } + + m.updateListTitle() +} + +// updateListTitle updates the title to show selection count when in visual mode +func (m *Inbox) updateListTitle() { + if m.visualMode && len(m.selectedUIDs) > 0 { + baseTitle := m.getBaseTitle() + m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs)) + } else { + m.list.Title = m.getTitle() + } +} + +// RemoveEmails removes multiple emails by UID and account ID (batch operation) +func (m *Inbox) RemoveEmails(uids []uint32, accountID string) { + uidSet := make(map[uint32]bool) + for _, uid := range uids { + uidSet[uid] = true + } + + // Remove from account-specific list + if emails, ok := m.emailsByAccount[accountID]; ok { + var filtered []fetcher.Email + for _, e := range emails { + if !uidSet[e.UID] { + filtered = append(filtered, e) + } + } + m.emailsByAccount[accountID] = filtered + } + + // Remove from all emails list + var filteredAll []fetcher.Email + for _, e := range m.allEmails { + if !(uidSet[e.UID] && e.AccountID == accountID) { + filteredAll = append(filteredAll, e) + } + } + m.allEmails = filteredAll + + m.updateList() +} + // RemoveEmail removes an email by UID and account ID func (m *Inbox) RemoveEmail(uid uint32, accountID string) { // Remove from account-specific list diff --git a/tui/messages.go b/tui/messages.go index 0502d0b3bef1f2a63b5ee7eab92616a664630995..bb3921a6ea85ab07fcb12b48ada1978c603bdfb5 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -153,6 +153,35 @@ type EmailActionDoneMsg struct { Err error } +// Batch operation messages +type BatchDeleteEmailsMsg struct { + UIDs []uint32 + AccountID string + Mailbox MailboxKind +} + +type BatchArchiveEmailsMsg struct { + UIDs []uint32 + AccountID string + Mailbox MailboxKind +} + +type BatchMoveEmailsMsg struct { + UIDs []uint32 + AccountID string + SourceFolder string + DestFolder string +} + +type BatchEmailActionDoneMsg struct { + Count int + SuccessCount int + FailureCount int + Action string // "delete", "archive", or "move" + Mailbox MailboxKind + Err error +} + type GoToChoiceMenuMsg struct{} type DownloadAttachmentMsg struct { diff --git a/tui/theme.go b/tui/theme.go index 60a40bd03853214e9d2b9df6dc0de954a856f8c9..dcec7c06a4c3b6122e6e9615868238fb85f558b7 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -75,6 +75,7 @@ func RebuildStyles() { dateStyle = lipgloss.NewStyle().Foreground(t.MutedText) unreadEmailStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) readEmailStyle = lipgloss.NewStyle().Foreground(t.Secondary) + visualSelectedStyle = lipgloss.NewStyle().Background(t.AccentDark).Foreground(t.AccentText) // folder_inbox.go sidebarStyle = lipgloss.NewStyle().