diff --git a/main.go b/main.go index f371548cfc5316560fe3f5b4c605bee650c256e7..1d933274b212dca8709822a89ff9c963ecfa87aa 100644 --- a/main.go +++ b/main.go @@ -1923,6 +1923,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) folderSnap := slices.Clone(m.folderEmails[folderName]) + m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, []uint32{msg.UID}) m.removeEmailFromStores(msg.UID, msg.AccountID) if emails, ok := m.folderEmails[folderName]; ok { @@ -1973,6 +1974,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) folderSnap := slices.Clone(m.folderEmails[folderName]) + m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, []uint32{msg.UID}) m.removeEmailFromStores(msg.UID, msg.AccountID) if emails, ok := m.folderEmails[folderName]; ok { @@ -2050,6 +2052,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) folderSnap := slices.Clone(m.folderEmails[folderName]) + m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, msg.UIDs) for _, uid := range msg.UIDs { m.removeEmailFromStores(uid, msg.AccountID) } @@ -2104,6 +2107,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) folderSnap := slices.Clone(m.folderEmails[folderName]) + m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, msg.UIDs) for _, uid := range msg.UIDs { m.removeEmailFromStores(uid, msg.AccountID) } @@ -2347,6 +2351,23 @@ func (m *mainModel) restorePendingAction() { if m.folderInbox != nil { m.folderInbox.SetEmails(pa.folderSnap, m.config.Accounts) + // Restore the folder unread counter that was decremented when the action + // was applied, so the sidebar count is correct again after undo. + restored := false + for _, uid := range pa.uids { + for _, e := range pa.folderSnap { + if e.UID == uid && e.AccountID == pa.accountID { + if !e.IsRead { + m.folderInbox.IncrementUnreadCount(pa.folderName) + restored = true + } + break + } + } + } + if restored { + config.SaveAccountFolders(pa.accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec + } } go saveFolderEmailsToCache(pa.folderName, pa.folderSnap) } @@ -2544,6 +2565,42 @@ func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) { } } +// decrementFolderUnreadForRemoved updates the sidebar folder unread counter when +// emails are removed from a folder (delete/archive). It mirrors the read-path in +// markEmailAsReadInStores: each removed email that is currently unread decrements +// the folder's counter, so the count shown in the folder list updates immediately +// instead of only on the next fetch (issue #1404). +// +// It must be called before the email is removed from the stores, since it reads +// the email's unread status from folderEmails. +func (m *mainModel) decrementFolderUnreadForRemoved(folderName, accountID string, uids []uint32) { + if m.folderInbox == nil || len(uids) == 0 { + return + } + + emails, ok := m.folderEmails[folderName] + if !ok { + return + } + + decremented := false + for _, uid := range uids { + for _, e := range emails { + if e.UID == uid && e.AccountID == accountID { + if !e.IsRead { + m.folderInbox.DecrementUnreadCount(folderName) + decremented = true + } + break + } + } + } + + if decremented { + config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec + } +} + // pluginFlagCmds drains pending flag ops from plugins and returns the corresponding tea.Cmds. func (m *mainModel) pluginFlagCmds() []tea.Cmd { if m.plugins == nil { diff --git a/main_test.go b/main_test.go index a7ab18261fbc51a4c19a1bbba5033f517a1c59f3..e698f416c0b78611685bc65bdaac30e3a97c151a 100644 --- a/main_test.go +++ b/main_test.go @@ -6,7 +6,9 @@ import ( "testing" "unicode/utf8" + "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/tui" ) func TestSanitizeFilenameTruncatesCJKOnUTF8Boundary(t *testing.T) { @@ -61,6 +63,114 @@ func TestParseGlobalFlagsDoesNotConsumeSubcommandFlags(t *testing.T) { } } +// newFolderCounterModel builds a minimal mainModel whose INBOX folder holds the +// given emails and reports the given unread count. It is used to verify that the +// per-folder unread counter shown in the sidebar is updated immediately after +// delete/archive operations, without waiting for the next fetch. +func newFolderCounterModel(emails []fetcher.Email, unread int) *mainModel { + accountID := "acct-a" + cfg := &config.Config{ + Accounts: []config.Account{{ID: accountID, Email: "a@example.com"}}, + } + + fi := tui.NewFolderInbox([]string{folderInbox}, cfg.Accounts) + fi.SetUnreadCounts(map[string]int{folderInbox: unread}) + fi.SetEmails(emails, cfg.Accounts) + + byAcct := make(map[string][]fetcher.Email) + for _, e := range emails { + byAcct[e.AccountID] = append(byAcct[e.AccountID], e) + } + + m := &mainModel{ + config: cfg, + folderInbox: fi, + current: fi, + emails: emails, + emailsByAcct: byAcct, + folderEmails: map[string][]fetcher.Email{folderInbox: emails}, + } + return m +} + +// TestDeleteUnreadEmailUpdatesFolderCounter verifies that deleting an unread +// email immediately decrements the folder's unread counter in the sidebar, +// rather than waiting for the next fetch (issue #1404). +func TestDeleteUnreadEmailUpdatesFolderCounter(t *testing.T) { + email := fetcher.Email{UID: 42, AccountID: "acct-a", IsRead: false} + m := newFolderCounterModel([]fetcher.Email{email}, 1) + + m.Update(tui.DeleteEmailMsg{UID: 42, AccountID: "acct-a", Mailbox: folderInbox}) + + if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 { + t.Fatalf("after deleting an unread email, folder unread count = %d, want 0", got) + } +} + +// TestArchiveUnreadEmailUpdatesFolderCounter verifies the same immediate update +// for archive operations. +func TestArchiveUnreadEmailUpdatesFolderCounter(t *testing.T) { + email := fetcher.Email{UID: 7, AccountID: "acct-a", IsRead: false} + m := newFolderCounterModel([]fetcher.Email{email}, 1) + + m.Update(tui.ArchiveEmailMsg{UID: 7, AccountID: "acct-a", Mailbox: folderInbox}) + + if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 { + t.Fatalf("after archiving an unread email, folder unread count = %d, want 0", got) + } +} + +// TestBatchDeleteUnreadEmailsUpdatesFolderCounter verifies that batch delete +// decrements the folder counter once per unread email removed, while leaving +// already-read emails untouched. +func TestBatchDeleteUnreadEmailsUpdatesFolderCounter(t *testing.T) { + emails := []fetcher.Email{ + {UID: 1, AccountID: "acct-a", IsRead: false}, + {UID: 2, AccountID: "acct-a", IsRead: false}, + {UID: 3, AccountID: "acct-a", IsRead: true}, + } + m := newFolderCounterModel(emails, 2) + + m.Update(tui.BatchDeleteEmailsMsg{UIDs: []uint32{1, 2, 3}, AccountID: "acct-a", Mailbox: folderInbox}) + + if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 { + t.Fatalf("after batch-deleting two unread emails, folder unread count = %d, want 0", got) + } +} + +// TestDeleteReadEmailLeavesFolderCounter verifies that deleting an already-read +// email does not change the folder unread counter. +func TestDeleteReadEmailLeavesFolderCounter(t *testing.T) { + emails := []fetcher.Email{ + {UID: 10, AccountID: "acct-a", IsRead: true}, + {UID: 11, AccountID: "acct-a", IsRead: false}, + } + m := newFolderCounterModel(emails, 1) + + m.Update(tui.DeleteEmailMsg{UID: 10, AccountID: "acct-a", Mailbox: folderInbox}) + + if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 1 { + t.Fatalf("after deleting a read email, folder unread count = %d, want 1", got) + } +} + +// TestUndoDeleteRestoresFolderCounter verifies that undoing a delete restores +// the folder unread counter that was decremented when the email was deleted. +func TestUndoDeleteRestoresFolderCounter(t *testing.T) { + email := fetcher.Email{UID: 99, AccountID: "acct-a", IsRead: false} + m := newFolderCounterModel([]fetcher.Email{email}, 1) + + m.Update(tui.DeleteEmailMsg{UID: 99, AccountID: "acct-a", Mailbox: folderInbox}) + if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 { + t.Fatalf("after delete, folder unread count = %d, want 0", got) + } + + m.restorePendingAction() + if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 1 { + t.Fatalf("after undo, folder unread count = %d, want 1", got) + } +} + func TestUnreadBadgeCountDeduplicatesOverlappingStores(t *testing.T) { email := fetcher.Email{UID: 42, AccountID: "acct-a"} got := unreadBadgeCount( diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index b88ececba7ff7b58178c77f4f3474c9161bb6dc2..49b127ec5b7120f8182c39e071483a449cd76a7d 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -673,6 +673,15 @@ func (m *FolderInbox) DecrementUnreadCount(folder string) { } } +// IncrementUnreadCount increases the unread counter for a folder. It is used to +// restore the counter when a delete/archive action is undone. +func (m *FolderInbox) IncrementUnreadCount(folder string) { + if m.unread == nil { + m.unread = make(map[string]int) + } + m.unread[folder]++ +} + // SetEmails updates the inbox emails. func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) { m.accounts = accounts