@@ -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 {
@@ -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(