diff --git a/main.go b/main.go index 6cdc3d57f3276e931ce6658216083f60409e0f62..f371548cfc5316560fe3f5b4c605bee650c256e7 100644 --- a/main.go +++ b/main.go @@ -76,8 +76,11 @@ var ( ) const ( - goosDarwin = "darwin" - folderInbox = "INBOX" + goosDarwin = "darwin" + folderInbox = "INBOX" + actionKindDelete = "delete" + actionKindArchive = "archive" + actionKindMove = "move" ) // UpdateAvailableMsg is sent into the TUI when a newer release is detected. @@ -87,6 +90,20 @@ type UpdateAvailableMsg struct { } // internal struct for parsing GitHub release JSON. +type pendingEmailAction struct { + jobID string + kind string // "delete", "archive", "move" + uids []uint32 + accountID string + folderName string + destFolder string // for "move" + mailbox tui.MailboxKind + // Snapshots for undo restore + emailsSnap []fetcher.Email + acctSnap []fetcher.Email + folderSnap []fetcher.Email +} + type githubRelease struct { TagName string `json:"tag_name"` Assets []struct { @@ -121,11 +138,13 @@ type mainModel struct { // mailto: URL parsed from os.Args mailtoURL *url.URL // Optional in-app log panel. - showLogPanel bool - logCh <-chan logging.Entry - logPanel *tui.LogPanel - pendingJobID string - sendNotice string + showLogPanel bool + logCh <-chan logging.Entry + logPanel *tui.LogPanel + pendingJobID string + sendNotice string + pendingAction *pendingEmailAction + actionNotice string } type logEntryMsg struct { @@ -343,6 +362,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo switch msg := msg.(type) { case tea.KeyPressMsg: if msg.String() == config.Keybinds.Composer.UndoSend { + if m.pendingAction != nil { + m.restorePendingAction() + return m, nil + } if m.pendingJobID != "" { jobID := m.pendingJobID m.pendingJobID = "" @@ -880,6 +903,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID) } + emailsSnap := slices.Clone(m.emails) + acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) + folderSnap := slices.Clone(m.folderEmails[folderName]) + m.removeEmailFromStores(msg.UID, msg.AccountID) if emails, ok := m.folderEmails[folderName]; ok { @@ -893,7 +920,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } - return m, m.moveEmailToFolderCmd(msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder) + pa := &pendingEmailAction{ + jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), + kind: actionKindMove, + uids: []uint32{msg.UID}, + accountID: msg.AccountID, + folderName: folderName, + destFolder: msg.DestFolder, + emailsSnap: emailsSnap, + acctSnap: acctSnap, + folderSnap: folderSnap, + } + flushCmd := m.flushPendingAction() + notice := fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend) + return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice)) case tui.UpdatePreviewMsg: // Trigger preview body fetch @@ -1803,6 +1843,15 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.current, _ = m.current.Update(m.currentWindowSize()) return m, m.current.Init() + case tui.ActionGracePeriodExpiredMsg: + if m.pendingAction != nil && m.pendingAction.jobID == msg.JobID { + pa := m.pendingAction + m.pendingAction = nil + m.actionNotice = "" + return m, m.executePendingAction(pa) + } + return m, nil + case tui.SendRSVPMsg: account := m.config.GetAccountByID(msg.AccountID) if account == nil { @@ -1870,6 +1919,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID) } + emailsSnap := slices.Clone(m.emails) + acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) + folderSnap := slices.Clone(m.folderEmails[folderName]) + m.removeEmailFromStores(msg.UID, msg.AccountID) if emails, ok := m.folderEmails[folderName]; ok { @@ -1883,7 +1936,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } - return m, m.deleteFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox) + pa := &pendingEmailAction{ + jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), + kind: actionKindDelete, + uids: []uint32{msg.UID}, + accountID: msg.AccountID, + folderName: folderName, + mailbox: msg.Mailbox, + emailsSnap: emailsSnap, + acctSnap: acctSnap, + folderSnap: folderSnap, + } + flushCmd := m.flushPendingAction() + notice := fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend) + return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice)) case tui.ArchiveEmailMsg: tui.ClearKittyGraphics() @@ -1903,6 +1969,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID) } + emailsSnap := slices.Clone(m.emails) + acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) + folderSnap := slices.Clone(m.folderEmails[folderName]) + m.removeEmailFromStores(msg.UID, msg.AccountID) if emails, ok := m.folderEmails[folderName]; ok { @@ -1916,7 +1986,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } - return m, m.archiveFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox) + pa := &pendingEmailAction{ + jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), + kind: actionKindArchive, + uids: []uint32{msg.UID}, + accountID: msg.AccountID, + folderName: folderName, + mailbox: msg.Mailbox, + emailsSnap: emailsSnap, + acctSnap: acctSnap, + folderSnap: folderSnap, + } + flushCmd := m.flushPendingAction() + notice := fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend) + return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice)) case tui.EmailMarkedReadMsg: if msg.Err != nil { @@ -1963,6 +2046,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID) } + emailsSnap := slices.Clone(m.emails) + acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) + folderSnap := slices.Clone(m.folderEmails[folderName]) + for _, uid := range msg.UIDs { m.removeEmailFromStores(uid, msg.AccountID) } @@ -1978,7 +2065,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } - return m, m.batchDeleteEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs)) + pa := &pendingEmailAction{ + jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), + kind: actionKindDelete, + uids: msg.UIDs, + accountID: msg.AccountID, + folderName: folderName, + mailbox: msg.Mailbox, + emailsSnap: emailsSnap, + acctSnap: acctSnap, + folderSnap: folderSnap, + } + flushCmd := m.flushPendingAction() + notice := fmt.Sprintf("%d emails deleted (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend) + if len(msg.UIDs) == 1 { + notice = fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend) + } + return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice)) case tui.BatchArchiveEmailsMsg: tui.ClearKittyGraphics() @@ -1997,6 +2100,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID) } + emailsSnap := slices.Clone(m.emails) + acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) + folderSnap := slices.Clone(m.folderEmails[folderName]) + for _, uid := range msg.UIDs { m.removeEmailFromStores(uid, msg.AccountID) } @@ -2012,7 +2119,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } - return m, m.batchArchiveEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs)) + pa := &pendingEmailAction{ + jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), + kind: actionKindArchive, + uids: msg.UIDs, + accountID: msg.AccountID, + folderName: folderName, + mailbox: msg.Mailbox, + emailsSnap: emailsSnap, + acctSnap: acctSnap, + folderSnap: folderSnap, + } + flushCmd := m.flushPendingAction() + notice := fmt.Sprintf("%d emails archived (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend) + if len(msg.UIDs) == 1 { + notice = fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend) + } + return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice)) case tui.BatchMoveEmailsMsg: if m.config == nil { @@ -2029,6 +2152,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID) } + emailsSnap := slices.Clone(m.emails) + acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID]) + folderSnap := slices.Clone(m.folderEmails[folderName]) + for _, uid := range msg.UIDs { m.removeEmailFromStores(uid, msg.AccountID) } @@ -2044,7 +2171,23 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo go saveFolderEmailsToCache(folderName, filtered) } - return m, m.batchMoveEmailsCmd(msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, len(msg.UIDs)) + pa := &pendingEmailAction{ + jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()), + kind: actionKindMove, + uids: msg.UIDs, + accountID: msg.AccountID, + folderName: folderName, + destFolder: msg.DestFolder, + emailsSnap: emailsSnap, + acctSnap: acctSnap, + folderSnap: folderSnap, + } + flushCmd := m.flushPendingAction() + notice := fmt.Sprintf("%d emails moved to %s (%s to undo)", len(msg.UIDs), msg.DestFolder, config.Keybinds.Composer.UndoSend) + if len(msg.UIDs) == 1 { + notice = fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend) + } + return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice)) case tui.BatchEmailActionDoneMsg: if msg.Err != nil { @@ -2127,6 +2270,9 @@ func (m *mainModel) View() tea.View { if m.sendNotice != "" { v.Content = m.renderSendNoticeOverlay(v.Content) } + if m.actionNotice != "" { + v.Content = m.renderActionNoticeOverlay(v.Content) + } v.AltScreen = true return v } @@ -2143,6 +2289,68 @@ func (m *mainModel) renderSendNoticeOverlay(content string) string { return overlay.Block(content, lines, 0, col) } +func (m *mainModel) renderActionNoticeOverlay(content string) string { + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.ActiveTheme.Accent). + Padding(0, 1). + Render(m.actionNotice) + lines := strings.Split(box, "\n") + boxWidth := lipgloss.Width(lines[0]) + col := max(0, m.width-boxWidth) + return overlay.Block(content, lines, 0, col) +} + +func (m *mainModel) startActionGracePeriod(pa *pendingEmailAction, notice string) tea.Cmd { + m.pendingAction = pa + m.actionNotice = notice + delay := time.Duration(m.config.GetUndoDelaySeconds()) * time.Second + jobID := pa.jobID + return tea.Tick(delay, func(t time.Time) tea.Msg { + return tui.ActionGracePeriodExpiredMsg{JobID: jobID} + }) +} + +func (m *mainModel) flushPendingAction() tea.Cmd { + if m.pendingAction == nil { + return nil + } + pa := m.pendingAction + m.pendingAction = nil + m.actionNotice = "" + return m.executePendingAction(pa) +} + +func (m *mainModel) executePendingAction(pa *pendingEmailAction) tea.Cmd { + switch pa.kind { + case actionKindDelete: + return m.batchDeleteEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids)) + case actionKindArchive: + return m.batchArchiveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids)) + case actionKindMove: + return m.batchMoveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.destFolder, len(pa.uids)) + } + return nil +} + +func (m *mainModel) restorePendingAction() { + if m.pendingAction == nil { + return + } + pa := m.pendingAction + m.pendingAction = nil + m.actionNotice = "" + + m.emails = pa.emailsSnap + m.emailsByAcct[pa.accountID] = pa.acctSnap + m.folderEmails[pa.folderName] = pa.folderSnap + + if m.folderInbox != nil { + m.folderInbox.SetEmails(pa.folderSnap, m.config.Accounts) + } + go saveFolderEmailsToCache(pa.folderName, pa.folderSnap) +} + func (m *mainModel) currentWindowSize() tea.WindowSizeMsg { return tea.WindowSizeMsg{ Width: m.width, @@ -3093,36 +3301,6 @@ func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, } } -func (m *mainModel) deleteFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { - return func() tea.Msg { - if m.service == nil { - return tui.EmailActionDoneMsg{ - UID: uid, - AccountID: accountID, - Mailbox: mailbox, - Err: fmt.Errorf("service not initialized"), - } - } - err := m.service.DeleteEmails(accountID, folderName, []uint32{uid}) - return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} - } -} - -func (m *mainModel) archiveFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { - return func() tea.Msg { - if m.service == nil { - return tui.EmailActionDoneMsg{ - UID: uid, - AccountID: accountID, - Mailbox: mailbox, - Err: fmt.Errorf("service not initialized"), - } - } - err := m.service.ArchiveEmails(accountID, folderName, []uint32{uid}) - return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} - } -} - func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { return func() tea.Msg { if m.service == nil { @@ -3130,7 +3308,7 @@ func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName st Count: count, SuccessCount: 0, FailureCount: count, - Action: "delete", + Action: actionKindDelete, Mailbox: mailbox, Err: fmt.Errorf("service not initialized"), } @@ -3147,7 +3325,7 @@ func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName st Count: count, SuccessCount: successCount, FailureCount: failureCount, - Action: "delete", + Action: actionKindDelete, Mailbox: mailbox, Err: err, } @@ -3161,7 +3339,7 @@ func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName s Count: count, SuccessCount: 0, FailureCount: count, - Action: "archive", + Action: actionKindArchive, Mailbox: mailbox, Err: fmt.Errorf("service not initialized"), } @@ -3178,7 +3356,7 @@ func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName s Count: count, SuccessCount: successCount, FailureCount: failureCount, - Action: "archive", + Action: actionKindArchive, Mailbox: mailbox, Err: err, } @@ -3192,7 +3370,7 @@ func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, d Count: count, SuccessCount: 0, FailureCount: count, - Action: "move", + Action: actionKindMove, Err: fmt.Errorf("service not initialized"), } } @@ -3208,30 +3386,7 @@ func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, d Count: count, SuccessCount: successCount, FailureCount: failureCount, - Action: "move", - Err: err, - } - } -} - -func (m *mainModel) moveEmailToFolderCmd(uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd { - return func() tea.Msg { - if m.service == nil { - return tui.EmailMovedMsg{ - UID: uid, - AccountID: accountID, - SourceFolder: sourceFolder, - DestFolder: destFolder, - Err: fmt.Errorf("service not initialized"), - } - } - - err := m.service.MoveEmails(accountID, []uint32{uid}, sourceFolder, destFolder) - return tui.EmailMovedMsg{ - UID: uid, - AccountID: accountID, - SourceFolder: sourceFolder, - DestFolder: destFolder, + Action: actionKindMove, Err: err, } } diff --git a/tui/messages.go b/tui/messages.go index e2f59f3ff5b2ee627ea846e9043d906d69022ea1..26bc50905bd5caa4b312719e8f3913d88c5c861c 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -56,6 +56,11 @@ type UndoSendMsg struct { JobID string } +// ActionGracePeriodExpiredMsg is fired when the undo grace period for a delete/archive/move expires. +type ActionGracePeriodExpiredMsg struct { + JobID string +} + type Credentials struct { Provider string Name string