From c9da42929254b7547f27268e175d0c027f80a422 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sun, 19 Apr 2026 19:34:55 +0400 Subject: [PATCH] chore: refactor settings.go and improve UI (#683) --- main.go | 3 +- tui/login.go | 22 +- tui/messages.go | 2 + tui/settings.go | 1353 +++++------------------------------- tui/settings_accounts.go | 181 +++++ tui/settings_crypto.go | 179 +++++ tui/settings_encryption.go | 149 ++++ tui/settings_general.go | 119 ++++ tui/settings_lists.go | 112 +++ tui/settings_theme.go | 131 ++++ tui/theme.go | 4 - tui/trash_archive.go | 154 ---- tui/trash_archive_test.go | 340 --------- 13 files changed, 1073 insertions(+), 1676 deletions(-) create mode 100644 tui/settings_accounts.go create mode 100644 tui/settings_crypto.go create mode 100644 tui/settings_encryption.go create mode 100644 tui/settings_general.go create mode 100644 tui/settings_lists.go create mode 100644 tui/settings_theme.go delete mode 100644 tui/trash_archive.go delete mode 100644 tui/trash_archive_test.go diff --git a/main.go b/main.go index 27062e239905645aae671301456259c88735037a..548481ead71caa02f5754ae9b01bf9c065145171 100644 --- a/main.go +++ b/main.go @@ -278,6 +278,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { SendAsEmail: msg.SendAsEmail, AuthMethod: msg.AuthMethod, Protocol: msg.Protocol, + Insecure: msg.Insecure, JMAPEndpoint: msg.JMAPEndpoint, POP3Server: msg.POP3Server, POP3Port: msg.POP3Port, @@ -879,7 +880,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { hideTips = m.config.HideTips } login := tui.NewLogin(hideTips) - login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port) + login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port) m.current = login m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() diff --git a/tui/login.go b/tui/login.go index 6eab0f62a82e1d2e5058c2ca5394a376d99c12d5..4f3cd0a2fd4b06aad75560a3395955e941152532 100644 --- a/tui/login.go +++ b/tui/login.go @@ -34,6 +34,7 @@ const ( inputIMAPPort inputSMTPServer inputSMTPPort + inputInsecure inputJMAPEndpoint // JMAP session URL inputPOP3Server inputPOP3Port @@ -93,6 +94,9 @@ func NewLogin(hideTips bool) *Login { case inputSMTPPort: t.Placeholder = "SMTP Port (default: 587)" t.Prompt = "🔢 > " + case inputInsecure: + t.Placeholder = "Insecure (true/false) - Skip TLS verification" + t.Prompt = "🔓 > " case inputJMAPEndpoint: t.Placeholder = "JMAP Session URL (e.g., https://api.fastmail.com/jmap/session)" t.Prompt = "🔗 > " @@ -139,7 +143,7 @@ func (m *Login) visibleFields() []int { case "pop3": // POP3: custom server fields + SMTP for sending fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputPassword, - inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort) + inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort, inputInsecure) default: // IMAP (default): existing flow fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail) @@ -150,7 +154,7 @@ func (m *Login) visibleFields() []int { fields = append(fields, inputPassword) } if m.showCustom { - fields = append(fields, inputIMAPServer, inputIMAPPort, inputSMTPServer, inputSMTPPort) + fields = append(fields, inputIMAPServer, inputIMAPPort, inputSMTPServer, inputSMTPPort, inputInsecure) } } @@ -268,6 +272,8 @@ func (m *Login) submitForm() func() tea.Msg { proto := m.protocol() + insecure := m.inputs[inputInsecure].Value() == "true" + return func() tea.Msg { return Credentials{ Protocol: proto, @@ -281,6 +287,7 @@ func (m *Login) submitForm() func() tea.Msg { IMAPPort: imapPort, SMTPServer: m.inputs[inputSMTPServer].Value(), SMTPPort: smtpPort, + Insecure: insecure, AuthMethod: authMethod, JMAPEndpoint: m.inputs[inputJMAPEndpoint].Value(), POP3Server: m.inputs[inputPOP3Server].Value(), @@ -324,6 +331,8 @@ func (m *Login) View() tea.View { tip = "The server address for sending emails." case inputSMTPPort: tip = "The port for the SMTP server (usually 587 for TLS)." + case inputInsecure: + tip = "Type 'true' to disable TLS certificate verification (not recommended)." case inputJMAPEndpoint: tip = "The JMAP session resource URL (e.g., https://api.fastmail.com/jmap/session)." case inputPOP3Server: @@ -366,6 +375,7 @@ func (m *Login) View() tea.View { listHeader.Render("SMTP Settings (for sending):"), m.inputs[inputSMTPServer].View(), m.inputs[inputSMTPPort].View(), + m.inputs[inputInsecure].View(), ) default: // IMAP flow @@ -398,6 +408,7 @@ func (m *Login) View() tea.View { m.inputs[inputIMAPPort].View(), m.inputs[inputSMTPServer].View(), m.inputs[inputSMTPPort].View(), + m.inputs[inputInsecure].View(), ) } } @@ -412,7 +423,7 @@ func (m *Login) View() tea.View { } // SetEditMode sets the login form to edit an existing account. -func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, jmapEndpoint, pop3Server string, pop3Port int) { +func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, insecure bool, jmapEndpoint, pop3Server string, pop3Port int) { m.isEditMode = true m.accountID = accountID @@ -429,6 +440,11 @@ func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEma if m.showCustom { m.inputs[inputIMAPServer].SetValue(imapServer) + if insecure { + m.inputs[inputInsecure].SetValue("true") + } else { + m.inputs[inputInsecure].SetValue("false") + } if imapPort != 0 { m.inputs[inputIMAPPort].SetValue(strconv.Itoa(imapPort)) } diff --git a/tui/messages.go b/tui/messages.go index f2b705cc80624f2916e61dbbb8618dfe41097819..8499eede547ca81d6a8ea73022f0fb8e619d8f59 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -51,6 +51,7 @@ type Credentials struct { IMAPPort int SMTPServer string SMTPPort int + Insecure bool AuthMethod string // "password" or "oauth2" Protocol string // "imap" (default), "jmap", or "pop3" JMAPEndpoint string // JMAP session URL @@ -245,6 +246,7 @@ type GoToEditAccountMsg struct { IMAPPort int SMTPServer string SMTPPort int + Insecure bool Protocol string JMAPEndpoint string POP3Server string diff --git a/tui/settings.go b/tui/settings.go index 53845fa82e0359723f9069bedb8254e3283275b7..9f1a8f369c91c0c4a8162b7355df8cf24ac299a5 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "strings" "charm.land/bubbles/v2/textinput" @@ -13,41 +12,55 @@ import ( var ( accountItemStyle = lipgloss.NewStyle().PaddingLeft(2) - selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")) + selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")).Bold(true) accountEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) - settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) ) -type SettingsState int +type SettingsPane int const ( - SettingsMain SettingsState = iota - SettingsAccounts - SettingsMailingLists - SettingsSMIMEConfig - SettingsTheme - SettingsEncryption + PaneMenu SettingsPane = iota + PaneContent +) + +type SettingsCategory int + +const ( + CategoryGeneral SettingsCategory = iota + CategoryAccounts + CategoryTheme + CategoryMailingLists + CategoryEncryption ) -// Settings displays the settings screen. type Settings struct { - cfg *config.Config - state SettingsState - cursor int + cfg *config.Config + width int + height int + + activePane SettingsPane + activeCategory SettingsCategory + + // Menu state + menuCursor int + + // Sub-components states + generalCursor int + accountsCursor int + themeCursor int + listsCursor int confirmingDelete bool - width int - height int // S/MIME Config fields - editingAccountIdx int - focusIndex int - smimeCertInput textinput.Model - smimeKeyInput textinput.Model - - // PGP Config fields + isCryptoConfig bool + editingAccountIdx int + cryptoFocusIndex int + smimeCertInput textinput.Model + smimeKeyInput textinput.Model pgpPublicKeyInput textinput.Model pgpPrivateKeyInput textinput.Model pgpKeySource string // "file" or "yubikey" @@ -58,11 +71,10 @@ type Settings struct { encConfirmInput textinput.Model encFocusIndex int encError string - encEnabling bool // true when enabling encryption in progress - confirmingDisable bool // true when confirming disable + encEnabling bool + confirmingDisable bool } -// NewSettings creates a new settings model. func NewSettings(cfg *config.Config) *Settings { if cfg == nil { cfg = &config.Config{} @@ -70,75 +82,38 @@ func NewSettings(cfg *config.Config) *Settings { tiStyles := ThemedTextInputStyles() - certInput := textinput.New() - certInput.Placeholder = "/path/to/cert.pem" - certInput.Prompt = "> " - certInput.CharLimit = 256 - certInput.SetStyles(tiStyles) - - keyInput := textinput.New() - keyInput.Placeholder = "/path/to/private_key.pem" - keyInput.Prompt = "> " - keyInput.CharLimit = 256 - keyInput.SetStyles(tiStyles) - - pgpPubInput := textinput.New() - pgpPubInput.Placeholder = "/path/to/public_key.asc" - pgpPubInput.Prompt = "> " - pgpPubInput.CharLimit = 256 - pgpPubInput.SetStyles(tiStyles) - - pgpPrivInput := textinput.New() - pgpPrivInput.Placeholder = "/path/to/private_key.asc" - pgpPrivInput.Prompt = "> " - pgpPrivInput.CharLimit = 256 - pgpPrivInput.SetStyles(tiStyles) - - pgpPINInput := textinput.New() - pgpPINInput.Placeholder = "YubiKey PIN (6-8 digits)" - pgpPINInput.Prompt = "> " - pgpPINInput.CharLimit = 16 - pgpPINInput.EchoMode = textinput.EchoPassword - pgpPINInput.EchoCharacter = '*' - pgpPINInput.SetStyles(tiStyles) - - encPassInput := textinput.New() - encPassInput.Placeholder = "Password" - encPassInput.Prompt = "> " - encPassInput.CharLimit = 256 - encPassInput.EchoMode = textinput.EchoPassword - encPassInput.EchoCharacter = '*' - encPassInput.SetStyles(tiStyles) - - encConfInput := textinput.New() - encConfInput.Placeholder = "Confirm Password" - encConfInput.Prompt = "> " - encConfInput.CharLimit = 256 - encConfInput.EchoMode = textinput.EchoPassword - encConfInput.EchoCharacter = '*' - encConfInput.SetStyles(tiStyles) + newInput := func(placeholder, prompt string, isPassword bool) textinput.Model { + t := textinput.New() + t.Placeholder = placeholder + t.Prompt = prompt + t.CharLimit = 256 + t.SetStyles(tiStyles) + if isPassword { + t.EchoMode = textinput.EchoPassword + t.EchoCharacter = '*' + } + return t + } return &Settings{ cfg: cfg, - state: SettingsMain, - cursor: 0, - smimeCertInput: certInput, - smimeKeyInput: keyInput, - pgpPublicKeyInput: pgpPubInput, - pgpPrivateKeyInput: pgpPrivInput, - pgpKeySource: "file", // Default to file-based keys - pgpPINInput: pgpPINInput, - encPasswordInput: encPassInput, - encConfirmInput: encConfInput, + activePane: PaneMenu, + activeCategory: CategoryGeneral, + smimeCertInput: newInput("/path/to/cert.pem", "> ", false), + smimeKeyInput: newInput("/path/to/private_key.pem", "> ", false), + pgpPublicKeyInput: newInput("/path/to/public_key.asc", "> ", false), + pgpPrivateKeyInput: newInput("/path/to/private_key.asc", "> ", false), + pgpPINInput: newInput("YubiKey PIN (6-8 digits)", "> ", true), + pgpKeySource: "file", + encPasswordInput: newInput("Password", "> ", true), + encConfirmInput: newInput("Confirm Password", "> ", true), } } -// Init initializes the settings model. func (m *Settings) Init() tea.Cmd { return textinput.Blink } -// Update handles messages for the settings model. func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd @@ -147,29 +122,43 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.smimeCertInput.SetWidth(m.width - 6) - m.smimeKeyInput.SetWidth(m.width - 6) - m.pgpPublicKeyInput.SetWidth(m.width - 6) - m.pgpPrivateKeyInput.SetWidth(m.width - 6) - m.pgpPINInput.SetWidth(m.width - 6) + inputWidth := (m.width - 30) - 6 // left pane is 30 + if inputWidth < 20 { + inputWidth = 20 + } + m.smimeCertInput.SetWidth(inputWidth) + m.smimeKeyInput.SetWidth(inputWidth) + m.pgpPublicKeyInput.SetWidth(inputWidth) + m.pgpPrivateKeyInput.SetWidth(inputWidth) + m.pgpPINInput.SetWidth(inputWidth) return m, nil case tea.KeyPressMsg: - if m.state == SettingsMain { - return m.updateMain(msg) - } else if m.state == SettingsTheme { - return m.updateTheme(msg) - } else if m.state == SettingsMailingLists { - return m.updateMailingLists(msg) - } else if m.state == SettingsSMIMEConfig { - var m2 *Settings - m2, cmd = m.updateSMIMEConfig(msg) - cmds = append(cmds, cmd) - return m2, tea.Batch(cmds...) - } else if m.state == SettingsEncryption { - return m.updateEncryption(msg) + // Global shortcut to return to menu from content pane + if m.activePane == PaneContent && msg.String() == "esc" { + // unless we are in crypto config or encryption editing which have their own esc logic + if !(m.activeCategory == CategoryAccounts && m.isCryptoConfig) && + !(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) { + m.activePane = PaneMenu + return m, nil + } + } + + if m.activePane == PaneMenu { + return m.updateMenu(msg) } else { - return m.updateAccounts(msg) + switch m.activeCategory { + case CategoryGeneral: + return m.updateGeneral(msg) + case CategoryAccounts: + return m.updateAccounts(msg) + case CategoryTheme: + return m.updateTheme(msg) + case CategoryMailingLists: + return m.updateMailingLists(msg) + case CategoryEncryption: + return m.updateEncryption(msg) + } } case SecureModeEnabledMsg: @@ -178,8 +167,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.encError = msg.Err.Error() return m, nil } - m.state = SettingsMain - m.cursor = 7 + m.activePane = PaneMenu return m, nil case SecureModeDisabledMsg: @@ -188,83 +176,60 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.confirmingDisable = false - m.state = SettingsMain - m.cursor = 7 + m.activePane = PaneMenu return m, nil } - if m.state == SettingsEncryption { - m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) - cmds = append(cmds, cmd) - m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) - cmds = append(cmds, cmd) - } - - if m.state == SettingsSMIMEConfig { - m.smimeCertInput, cmd = m.smimeCertInput.Update(msg) - cmds = append(cmds, cmd) - m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg) - cmds = append(cmds, cmd) - m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg) - cmds = append(cmds, cmd) - m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg) - cmds = append(cmds, cmd) - m.pgpPINInput, cmd = m.pgpPINInput.Update(msg) - cmds = append(cmds, cmd) + // Update text inputs if active + if m.activePane == PaneContent { + if m.activeCategory == CategoryEncryption { + m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) + cmds = append(cmds, cmd) + m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) + cmds = append(cmds, cmd) + } else if m.activeCategory == CategoryAccounts && m.isCryptoConfig { + m.smimeCertInput, cmd = m.smimeCertInput.Update(msg) + cmds = append(cmds, cmd) + m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg) + cmds = append(cmds, cmd) + m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg) + cmds = append(cmds, cmd) + m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg) + cmds = append(cmds, cmd) + m.pgpPINInput, cmd = m.pgpPINInput.Update(msg) + cmds = append(cmds, cmd) + } } return m, tea.Batch(cmds...) } -func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { +func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", "k": - if m.cursor > 0 { - m.cursor-- + if m.menuCursor > 0 { + m.menuCursor-- } case "down", "j": - // Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Desktop Notifications, 6: Mailing Lists, 7: Encryption - if m.cursor < 7 { - m.cursor++ + if m.menuCursor < 4 { + m.menuCursor++ } - case "enter": - switch m.cursor { - case 0: // Email Accounts - m.state = SettingsAccounts - m.cursor = 0 - return m, nil - case 1: // Theme - m.state = SettingsTheme - // Position cursor on the currently active theme + case "right", "l", "enter": + m.activeCategory = SettingsCategory(m.menuCursor) + m.activePane = PaneContent + + // Reset states + m.confirmingDelete = false + if m.activeCategory == CategoryTheme { + // Find current theme index themes := theme.AllThemes() - m.cursor = 0 for i, t := range themes { if t.Name == theme.ActiveTheme.Name { - m.cursor = i + m.themeCursor = i break } } - return m, nil - case 2: // Image Display - m.cfg.DisableImages = !m.cfg.DisableImages - _ = config.SaveConfig(m.cfg) - return m, nil - case 3: // Edit Signature - return m, func() tea.Msg { return GoToSignatureEditorMsg{} } - case 4: // Contextual Tips - m.cfg.HideTips = !m.cfg.HideTips - _ = config.SaveConfig(m.cfg) - return m, nil - case 5: // Desktop Notifications - m.cfg.DisableNotifications = !m.cfg.DisableNotifications - _ = config.SaveConfig(m.cfg) - return m, nil - case 6: // Mailing Lists - m.state = SettingsMailingLists - m.cursor = 0 - return m, nil - case 7: // Encryption - m.state = SettingsEncryption + } else if m.activeCategory == CategoryEncryption { m.encError = "" m.encPasswordInput.SetValue("") m.encConfirmInput.SetValue("") @@ -274,809 +239,78 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if !config.IsSecureModeEnabled() { m.encPasswordInput.Focus() m.encConfirmInput.Blur() - return m, textinput.Blink - } - return m, nil - } - case "esc": - return m, func() tea.Msg { return GoToChoiceMenuMsg{} } - } - return m, nil -} - -func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if m.confirmingDelete { - switch msg.String() { - case "y", "Y": - if m.cursor < len(m.cfg.Accounts) { - accountID := m.cfg.Accounts[m.cursor].ID - m.confirmingDelete = false - return m, func() tea.Msg { - return DeleteAccountMsg{AccountID: accountID} - } } - case "n", "N", "esc": - m.confirmingDelete = false - return m, nil } - return m, nil - } - switch msg.String() { - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - // +1 for "Add Account" option - if m.cursor < len(m.cfg.Accounts) { - m.cursor++ - } - case "d": - // Delete selected account (not the "Add Account" option) - if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 { - m.confirmingDelete = true - } - case "e": - // Edit selected account - if m.cursor < len(m.cfg.Accounts) { - acc := m.cfg.Accounts[m.cursor] - return m, func() tea.Msg { - return GoToEditAccountMsg{ - AccountID: acc.ID, - Provider: acc.ServiceProvider, - Name: acc.Name, - Email: acc.Email, - FetchEmail: acc.FetchEmail, - SendAsEmail: acc.SendAsEmail, - IMAPServer: acc.IMAPServer, - IMAPPort: acc.IMAPPort, - SMTPServer: acc.SMTPServer, - SMTPPort: acc.SMTPPort, - Protocol: acc.Protocol, - JMAPEndpoint: acc.JMAPEndpoint, - POP3Server: acc.POP3Server, - POP3Port: acc.POP3Port, - } - } - } - case "enter": - // If cursor is on "Add Account" - if m.cursor == len(m.cfg.Accounts) { - return m, func() tea.Msg { return GoToAddAccountMsg{} } - } else if m.cursor < len(m.cfg.Accounts) { - m.editingAccountIdx = m.cursor - m.state = SettingsSMIMEConfig - m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert) - m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey) - m.pgpPublicKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPublicKey) - m.pgpPrivateKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPrivateKey) - // Initialize PGP key source - if m.cfg.Accounts[m.cursor].PGPKeySource == "" { - m.pgpKeySource = "file" - } else { - m.pgpKeySource = m.cfg.Accounts[m.cursor].PGPKeySource - } - m.pgpPINInput.SetValue(m.cfg.Accounts[m.cursor].PGPPIN) - m.focusIndex = 0 - m.smimeCertInput.Focus() - m.smimeKeyInput.Blur() - m.pgpPublicKeyInput.Blur() - m.pgpPrivateKeyInput.Blur() - m.pgpPINInput.Blur() - return m, textinput.Blink - } + return m, textinput.Blink case "esc": - m.state = SettingsMain - m.cursor = 0 - return m, nil + return m, func() tea.Msg { return GoToChoiceMenuMsg{} } } + m.activeCategory = SettingsCategory(m.menuCursor) return m, nil } -// Focus indices for the crypto config screen: -// 0: S/MIME Certificate Path -// 1: S/MIME Private Key Path -// 2: S/MIME Sign By Default toggle -// 3: PGP Public Key Path -// 4: PGP Private Key Path -// 5: PGP Key Source toggle (file/yubikey) -// 6: PGP PIN input (only shown if yubikey) -// 7: PGP Sign By Default toggle -// 8: Save button -// 9: Cancel button -const cryptoConfigMaxFocus = 9 - -func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - key := msg.Key() - isEnter := key.Code == tea.KeyEnter || key.Code == tea.KeyReturn || key.Code == tea.KeyKpEnter - isSpace := key.Code == tea.KeySpace - - setFocus := func(next int) tea.Cmd { - m.focusIndex = next - - m.smimeCertInput.Blur() - m.smimeKeyInput.Blur() - m.pgpPublicKeyInput.Blur() - m.pgpPrivateKeyInput.Blur() - m.pgpPINInput.Blur() - - switch m.focusIndex { - case 0: - return m.smimeCertInput.Focus() - case 1: - return m.smimeKeyInput.Focus() - case 3: - return m.pgpPublicKeyInput.Focus() - case 4: - return m.pgpPrivateKeyInput.Focus() - case 6: - return m.pgpPINInput.Focus() - default: - return nil - } - } - - switch msg.String() { - case "esc": - m.state = SettingsAccounts - return m, nil - case "tab", "shift+tab", "up", "down": - if msg.String() == "shift+tab" || msg.String() == "up" { - m.focusIndex-- - if m.focusIndex < 0 { - m.focusIndex = cryptoConfigMaxFocus - } - } else { - m.focusIndex++ - if m.focusIndex > cryptoConfigMaxFocus { - m.focusIndex = 0 - } - } - - // Skip Yubikey PIN field when key source is "file" - if m.focusIndex == 6 && m.pgpKeySource != "yubikey" { - if msg.String() == "shift+tab" || msg.String() == "up" { - m.focusIndex = 5 - } else { - m.focusIndex = 7 - } - } - - cmds = append(cmds, setFocus(m.focusIndex)) - return m, tea.Batch(cmds...) - } - - if isEnter { - switch m.focusIndex { - case 0: // S/MIME cert - enter advances to next field - cmds = append(cmds, setFocus(1)) - return m, tea.Batch(cmds...) - case 1: // S/MIME key - enter advances - cmds = append(cmds, setFocus(2)) - return m, tea.Batch(cmds...) - case 2: // S/MIME sign toggle - enter advances - cmds = append(cmds, setFocus(3)) - return m, tea.Batch(cmds...) - case 3: // PGP public key - enter advances - cmds = append(cmds, setFocus(4)) - return m, tea.Batch(cmds...) - case 4: // PGP private key - enter advances - cmds = append(cmds, setFocus(5)) - return m, tea.Batch(cmds...) - case 5: // PGP key source - enter advances - nextFocus := 7 - if m.pgpKeySource == "yubikey" { - nextFocus = 6 - } - cmds = append(cmds, setFocus(nextFocus)) - return m, tea.Batch(cmds...) - case 6: // PGP PIN input - enter advances - cmds = append(cmds, setFocus(7)) - return m, tea.Batch(cmds...) - case 7: // PGP sign toggle - enter advances - cmds = append(cmds, setFocus(8)) - return m, tea.Batch(cmds...) - case 8: // Save - m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value() - m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value() - m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value() - m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value() - m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource - m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value() - _ = config.SaveConfig(m.cfg) - m.state = SettingsAccounts - return m, nil - case 9: // Cancel - m.state = SettingsAccounts - return m, nil - } - } - - if isSpace { - switch m.focusIndex { - case 2: // S/MIME sign toggle - m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault - return m, nil - case 5: // PGP key source toggle (file/yubikey) - if m.pgpKeySource == "file" { - m.pgpKeySource = "yubikey" +func (m *Settings) View() tea.View { + // Left pane + var left strings.Builder + left.WriteString(titleStyle.Render("Settings") + "\n\n") + + categories := []string{"General", "Accounts", "Theme", "Mailing Lists", "App Encryption"} + for i, c := range categories { + cursor := " " + if m.menuCursor == i { + if m.activePane == PaneMenu { + cursor = "> " } else { - m.pgpKeySource = "file" - } - return m, nil - case 7: // PGP sign toggle - m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault - return m, nil - } - } - - switch m.focusIndex { - case 0: - m.smimeCertInput, cmd = m.smimeCertInput.Update(msg) - cmds = append(cmds, cmd) - case 1: - m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg) - cmds = append(cmds, cmd) - case 3: - m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg) - cmds = append(cmds, cmd) - case 4: - m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg) - cmds = append(cmds, cmd) - case 6: - m.pgpPINInput, cmd = m.pgpPINInput.Update(msg) - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} - -func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if m.confirmingDelete { - switch msg.String() { - case "y", "Y": - if m.cursor < len(m.cfg.MailingLists) { - m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...) - _ = config.SaveConfig(m.cfg) - if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 { - m.cursor-- - } - m.confirmingDelete = false + cursor = "• " } - case "n", "N", "esc": - m.confirmingDelete = false - return m, nil } - return m, nil - } - switch msg.String() { - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.cfg.MailingLists) { - m.cursor++ - } - case "d": - if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 { - m.confirmingDelete = true - } - case "e": - // Edit selected mailing list - if m.cursor < len(m.cfg.MailingLists) { - list := m.cfg.MailingLists[m.cursor] - idx := m.cursor - return m, func() tea.Msg { - return GoToEditMailingListMsg{ - Index: idx, - Name: list.Name, - Addresses: strings.Join(list.Addresses, ", "), - } - } - } - case "enter": - if m.cursor == len(m.cfg.MailingLists) { - return m, func() tea.Msg { return GoToAddMailingListMsg{} } + style := accountItemStyle + if m.menuCursor == i { + style = selectedAccountItemStyle } - case "esc": - m.state = SettingsMain - m.cursor = 0 - return m, nil - } - return m, nil -} -// View renders the settings screen. -func (m *Settings) View() tea.View { - if m.state == SettingsMain { - return tea.NewView(m.viewMain()) - } else if m.state == SettingsTheme { - return tea.NewView(m.viewTheme()) - } else if m.state == SettingsMailingLists { - return tea.NewView(m.viewMailingLists()) - } else if m.state == SettingsSMIMEConfig { - return tea.NewView(m.viewSMIMEConfig()) - } else if m.state == SettingsEncryption { - return tea.NewView(m.viewEncryption()) + left.WriteString(style.Render(cursor+c) + "\n") } - return tea.NewView(m.viewAccounts()) -} -func (m *Settings) viewMain() string { - var b strings.Builder - - b.WriteString(titleStyle.Render("Settings") + "\n\n") - - // Option 0: Email Accounts - if m.cursor == 0 { - b.WriteString(selectedAccountItemStyle.Render("> Email Accounts")) - } else { - b.WriteString(accountItemStyle.Render(" Email Accounts")) - } - b.WriteString("\n") + leftPanel := lipgloss.NewStyle(). + Width(30). + PaddingRight(2). + Border(lipgloss.NormalBorder(), false, true, false, false). + BorderForeground(theme.ActiveTheme.Secondary). + Render(left.String()) - // Option 1: Theme - themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name) - if m.cursor == 1 { - b.WriteString(selectedAccountItemStyle.Render("> " + themeText)) - } else { - b.WriteString(accountItemStyle.Render(" " + themeText)) + // Right pane + var right string + switch m.activeCategory { + case CategoryGeneral: + right = m.viewGeneral() + case CategoryAccounts: + right = m.viewAccounts() + case CategoryTheme: + right = m.viewTheme() + case CategoryMailingLists: + right = m.viewMailingLists() + case CategoryEncryption: + right = m.viewEncryption() } - b.WriteString("\n") - // Option 2: Image Display - status := "ON" - if m.cfg.DisableImages { - status = "OFF" - } - text := fmt.Sprintf("Image Display: %s", status) - if m.cursor == 2 { - b.WriteString(selectedAccountItemStyle.Render("> " + text)) - } else { - b.WriteString(accountItemStyle.Render(" " + text)) - } - b.WriteString("\n") + rightPanel := lipgloss.NewStyle(). + PaddingLeft(2). + Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding) + Render(right) - // Option 3: Edit Signature - sigText := "Edit Signature" - if config.HasSignature() { - sigText = "Edit Signature (configured)" - } - if m.cursor == 3 { - b.WriteString(selectedAccountItemStyle.Render("> " + sigText)) - } else { - b.WriteString(accountItemStyle.Render(" " + sigText)) - } - b.WriteString("\n") + content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) - // Option 4: Contextual Tips - tipsStatus := "ON" - if m.cfg.HideTips { - tipsStatus = "OFF" - } - tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus) - if m.cursor == 4 { - b.WriteString(selectedAccountItemStyle.Render("> " + tipsText)) - } else { - b.WriteString(accountItemStyle.Render(" " + tipsText)) + helpText := "esc: back to menu" + if m.activePane == PaneMenu { + helpText = "↑/↓: navigate • right/enter: select • esc: go back" } - b.WriteString("\n") - - // Option 5: Desktop Notifications - notifStatus := "ON" - if m.cfg.DisableNotifications { - notifStatus = "OFF" - } - notifText := fmt.Sprintf("Desktop Notifications: %s", notifStatus) - if m.cursor == 5 { - b.WriteString(selectedAccountItemStyle.Render("> " + notifText)) - } else { - b.WriteString(accountItemStyle.Render(" " + notifText)) - } - b.WriteString("\n") - - // Option 6: Mailing Lists - mailingListsText := "Mailing Lists" - if m.cursor == 6 { - b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText)) - } else { - b.WriteString(accountItemStyle.Render(" " + mailingListsText)) - } - b.WriteString("\n") - - // Option 7: Encryption - encStatus := "OFF" - if config.IsSecureModeEnabled() { - encStatus = "ON" - } - encText := fmt.Sprintf("Encryption: %s", encStatus) - if m.cursor == 7 { - b.WriteString(selectedAccountItemStyle.Render("> " + encText)) - } else { - b.WriteString(accountItemStyle.Render(" " + encText)) - } - b.WriteString("\n\n") - - if !m.cfg.HideTips { - tip := "" - switch m.cursor { - case 0: - tip = "Manage your connected email accounts." - case 1: - tip = "Choose a color theme for the application." - case 2: - tip = "Toggle displaying images in emails." - case 3: - tip = "Configure the signature appended to your outgoing emails." - case 4: - tip = "Toggle displaying helpful contextual tips like this one." - case 5: - tip = "Toggle desktop notifications when new mail arrives." - case 6: - tip = "Manage groups of email addresses to quickly send to multiple people." - case 7: - tip = "Encrypt all data with a password. You'll need to enter it each time you open matcha." - } - if tip != "" { - b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n") - } - } - - mainContent := b.String() - helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back") + helpView := helpStyle.Render(helpText) if m.height > 0 { - currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) - gap := m.height - currentHeight - if gap > 0 { - mainContent += strings.Repeat("\n", gap) - } - } else { - mainContent += "\n\n" - } - - return docStyle.Render(mainContent + helpView) -} - -func (m *Settings) viewAccounts() string { - var b strings.Builder - - b.WriteString(titleStyle.Render("Account Settings")) - b.WriteString("\n\n") - - if len(m.cfg.Accounts) == 0 { - b.WriteString(accountEmailStyle.Render(" No accounts configured.\n")) - b.WriteString("\n") - } - - for i, account := range m.cfg.Accounts { - displayName := account.Email - if account.Name != "" { - displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail) - } - - providerInfo := account.ServiceProvider - if account.ServiceProvider == "custom" { - providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer) - } - - if account.SMIMECert != "" && account.SMIMEKey != "" { - providerInfo += " [S/MIME Configured]" - } - if account.PGPPublicKey != "" && account.PGPPrivateKey != "" { - providerInfo += " [PGP Configured]" - } - - line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo)) - - if m.cursor == i { - b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line))) - } else { - b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line))) - } - b.WriteString("\n") - } - - // Add Account option - addAccountText := "Add New Account" - if m.cursor == len(m.cfg.Accounts) { - b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText))) - } else { - b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addAccountText))) - } - b.WriteString("\n") - - mainContent := b.String() - helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back") - - if m.height > 0 { - currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) - gap := m.height - currentHeight - if gap > 0 { - mainContent += strings.Repeat("\n", gap) - } - } else { - mainContent += "\n\n" - } - - if m.confirmingDelete { - accountName := m.cfg.Accounts[m.cursor].Email - dialog := DialogBoxStyle.Render( - lipgloss.JoinVertical(lipgloss.Center, - dangerStyle.Render("Delete account?"), - accountEmailStyle.Render(accountName), - HelpStyle.Render("\n(y/n)"), - ), - ) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) - } - - return docStyle.Render(mainContent + helpView) -} - -func (m *Settings) viewSMIMEConfig() string { - var b strings.Builder - - account := m.cfg.Accounts[m.editingAccountIdx] - b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Configuration for %s", account.FetchEmail))) - b.WriteString("\n\n") - - // --- S/MIME Section --- - b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n") - - if m.focusIndex == 0 { - b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n")) - } else { - b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n")) - } - b.WriteString(m.smimeCertInput.View() + "\n\n") - - if m.focusIndex == 1 { - b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n")) - } else { - b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n")) - } - b.WriteString(m.smimeKeyInput.View() + "\n\n") - - smimeSignStatus := "OFF" - if account.SMIMESignByDefault { - smimeSignStatus = "ON" - } - if m.focusIndex == 2 { - b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("Sign By Default: %s", smimeSignStatus)) + "\n\n") - } else { - b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("Sign By Default: %s", smimeSignStatus)) + "\n\n") - } - - // --- PGP Section --- - b.WriteString(settingsFocusedStyle.Render("PGP") + "\n") - - if m.focusIndex == 3 { - b.WriteString(settingsFocusedStyle.Render("Public Key Path:\n")) - } else { - b.WriteString(settingsBlurredStyle.Render("Public Key Path:\n")) - } - b.WriteString(m.pgpPublicKeyInput.View() + "\n\n") - - if m.focusIndex == 4 { - b.WriteString(settingsFocusedStyle.Render("Private Key Path:\n")) - } else { - b.WriteString(settingsBlurredStyle.Render("Private Key Path:\n")) - } - b.WriteString(m.pgpPrivateKeyInput.View() + "\n\n") - - // Key source toggle - keySourceDisplay := "File" - if m.pgpKeySource == "yubikey" { - keySourceDisplay = "YubiKey" - } - if m.focusIndex == 5 { - b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("Key Source: %s", keySourceDisplay)) + "\n\n") - } else { - b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("Key Source: %s", keySourceDisplay)) + "\n\n") - } - - // PIN input (only shown if YubiKey is selected) - if m.pgpKeySource == "yubikey" { - if m.focusIndex == 6 { - b.WriteString(settingsFocusedStyle.Render("YubiKey PIN:\n")) - } else { - b.WriteString(settingsBlurredStyle.Render("YubiKey PIN:\n")) - } - b.WriteString(m.pgpPINInput.View() + "\n\n") - } - - pgpSignStatus := "OFF" - if account.PGPSignByDefault { - pgpSignStatus = "ON" - } - if m.focusIndex == 7 { - b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("Sign By Default: %s", pgpSignStatus)) + "\n\n") - } else { - b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("Sign By Default: %s", pgpSignStatus)) + "\n\n") - } - - // --- Buttons --- - saveBtn := "[ Save ]" - cancelBtn := "[ Cancel ]" - - if m.focusIndex == 8 { - saveBtn = settingsFocusedStyle.Render(saveBtn) - } else { - saveBtn = settingsBlurredStyle.Render(saveBtn) - } - - if m.focusIndex == 9 { - cancelBtn = settingsFocusedStyle.Render(cancelBtn) - } else { - cancelBtn = settingsBlurredStyle.Render(cancelBtn) - } - - b.WriteString(saveBtn + " " + cancelBtn + "\n\n") - - mainContent := b.String() - helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • space: toggle • esc: back") - - if m.height > 0 { - currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) - gap := m.height - currentHeight - if gap > 0 { - mainContent += strings.Repeat("\n", gap) - } - } else { - mainContent += "\n\n" - } - - return docStyle.Render(mainContent + helpView) -} - -func (m *Settings) viewMailingLists() string { - var b strings.Builder - - b.WriteString(titleStyle.Render("Mailing Lists")) - b.WriteString("\n\n") - - if len(m.cfg.MailingLists) == 0 { - b.WriteString(accountEmailStyle.Render(" No mailing lists configured.\n")) - b.WriteString("\n") - } - - for i, list := range m.cfg.MailingLists { - displayName := list.Name - - addrCount := fmt.Sprintf("%d address", len(list.Addresses)) - if len(list.Addresses) != 1 { - addrCount += "es" - } - - line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount)) - - if m.cursor == i { - b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line))) - } else { - b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line))) - } - b.WriteString("\n") - } - - // Add Mailing List option - addListText := "Add New Mailing List" - if m.cursor == len(m.cfg.MailingLists) { - b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText))) - } else { - b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addListText))) - } - b.WriteString("\n") - - helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back") - mainContent := b.String() - - if m.height > 0 { - contentHeight := strings.Count(mainContent, "\n") + 1 - gap := m.height - contentHeight - 2 // -2 for margins - if gap > 0 { - mainContent += strings.Repeat("\n", gap) - } - } else { - mainContent += "\n\n" - } - - if m.confirmingDelete { - listName := m.cfg.MailingLists[m.cursor].Name - dialog := DialogBoxStyle.Render( - lipgloss.JoinVertical(lipgloss.Center, - dangerStyle.Render("Delete mailing list?"), - accountEmailStyle.Render(listName), - HelpStyle.Render("\n(y/n)"), - ), - ) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) - } - - return docStyle.Render(mainContent + helpView) -} - -func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - themes := theme.AllThemes() - - switch msg.String() { - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(themes)-1 { - m.cursor++ - } - case "enter": - if m.cursor < len(themes) { - selected := themes[m.cursor] - theme.SetTheme(selected.Name) - RebuildStyles() - m.cfg.Theme = selected.Name - _ = config.SaveConfig(m.cfg) - } - m.state = SettingsMain - m.cursor = 1 // Return to Theme option - return m, nil - case "esc": - m.state = SettingsMain - m.cursor = 1 // Return to Theme option - return m, nil - } - return m, nil -} - -func (m *Settings) viewTheme() string { - themes := theme.AllThemes() - - // Build left panel: theme list - var left strings.Builder - left.WriteString(titleStyle.Render("Theme") + "\n\n") - - for i, t := range themes { - isActive := t.Name == theme.ActiveTheme.Name - - label := t.Name - if isActive { - label += " (active)" - } - - if m.cursor == i { - left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label))) - } else { - left.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", label))) - } - left.WriteString("\n") - } - - left.WriteString("\n") - if !m.cfg.HideTips { - left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n") - } - - // Build right panel: theme preview - var previewTheme theme.Theme - if m.cursor < len(themes) { - previewTheme = themes[m.cursor] - } else { - previewTheme = theme.ActiveTheme - } - preview := renderThemePreview(previewTheme, m.width) - - // Join panels side by side - leftPanel := lipgloss.NewStyle().Width(30).Render(left.String()) - content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", preview) - - helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back") - - if m.height > 0 { - currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView)) + currentHeight := lipgloss.Height(content + "\n\n" + helpView) gap := m.height - currentHeight if gap > 0 { content += strings.Repeat("\n", gap) @@ -1085,241 +319,12 @@ func (m *Settings) viewTheme() string { content += "\n\n" } - return docStyle.Render(content + "\n" + helpView) -} - -// renderThemePreview renders a small mockup showing how a theme looks. -func renderThemePreview(t theme.Theme, maxWidth int) string { - previewWidth := maxWidth - 38 // 30 for left panel + padding/margins - if previewWidth < 30 { - previewWidth = 30 - } - if previewWidth > 60 { - previewWidth = 60 - } - - accent := lipgloss.NewStyle().Foreground(t.Accent) - accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - secondary := lipgloss.NewStyle().Foreground(t.Secondary) - muted := lipgloss.NewStyle().Foreground(t.MutedText) - dim := lipgloss.NewStyle().Foreground(t.DimText) - danger := lipgloss.NewStyle().Foreground(t.Danger) - warn := lipgloss.NewStyle().Foreground(t.Warning) - tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true) - link := lipgloss.NewStyle().Foreground(t.Link) - title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1) - activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true) - activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1) - - var b strings.Builder - - b.WriteString(title.Render("Preview: "+t.Name) + "\n\n") - - // Fake inbox tabs - b.WriteString(activeTab.Render("Inbox") + " " + secondary.Render("Sent") + " " + secondary.Render("Drafts") + "\n") - b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n") - - // Fake email list - b.WriteString(accentBold.Render("> ") + dim.Render("Alice ") + accent.Render("Meeting tomorrow") + " " + muted.Render("2m ago") + "\n") - b.WriteString(" " + dim.Render("Bob ") + secondary.Render("Re: Project update") + " " + muted.Render("1h ago") + "\n") - b.WriteString(" " + dim.Render("Carol ") + secondary.Render("Quick question") + " " + muted.Render("3h ago") + "\n\n") - - // Folder sidebar sample - b.WriteString(accentBold.Render("Folders") + "\n") - b.WriteString(activeFolder.Render(" INBOX ") + " " + secondary.Render("Sent") + " " + secondary.Render("Trash") + "\n\n") - - // Status indicators - b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n") - b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n") - b.WriteString(warn.Render("Update available: v2.0") + "\n") - b.WriteString(tip.Render("Tip: Press ? for help") + "\n") - b.WriteString(link.Render("https://example.com") + "\n") - - box := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.AccentDark). - Padding(1, 2). - Width(previewWidth). - Render(b.String()) - - return box -} - -func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - isEnabled := config.IsSecureModeEnabled() - - if isEnabled { - // Disable flow: confirmation dialog - if m.confirmingDisable { - switch msg.String() { - case "y", "Y": - m.confirmingDisable = false - cfg := m.cfg - return m, func() tea.Msg { - err := config.DisableSecureMode(cfg) - return SecureModeDisabledMsg{Err: err} - } - case "n", "N", "esc": - m.confirmingDisable = false - m.state = SettingsMain - m.cursor = 7 - return m, nil - } - return m, nil - } - - // Show disable prompt - m.confirmingDisable = true - return m, nil - } - - // Enable flow: password + confirm - switch msg.String() { - case "esc": - m.state = SettingsMain - m.cursor = 7 - return m, nil - case "tab", "shift+tab", "down", "up": - if msg.String() == "shift+tab" || msg.String() == "up" { - m.encFocusIndex-- - if m.encFocusIndex < 0 { - m.encFocusIndex = 2 // wrap to cancel - } - } else { - m.encFocusIndex++ - if m.encFocusIndex > 2 { - m.encFocusIndex = 0 - } - } - m.encPasswordInput.Blur() - m.encConfirmInput.Blur() - var cmds []tea.Cmd - switch m.encFocusIndex { - case 0: - cmds = append(cmds, m.encPasswordInput.Focus()) - case 1: - cmds = append(cmds, m.encConfirmInput.Focus()) - } - return m, tea.Batch(cmds...) - case "enter": - switch m.encFocusIndex { - case 0: // Password field — advance to confirm - m.encFocusIndex = 1 - m.encPasswordInput.Blur() - return m, m.encConfirmInput.Focus() - case 1: // Confirm field — advance to save - m.encFocusIndex = 2 - m.encConfirmInput.Blur() - return m, nil - case 2: // Save button - password := m.encPasswordInput.Value() - confirm := m.encConfirmInput.Value() - if password == "" { - m.encError = "Password cannot be empty" - return m, nil - } - if password != confirm { - m.encError = "Passwords do not match" - return m, nil - } - m.encEnabling = true - m.encError = "" - cfg := m.cfg - return m, func() tea.Msg { - err := config.EnableSecureMode(password, cfg) - return SecureModeEnabledMsg{Err: err} - } - } - } - - // Update text inputs - var cmd tea.Cmd - switch m.encFocusIndex { - case 0: - m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) - case 1: - m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) - } - return m, cmd -} - -func (m *Settings) viewEncryption() string { - var b strings.Builder - - isEnabled := config.IsSecureModeEnabled() - - b.WriteString(titleStyle.Render("Encryption") + "\n\n") - - if isEnabled { - // Disable mode - if m.confirmingDisable { - dialog := DialogBoxStyle.Render( - lipgloss.JoinVertical(lipgloss.Center, - dangerStyle.Render("Disable encryption?"), - accountEmailStyle.Render("All data will be stored unencrypted."), - HelpStyle.Render("\n(y/n)"), - ), - ) - return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) - } - - b.WriteString(settingsFocusedStyle.Render(" Encryption is currently enabled.") + "\n\n") - b.WriteString(accountEmailStyle.Render(" Press enter to disable encryption.") + "\n") - } else { - // Enable mode - b.WriteString(accountEmailStyle.Render(" Set a password to encrypt all data.") + "\n\n") - - if m.encFocusIndex == 0 { - b.WriteString(settingsFocusedStyle.Render("Password:\n")) - } else { - b.WriteString(settingsBlurredStyle.Render("Password:\n")) - } - b.WriteString(m.encPasswordInput.View() + "\n\n") - - if m.encFocusIndex == 1 { - b.WriteString(settingsFocusedStyle.Render("Confirm Password:\n")) - } else { - b.WriteString(settingsBlurredStyle.Render("Confirm Password:\n")) - } - b.WriteString(m.encConfirmInput.View() + "\n\n") - - saveBtn := "[ Enable Encryption ]" - if m.encFocusIndex == 2 { - saveBtn = settingsFocusedStyle.Render(saveBtn) - } else { - saveBtn = settingsBlurredStyle.Render(saveBtn) - } - b.WriteString(saveBtn + "\n") - - if m.encEnabling { - b.WriteString("\n" + accountEmailStyle.Render(" Encrypting data...") + "\n") - } - } - - if m.encError != "" { - b.WriteString("\n" + dangerStyle.Render(" "+m.encError) + "\n") - } - - mainContent := b.String() - helpView := helpStyle.Render("tab/shift+tab: navigate • enter: select • esc: back") - - if m.height > 0 { - currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) - gap := m.height - currentHeight - if gap > 0 { - mainContent += strings.Repeat("\n", gap) - } - } else { - mainContent += "\n\n" - } - - return docStyle.Render(mainContent + helpView) + return tea.NewView(docStyle.Render(content + helpView)) } -// UpdateConfig updates the configuration (used when accounts are deleted). func (m *Settings) UpdateConfig(cfg *config.Config) { m.cfg = cfg - if m.state == SettingsAccounts && m.cursor >= len(cfg.Accounts) { - m.cursor = len(cfg.Accounts) + if m.activeCategory == CategoryAccounts && m.accountsCursor >= len(cfg.Accounts) { + m.accountsCursor = len(cfg.Accounts) } } diff --git a/tui/settings_accounts.go b/tui/settings_accounts.go new file mode 100644 index 0000000000000000000000000000000000000000..844174ef0dcca63ef2646f07adf3e4a48b124b49 --- /dev/null +++ b/tui/settings_accounts.go @@ -0,0 +1,181 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.isCryptoConfig { + var m2 *Settings + var cmd tea.Cmd + m2, cmd = m.updateSMIMEConfig(msg) + return m2, cmd + } + + if m.confirmingDelete { + switch msg.String() { + case "y", "Y": + if m.accountsCursor < len(m.cfg.Accounts) { + accountID := m.cfg.Accounts[m.accountsCursor].ID + m.confirmingDelete = false + return m, func() tea.Msg { + return DeleteAccountMsg{AccountID: accountID} + } + } + case "n", "N", "esc": + m.confirmingDelete = false + return m, nil + } + return m, nil + } + + switch msg.String() { + case "up", "k": + if m.accountsCursor > 0 { + m.accountsCursor-- + } + case "down", "j": + if m.accountsCursor < len(m.cfg.Accounts) { + m.accountsCursor++ + } + case "d": + if m.accountsCursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 { + m.confirmingDelete = true + } + case "e": + if m.accountsCursor < len(m.cfg.Accounts) { + acc := m.cfg.Accounts[m.accountsCursor] + return m, func() tea.Msg { + return GoToEditAccountMsg{ + AccountID: acc.ID, + Provider: acc.ServiceProvider, + Name: acc.Name, + Email: acc.Email, + FetchEmail: acc.FetchEmail, + SendAsEmail: acc.SendAsEmail, + IMAPServer: acc.IMAPServer, + IMAPPort: acc.IMAPPort, + SMTPServer: acc.SMTPServer, + SMTPPort: acc.SMTPPort, + Insecure: acc.Insecure, + Protocol: acc.Protocol, + JMAPEndpoint: acc.JMAPEndpoint, + POP3Server: acc.POP3Server, + POP3Port: acc.POP3Port, + } + } + } + case "c": // Quick shortcut to crypto config + if m.accountsCursor < len(m.cfg.Accounts) { + m.enterCryptoConfig() + return m, textinput.Blink + } + case "enter": + if m.accountsCursor == len(m.cfg.Accounts) { + return m, func() tea.Msg { return GoToAddAccountMsg{} } + } else if m.accountsCursor < len(m.cfg.Accounts) { + m.enterCryptoConfig() + return m, textinput.Blink + } + } + return m, nil +} + +func (m *Settings) enterCryptoConfig() { + m.isCryptoConfig = true + m.editingAccountIdx = m.accountsCursor + acc := m.cfg.Accounts[m.accountsCursor] + + m.smimeCertInput.SetValue(acc.SMIMECert) + m.smimeKeyInput.SetValue(acc.SMIMEKey) + m.pgpPublicKeyInput.SetValue(acc.PGPPublicKey) + m.pgpPrivateKeyInput.SetValue(acc.PGPPrivateKey) + if acc.PGPKeySource == "" { + m.pgpKeySource = "file" + } else { + m.pgpKeySource = acc.PGPKeySource + } + m.pgpPINInput.SetValue(acc.PGPPIN) + + m.cryptoFocusIndex = 0 + m.smimeCertInput.Focus() + m.smimeKeyInput.Blur() + m.pgpPublicKeyInput.Blur() + m.pgpPrivateKeyInput.Blur() + m.pgpPINInput.Blur() +} + +func (m *Settings) viewAccounts() string { + if m.isCryptoConfig { + return m.viewSMIMEConfig() + } + + var b strings.Builder + b.WriteString(titleStyle.Render("Account Settings") + "\n\n") + + if len(m.cfg.Accounts) == 0 { + b.WriteString(accountEmailStyle.Render(" No accounts configured.\n\n")) + } + + for i, account := range m.cfg.Accounts { + displayName := account.Email + if account.Name != "" { + displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail) + } + + providerInfo := account.ServiceProvider + if account.ServiceProvider == "custom" { + providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer) + } + + if account.SMIMECert != "" && account.SMIMEKey != "" { + providerInfo += " [S/MIME Configured]" + } + if account.PGPPublicKey != "" && account.PGPPrivateKey != "" { + providerInfo += " [PGP Configured]" + } + + line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo)) + + cursor := " " + style := accountItemStyle + if m.accountsCursor == i { + cursor = "> " + style = selectedAccountItemStyle + } + + b.WriteString(style.Render(cursor+line) + "\n") + } + + // Add Account option + cursor := " " + style := accountItemStyle + if m.accountsCursor == len(m.cfg.Accounts) { + cursor = "> " + style = selectedAccountItemStyle + } + b.WriteString(style.Render(cursor+"Add New Account") + "\n\n") + + b.WriteString(helpStyle.Render("↑/↓: navigate • enter: edit crypto config • e: edit server • d: delete")) + + if m.confirmingDelete { + accountName := m.cfg.Accounts[m.accountsCursor].Email + dialog := DialogBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Center, + dangerStyle.Render("Delete account?"), + accountEmailStyle.Render(accountName), + HelpStyle.Render("\n(y/n)"), + ), + ) + // Try to overlay dialog in a reasonable way, since we don't have full screen width access easily here. + // Just append it. + b.WriteString("\n\n" + dialog) + } + + return b.String() +} diff --git a/tui/settings_crypto.go b/tui/settings_crypto.go new file mode 100644 index 0000000000000000000000000000000000000000..baf1acec0b8574e205c3d166a3eab0866cbd90ca --- /dev/null +++ b/tui/settings_crypto.go @@ -0,0 +1,179 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/floatpane/matcha/config" +) + +const cryptoConfigMaxFocus = 9 + +func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { + var cmds []tea.Cmd + + key := msg.Key() + isEnter := key.Code == tea.KeyEnter || key.Code == tea.KeyReturn || key.Code == tea.KeyKpEnter + isSpace := key.Code == tea.KeySpace + + setFocus := func(next int) tea.Cmd { + m.cryptoFocusIndex = next + m.smimeCertInput.Blur() + m.smimeKeyInput.Blur() + m.pgpPublicKeyInput.Blur() + m.pgpPrivateKeyInput.Blur() + m.pgpPINInput.Blur() + + switch m.cryptoFocusIndex { + case 0: + return m.smimeCertInput.Focus() + case 1: + return m.smimeKeyInput.Focus() + case 3: + return m.pgpPublicKeyInput.Focus() + case 4: + return m.pgpPrivateKeyInput.Focus() + case 6: + return m.pgpPINInput.Focus() + } + return nil + } + + switch msg.String() { + case "esc": + m.isCryptoConfig = false + return m, nil + case "tab", "shift+tab", "up", "down": + if msg.String() == "shift+tab" || msg.String() == "up" { + m.cryptoFocusIndex-- + if m.cryptoFocusIndex < 0 { + m.cryptoFocusIndex = cryptoConfigMaxFocus + } + } else { + m.cryptoFocusIndex++ + if m.cryptoFocusIndex > cryptoConfigMaxFocus { + m.cryptoFocusIndex = 0 + } + } + if m.cryptoFocusIndex == 6 && m.pgpKeySource != "yubikey" { + if msg.String() == "shift+tab" || msg.String() == "up" { + m.cryptoFocusIndex = 5 + } else { + m.cryptoFocusIndex = 7 + } + } + cmds = append(cmds, setFocus(m.cryptoFocusIndex)) + return m, tea.Batch(cmds...) + } + + if isEnter { + switch m.cryptoFocusIndex { + case 8: // Save + m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value() + m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value() + m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value() + m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value() + m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource + m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value() + _ = config.SaveConfig(m.cfg) + m.isCryptoConfig = false + return m, nil + case 9: // Cancel + m.isCryptoConfig = false + return m, nil + default: + // advance to next + next := m.cryptoFocusIndex + 1 + if next == 6 && m.pgpKeySource != "yubikey" { + next = 7 + } + cmds = append(cmds, setFocus(next)) + return m, tea.Batch(cmds...) + } + } + + if isSpace { + switch m.cryptoFocusIndex { + case 2: + m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault + return m, nil + case 5: + if m.pgpKeySource == "file" { + m.pgpKeySource = "yubikey" + } else { + m.pgpKeySource = "file" + } + return m, nil + case 7: + m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault + return m, nil + } + } + + return m, tea.Batch(cmds...) +} + +func (m *Settings) viewSMIMEConfig() string { + var b strings.Builder + account := m.cfg.Accounts[m.editingAccountIdx] + b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Config: %s", account.FetchEmail)) + "\n\n") + + renderField := func(index int, label, content string) { + if m.cryptoFocusIndex == index { + b.WriteString(settingsFocusedStyle.Render(label) + "\n") + } else { + b.WriteString(settingsBlurredStyle.Render(label) + "\n") + } + b.WriteString(content + "\n\n") + } + + // S/MIME + b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n") + renderField(0, "Certificate (PEM) Path:", m.smimeCertInput.View()) + renderField(1, "Private Key (PEM) Path:", m.smimeKeyInput.View()) + smimeSign := "OFF" + if account.SMIMESignByDefault { + smimeSign = "ON" + } + renderField(2, "Sign By Default:", smimeSign) + + // PGP + b.WriteString(settingsFocusedStyle.Render("PGP") + "\n") + renderField(3, "Public Key Path:", m.pgpPublicKeyInput.View()) + renderField(4, "Private Key Path:", m.pgpPrivateKeyInput.View()) + + keySource := "File" + if m.pgpKeySource == "yubikey" { + keySource = "YubiKey" + } + renderField(5, "Key Source:", keySource) + + if m.pgpKeySource == "yubikey" { + renderField(6, "YubiKey PIN:", m.pgpPINInput.View()) + } + + pgpSign := "OFF" + if account.PGPSignByDefault { + pgpSign = "ON" + } + renderField(7, "Sign By Default:", pgpSign) + + saveBtn := "[ Save ]" + cancelBtn := "[ Cancel ]" + if m.cryptoFocusIndex == 8 { + saveBtn = settingsFocusedStyle.Render(saveBtn) + } else { + saveBtn = settingsBlurredStyle.Render(saveBtn) + } + if m.cryptoFocusIndex == 9 { + cancelBtn = settingsFocusedStyle.Render(cancelBtn) + } else { + cancelBtn = settingsBlurredStyle.Render(cancelBtn) + } + + b.WriteString(saveBtn + " " + cancelBtn + "\n\n") + b.WriteString(helpStyle.Render("tab: next • enter: next/save • space: toggle • esc: cancel")) + + return b.String() +} diff --git a/tui/settings_encryption.go b/tui/settings_encryption.go new file mode 100644 index 0000000000000000000000000000000000000000..4bcc46095b2c1fa2f72b9b882bb27e9efe8928e1 --- /dev/null +++ b/tui/settings_encryption.go @@ -0,0 +1,149 @@ +package tui + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/config" +) + +func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + isEnabled := config.IsSecureModeEnabled() + + if isEnabled { + if m.confirmingDisable { + switch msg.String() { + case "y", "Y": + m.confirmingDisable = false + cfg := m.cfg + return m, func() tea.Msg { + err := config.DisableSecureMode(cfg) + return SecureModeDisabledMsg{Err: err} + } + case "n", "N", "esc": + m.confirmingDisable = false + return m, nil + } + return m, nil + } + if msg.String() == "enter" { + m.confirmingDisable = true + } + return m, nil + } + + switch msg.String() { + case "tab", "shift+tab", "down", "up": + if msg.String() == "shift+tab" || msg.String() == "up" { + m.encFocusIndex-- + if m.encFocusIndex < 0 { + m.encFocusIndex = 2 + } + } else { + m.encFocusIndex++ + if m.encFocusIndex > 2 { + m.encFocusIndex = 0 + } + } + m.encPasswordInput.Blur() + m.encConfirmInput.Blur() + var cmds []tea.Cmd + if m.encFocusIndex == 0 { + cmds = append(cmds, m.encPasswordInput.Focus()) + } + if m.encFocusIndex == 1 { + cmds = append(cmds, m.encConfirmInput.Focus()) + } + return m, tea.Batch(cmds...) + case "enter": + switch m.encFocusIndex { + case 0: + m.encFocusIndex = 1 + m.encPasswordInput.Blur() + return m, m.encConfirmInput.Focus() + case 1: + m.encFocusIndex = 2 + m.encConfirmInput.Blur() + return m, nil + case 2: + password := m.encPasswordInput.Value() + confirm := m.encConfirmInput.Value() + if password == "" { + m.encError = "Password cannot be empty" + return m, nil + } + if password != confirm { + m.encError = "Passwords do not match" + return m, nil + } + m.encEnabling = true + m.encError = "" + cfg := m.cfg + return m, func() tea.Msg { + err := config.EnableSecureMode(password, cfg) + return SecureModeEnabledMsg{Err: err} + } + } + } + return m, nil +} + +func (m *Settings) viewEncryption() string { + var b strings.Builder + isEnabled := config.IsSecureModeEnabled() + + b.WriteString(titleStyle.Render("App Encryption") + "\n\n") + + if isEnabled { + if m.confirmingDisable { + dialog := DialogBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Center, + dangerStyle.Render("Disable encryption?"), + accountEmailStyle.Render("All data will be stored unencrypted."), + HelpStyle.Render("\n(y/n)"), + ), + ) + b.WriteString(dialog + "\n") + } else { + b.WriteString(settingsFocusedStyle.Render(" Encryption is currently enabled.") + "\n\n") + b.WriteString(accountEmailStyle.Render(" Press enter to disable encryption.") + "\n\n") + b.WriteString(helpStyle.Render("enter: disable")) + } + } else { + b.WriteString(accountEmailStyle.Render("Set a password to encrypt all data.") + "\n\n") + + if m.encFocusIndex == 0 { + b.WriteString(settingsFocusedStyle.Render("Password:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("Password:\n")) + } + b.WriteString(m.encPasswordInput.View() + "\n\n") + + if m.encFocusIndex == 1 { + b.WriteString(settingsFocusedStyle.Render("Confirm Password:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("Confirm Password:\n")) + } + b.WriteString(m.encConfirmInput.View() + "\n\n") + + saveBtn := "[ Enable Encryption ]" + if m.encFocusIndex == 2 { + b.WriteString(settingsFocusedStyle.Render(saveBtn) + "\n") + } else { + b.WriteString(settingsBlurredStyle.Render(saveBtn) + "\n") + } + + if m.encEnabling { + b.WriteString("\n" + accountEmailStyle.Render(" Encrypting data...") + "\n") + } + + b.WriteString("\n" + helpStyle.Render("tab: next • enter: save")) + } + + if m.encError != "" { + b.WriteString("\n" + dangerStyle.Render(" "+m.encError) + "\n") + } + + return b.String() +} diff --git a/tui/settings_general.go b/tui/settings_general.go new file mode 100644 index 0000000000000000000000000000000000000000..5cf88b0200f10d3554a913c890069ac0228beae9 --- /dev/null +++ b/tui/settings_general.go @@ -0,0 +1,119 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/floatpane/matcha/config" +) + +func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.generalCursor > 0 { + m.generalCursor-- + } + case "down", "j": + if m.generalCursor < 4 { + m.generalCursor++ + } + case "enter", "space", "right", "l": + switch m.generalCursor { + case 0: // Image Display + m.cfg.DisableImages = !m.cfg.DisableImages + _ = config.SaveConfig(m.cfg) + case 1: // Contextual Tips + m.cfg.HideTips = !m.cfg.HideTips + _ = config.SaveConfig(m.cfg) + case 2: // Desktop Notifications + m.cfg.DisableNotifications = !m.cfg.DisableNotifications + _ = config.SaveConfig(m.cfg) + case 3: // Date Format + switch m.cfg.DateFormat { + case config.DateFormatEU: + m.cfg.DateFormat = config.DateFormatUS + case config.DateFormatUS: + m.cfg.DateFormat = config.DateFormatISO + default: // or ISO + m.cfg.DateFormat = config.DateFormatEU + } + _ = config.SaveConfig(m.cfg) + case 4: // Edit Signature + if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" { + return m, func() tea.Msg { return GoToSignatureEditorMsg{} } + } + } + } + return m, nil +} + +func (m *Settings) viewGeneral() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("General Settings") + "\n\n") + + options := []struct { + label string + value string + tip string + }{ + {"Disable Image Display", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails."}, + {"Hide Contextual Tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen."}, + {"Disable Notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail."}, + {"Date Format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed."}, + {"Signature", getSignatureStatus(), "Configure the signature appended to your outgoing emails."}, + } + + for i, opt := range options { + cursor := " " + style := accountItemStyle + if m.generalCursor == i { + cursor = "> " + style = selectedAccountItemStyle + } + + text := fmt.Sprintf("%s: %s", opt.label, opt.value) + if opt.label == "Signature" { + text = fmt.Sprintf("Edit Signature (%s)", opt.value) + } + + b.WriteString(style.Render(cursor+text) + "\n") + } + + b.WriteString("\n\n") + + if !m.cfg.HideTips && m.generalCursor < len(options) { + b.WriteString(TipStyle.Render("Tip: " + options[m.generalCursor].tip)) + } + + return b.String() +} + +func onOff(b bool) string { + if b { + return "ON" + } + return "OFF" +} + +func getDateFormatLabel(f string) string { + if f == "" { + f = config.DateFormatEU + } + switch f { + case config.DateFormatUS: + return "US (MM/DD/YYYY hh:MM AM)" + case config.DateFormatISO: + return "ISO (YYYY-MM-DD HH:MM)" + default: + return "EU (DD/MM/YYYY HH:MM)" + } +} + +func getSignatureStatus() string { + if config.HasSignature() { + return "configured" + } + return "not configured" +} diff --git a/tui/settings_lists.go b/tui/settings_lists.go new file mode 100644 index 0000000000000000000000000000000000000000..e6a401391d39bd8e86a0ddd56e08de3f397ebc51 --- /dev/null +++ b/tui/settings_lists.go @@ -0,0 +1,112 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/config" +) + +func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.confirmingDelete { + switch msg.String() { + case "y", "Y": + if m.listsCursor < len(m.cfg.MailingLists) { + m.cfg.MailingLists = append(m.cfg.MailingLists[:m.listsCursor], m.cfg.MailingLists[m.listsCursor+1:]...) + _ = config.SaveConfig(m.cfg) + if m.listsCursor >= len(m.cfg.MailingLists) && m.listsCursor > 0 { + m.listsCursor-- + } + m.confirmingDelete = false + } + case "n", "N", "esc": + m.confirmingDelete = false + return m, nil + } + return m, nil + } + + switch msg.String() { + case "up", "k": + if m.listsCursor > 0 { + m.listsCursor-- + } + case "down", "j": + if m.listsCursor < len(m.cfg.MailingLists) { + m.listsCursor++ + } + case "d": + if m.listsCursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 { + m.confirmingDelete = true + } + case "e": + if m.listsCursor < len(m.cfg.MailingLists) { + list := m.cfg.MailingLists[m.listsCursor] + idx := m.listsCursor + return m, func() tea.Msg { + return GoToEditMailingListMsg{ + Index: idx, + Name: list.Name, + Addresses: strings.Join(list.Addresses, ", "), + } + } + } + case "enter": + if m.listsCursor == len(m.cfg.MailingLists) { + return m, func() tea.Msg { return GoToAddMailingListMsg{} } + } + } + return m, nil +} + +func (m *Settings) viewMailingLists() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("Mailing Lists") + "\n\n") + + if len(m.cfg.MailingLists) == 0 { + b.WriteString(accountEmailStyle.Render(" No mailing lists configured.\n\n")) + } + + for i, list := range m.cfg.MailingLists { + addrCount := fmt.Sprintf("%d address", len(list.Addresses)) + if len(list.Addresses) != 1 { + addrCount += "es" + } + line := fmt.Sprintf("%s - %s", list.Name, accountEmailStyle.Render(addrCount)) + + cursor := " " + style := accountItemStyle + if m.listsCursor == i { + cursor = "> " + style = selectedAccountItemStyle + } + b.WriteString(style.Render(cursor+line) + "\n") + } + + cursor := " " + style := accountItemStyle + if m.listsCursor == len(m.cfg.MailingLists) { + cursor = "> " + style = selectedAccountItemStyle + } + b.WriteString(style.Render(cursor+"Add New Mailing List") + "\n\n") + + b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete")) + + if m.confirmingDelete { + listName := m.cfg.MailingLists[m.listsCursor].Name + dialog := DialogBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Center, + dangerStyle.Render("Delete mailing list?"), + accountEmailStyle.Render(listName), + HelpStyle.Render("\n(y/n)"), + ), + ) + b.WriteString("\n\n" + dialog) + } + + return b.String() +} diff --git a/tui/settings_theme.go b/tui/settings_theme.go new file mode 100644 index 0000000000000000000000000000000000000000..862056cf88c84a330c44bfdb36b795ea0c4ed40b --- /dev/null +++ b/tui/settings_theme.go @@ -0,0 +1,131 @@ +package tui + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/theme" +) + +func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + themes := theme.AllThemes() + + switch msg.String() { + case "up", "k": + if m.themeCursor > 0 { + m.themeCursor-- + } + case "down", "j": + if m.themeCursor < len(themes)-1 { + m.themeCursor++ + } + case "enter", "space", "right", "l": + if m.themeCursor < len(themes) { + selected := themes[m.themeCursor] + theme.SetTheme(selected.Name) + RebuildStyles() + m.cfg.Theme = selected.Name + _ = config.SaveConfig(m.cfg) + } + } + return m, nil +} + +func (m *Settings) viewTheme() string { + themes := theme.AllThemes() + var b strings.Builder + + b.WriteString(titleStyle.Render("Theme") + "\n\n") + + for i, t := range themes { + isActive := t.Name == theme.ActiveTheme.Name + label := t.Name + if isActive { + label += " (active)" + } + + cursor := " " + style := accountItemStyle + if m.themeCursor == i { + cursor = "> " + style = selectedAccountItemStyle + } + + b.WriteString(style.Render(cursor+label) + "\n") + } + + b.WriteString("\n") + + // Preview + var previewTheme theme.Theme + if m.themeCursor < len(themes) { + previewTheme = themes[m.themeCursor] + } else { + previewTheme = theme.ActiveTheme + } + + previewWidth := m.width - 34 - 4 + if previewWidth < 30 { + previewWidth = 30 + } + + b.WriteString(renderThemePreview(previewTheme, previewWidth) + "\n\n") + + if !m.cfg.HideTips { + b.WriteString(TipStyle.Render("Tip: Custom themes can be added as JSON files in ~/.config/matcha/themes/") + "\n\n") + } + + b.WriteString(helpStyle.Render("↑/↓: navigate • enter/space: apply theme")) + + return b.String() +} + +// renderThemePreview renders a small mockup showing how a theme looks. +func renderThemePreview(t theme.Theme, previewWidth int) string { + if previewWidth > 60 { + previewWidth = 60 + } + + accent := lipgloss.NewStyle().Foreground(t.Accent) + accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true) + secondary := lipgloss.NewStyle().Foreground(t.Secondary) + muted := lipgloss.NewStyle().Foreground(t.MutedText) + dim := lipgloss.NewStyle().Foreground(t.DimText) + danger := lipgloss.NewStyle().Foreground(t.Danger) + warn := lipgloss.NewStyle().Foreground(t.Warning) + tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true) + link := lipgloss.NewStyle().Foreground(t.Link) + title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1) + activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true) + activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1) + + var b strings.Builder + + b.WriteString(title.Render("Preview: "+t.Name) + "\n\n") + b.WriteString(activeTab.Render("Inbox") + " " + secondary.Render("Sent") + " " + secondary.Render("Drafts") + "\n") + b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n") + + b.WriteString(accentBold.Render("> ") + dim.Render("Alice ") + accent.Render("Meeting tomorrow") + " " + muted.Render("2m ago") + "\n") + b.WriteString(" " + dim.Render("Bob ") + secondary.Render("Re: Project update") + " " + muted.Render("1h ago") + "\n") + b.WriteString(" " + dim.Render("Carol ") + secondary.Render("Quick question") + " " + muted.Render("3h ago") + "\n\n") + + b.WriteString(accentBold.Render("Folders") + "\n") + b.WriteString(activeFolder.Render(" INBOX ") + " " + secondary.Render("Sent") + " " + secondary.Render("Trash") + "\n\n") + + b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n") + b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n") + b.WriteString(warn.Render("Update available: v2.0") + "\n") + b.WriteString(tip.Render("Tip: Press ? for help") + "\n") + b.WriteString(link.Render("https://example.com") + "\n") + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.AccentDark). + Padding(1, 2). + Width(previewWidth). + Render(b.String()) + + return box +} diff --git a/tui/theme.go b/tui/theme.go index dcec7c06a4c3b6122e6e9615868238fb85f558b7..ab6ac47d74eec4bc38afadb6a06cdbb4048efe6b 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -117,10 +117,6 @@ func RebuildStyles() { filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Accent) directoryStyle = lipgloss.NewStyle().Foreground(t.Directory) fileSizeStyle = lipgloss.NewStyle().Foreground(t.Secondary) - - // trash_archive.go - mailboxTabStyle = lipgloss.NewStyle().Padding(0, 3) - activeMailboxTabStyle = lipgloss.NewStyle().Padding(0, 3).Foreground(t.Accent).Bold(true).Underline(true) } // ThemedTextInputStyles returns textinput.Styles using the active theme colors. diff --git a/tui/trash_archive.go b/tui/trash_archive.go deleted file mode 100644 index 00a225aa88d3932483b73c0816dc01ab75656919..0000000000000000000000000000000000000000 --- a/tui/trash_archive.go +++ /dev/null @@ -1,154 +0,0 @@ -package tui - -import ( - "strings" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/floatpane/matcha/config" - "github.com/floatpane/matcha/fetcher" -) - -var ( - mailboxTabStyle = lipgloss.NewStyle().Padding(0, 3) - activeMailboxTabStyle = lipgloss.NewStyle().Padding(0, 3).Foreground(lipgloss.Color("42")).Bold(true).Underline(true) - mailboxTabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1) -) - -// TrashArchive is a combined view for trash and archive emails with a toggle -type TrashArchive struct { - trashInbox *Inbox - archiveInbox *Inbox - activeView MailboxKind // MailboxTrash or MailboxArchive - width int - height int - accounts []config.Account -} - -// NewTrashArchive creates a new combined trash/archive view -func NewTrashArchive(trashEmails, archiveEmails []fetcher.Email, accounts []config.Account) *TrashArchive { - return &TrashArchive{ - trashInbox: NewTrashInbox(trashEmails, accounts), - archiveInbox: NewArchiveInbox(archiveEmails, accounts), - activeView: MailboxTrash, - accounts: accounts, - } -} - -func (m *TrashArchive) Init() tea.Cmd { - return nil -} - -func (m *TrashArchive) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch msg.String() { - case "tab": - // Toggle between trash and archive - if m.activeView == MailboxTrash { - m.activeView = MailboxArchive - } else { - m.activeView = MailboxTrash - } - return m, nil - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - // Pass to both inboxes - m.trashInbox.Update(msg) - m.archiveInbox.Update(msg) - return m, nil - - case FetchingMoreEmailsMsg, EmailsAppendedMsg, RefreshingEmailsMsg, EmailsRefreshedMsg: - // Forward to the appropriate inbox based on mailbox - if m.activeView == MailboxTrash { - m.trashInbox.Update(msg) - } else { - m.archiveInbox.Update(msg) - } - return m, nil - } - - // Forward other messages to the active inbox - var cmd tea.Cmd - if m.activeView == MailboxTrash { - _, cmd = m.trashInbox.Update(msg) - } else { - _, cmd = m.archiveInbox.Update(msg) - } - return m, cmd -} - -func (m *TrashArchive) View() tea.View { - var b strings.Builder - - // Render the mailbox toggle tabs - var tabViews []string - if m.activeView == MailboxTrash { - tabViews = append(tabViews, activeMailboxTabStyle.Render("Trash")) - tabViews = append(tabViews, mailboxTabStyle.Render("Archive")) - } else { - tabViews = append(tabViews, mailboxTabStyle.Render("Trash")) - tabViews = append(tabViews, activeMailboxTabStyle.Render("Archive")) - } - tabBar := mailboxTabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...)) - b.WriteString(tabBar) - b.WriteString("\n") - - // Add help text for tab switching - helpText := helpStyle.Render("Press tab to switch between Trash and Archive") - b.WriteString(helpText) - b.WriteString("\n\n") - - // Render the active inbox - if m.activeView == MailboxTrash { - b.WriteString(m.trashInbox.View().Content) - } else { - b.WriteString(m.archiveInbox.View().Content) - } - - return tea.NewView(b.String()) -} - -// GetActiveMailbox returns the currently active mailbox kind -func (m *TrashArchive) GetActiveMailbox() MailboxKind { - return m.activeView -} - -// GetActiveInbox returns the currently active inbox -func (m *TrashArchive) GetActiveInbox() *Inbox { - if m.activeView == MailboxTrash { - return m.trashInbox - } - return m.archiveInbox -} - -// RemoveEmail removes an email from the appropriate inbox -func (m *TrashArchive) RemoveEmail(uid uint32, accountID string, mailbox MailboxKind) { - if mailbox == MailboxTrash { - m.trashInbox.RemoveEmail(uid, accountID) - } else if mailbox == MailboxArchive { - m.archiveInbox.RemoveEmail(uid, accountID) - } -} - -// SetTrashEmails updates the trash emails -func (m *TrashArchive) SetTrashEmails(emails []fetcher.Email, accounts []config.Account) { - m.trashInbox.SetEmails(emails, accounts) - m.accounts = accounts -} - -// SetArchiveEmails updates the archive emails -func (m *TrashArchive) SetArchiveEmails(emails []fetcher.Email, accounts []config.Account) { - m.archiveInbox.SetEmails(emails, accounts) - m.accounts = accounts -} - -// AdditionalShortHelpKeys returns additional help keys for the trash/archive view -func (m *TrashArchive) AdditionalShortHelpKeys() []key.Binding { - return []key.Binding{ - key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch view")), - } -} diff --git a/tui/trash_archive_test.go b/tui/trash_archive_test.go deleted file mode 100644 index bd1cda9398b38580a5399676d5c845e9ff569feb..0000000000000000000000000000000000000000 --- a/tui/trash_archive_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package tui - -import ( - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/floatpane/matcha/config" - "github.com/floatpane/matcha/fetcher" -) - -// TestNewTrashArchive verifies that a new TrashArchive is created correctly. -func TestNewTrashArchive(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com", FetchEmail: "fetch@example.com"}, - } - - trashEmails := []fetcher.Email{ - {UID: 1, From: "a@example.com", Subject: "Trash Email 1", AccountID: "account-1"}, - } - - archiveEmails := []fetcher.Email{ - {UID: 2, From: "b@example.com", Subject: "Archive Email 1", AccountID: "account-1"}, - } - - ta := NewTrashArchive(trashEmails, archiveEmails, accounts) - - // Default view should be Trash - if ta.activeView != MailboxTrash { - t.Errorf("Expected default view to be MailboxTrash, got %v", ta.activeView) - } - - // GetActiveMailbox should return Trash - if ta.GetActiveMailbox() != MailboxTrash { - t.Errorf("Expected GetActiveMailbox to return MailboxTrash, got %v", ta.GetActiveMailbox()) - } - - // GetActiveInbox should return trashInbox - if ta.GetActiveInbox() != ta.trashInbox { - t.Error("Expected GetActiveInbox to return trashInbox") - } -} - -// TestTrashArchiveToggle verifies toggling between trash and archive views. -func TestTrashArchiveToggle(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - trashEmails := []fetcher.Email{ - {UID: 1, From: "a@example.com", Subject: "Trash Email", AccountID: "account-1"}, - } - - archiveEmails := []fetcher.Email{ - {UID: 2, From: "b@example.com", Subject: "Archive Email", AccountID: "account-1"}, - } - - ta := NewTrashArchive(trashEmails, archiveEmails, accounts) - - // Initially should be Trash - if ta.activeView != MailboxTrash { - t.Fatalf("Expected initial view to be MailboxTrash, got %v", ta.activeView) - } - - // Press tab to switch to Archive - ta.Update(tea.KeyPressMsg{Code: tea.KeyTab}) - - if ta.activeView != MailboxArchive { - t.Errorf("Expected view to be MailboxArchive after tab, got %v", ta.activeView) - } - - if ta.GetActiveInbox() != ta.archiveInbox { - t.Error("Expected GetActiveInbox to return archiveInbox after toggle") - } - - // Press tab again to switch back to Trash - ta.Update(tea.KeyPressMsg{Code: tea.KeyTab}) - - if ta.activeView != MailboxTrash { - t.Errorf("Expected view to be MailboxTrash after second tab, got %v", ta.activeView) - } -} - -// TestTrashArchiveRemoveEmail verifies removing emails from the correct inbox. -func TestTrashArchiveRemoveEmail(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - trashEmails := []fetcher.Email{ - {UID: 1, From: "a@example.com", Subject: "Trash Email 1", AccountID: "account-1"}, - {UID: 2, From: "b@example.com", Subject: "Trash Email 2", AccountID: "account-1"}, - } - - archiveEmails := []fetcher.Email{ - {UID: 3, From: "c@example.com", Subject: "Archive Email 1", AccountID: "account-1"}, - {UID: 4, From: "d@example.com", Subject: "Archive Email 2", AccountID: "account-1"}, - } - - ta := NewTrashArchive(trashEmails, archiveEmails, accounts) - - // Remove a trash email - ta.RemoveEmail(1, "account-1", MailboxTrash) - - if len(ta.trashInbox.allEmails) != 1 { - t.Errorf("Expected 1 trash email after removal, got %d", len(ta.trashInbox.allEmails)) - } - - // Archive emails should be unchanged - if len(ta.archiveInbox.allEmails) != 2 { - t.Errorf("Expected 2 archive emails unchanged, got %d", len(ta.archiveInbox.allEmails)) - } - - // Remove an archive email - ta.RemoveEmail(3, "account-1", MailboxArchive) - - if len(ta.archiveInbox.allEmails) != 1 { - t.Errorf("Expected 1 archive email after removal, got %d", len(ta.archiveInbox.allEmails)) - } -} - -// TestTrashArchiveSetEmails verifies updating emails in trash and archive. -func TestTrashArchiveSetEmails(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - ta := NewTrashArchive(nil, nil, accounts) - - // Set trash emails - newTrashEmails := []fetcher.Email{ - {UID: 10, From: "new@example.com", Subject: "New Trash", AccountID: "account-1"}, - } - ta.SetTrashEmails(newTrashEmails, accounts) - - if len(ta.trashInbox.allEmails) != 1 { - t.Errorf("Expected 1 trash email after SetTrashEmails, got %d", len(ta.trashInbox.allEmails)) - } - - // Set archive emails - newArchiveEmails := []fetcher.Email{ - {UID: 20, From: "archive@example.com", Subject: "New Archive", AccountID: "account-1"}, - {UID: 21, From: "archive2@example.com", Subject: "New Archive 2", AccountID: "account-1"}, - } - ta.SetArchiveEmails(newArchiveEmails, accounts) - - if len(ta.archiveInbox.allEmails) != 2 { - t.Errorf("Expected 2 archive emails after SetArchiveEmails, got %d", len(ta.archiveInbox.allEmails)) - } -} - -// TestTrashArchiveWindowSizeMsg verifies window size is passed to both inboxes. -func TestTrashArchiveWindowSizeMsg(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - ta := NewTrashArchive(nil, nil, accounts) - - ta.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) - - if ta.width != 100 { - t.Errorf("Expected width 100, got %d", ta.width) - } - if ta.height != 50 { - t.Errorf("Expected height 50, got %d", ta.height) - } -} - -// TestTrashArchiveViewEmailMsg verifies that selecting an email generates correct message. -func TestTrashArchiveViewEmailMsg(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - trashEmails := []fetcher.Email{ - {UID: 100, From: "sender@example.com", Subject: "Test Trash", AccountID: "account-1", Date: time.Now()}, - } - - ta := NewTrashArchive(trashEmails, nil, accounts) - - // Simulate pressing Enter on trash view - _, cmd := ta.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd == nil { - t.Fatal("Expected a command, but got nil") - } - - msg := cmd() - viewMsg, ok := msg.(ViewEmailMsg) - if !ok { - t.Fatalf("Expected ViewEmailMsg, got %T", msg) - } - - if viewMsg.UID != 100 { - t.Errorf("Expected UID 100, got %d", viewMsg.UID) - } - - if viewMsg.Mailbox != MailboxTrash { - t.Errorf("Expected Mailbox MailboxTrash, got %v", viewMsg.Mailbox) - } -} - -// TestTrashArchiveDeleteEmailMsg verifies delete messages from trash/archive views. -func TestTrashArchiveDeleteEmailMsg(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - archiveEmails := []fetcher.Email{ - {UID: 200, From: "sender@example.com", Subject: "Test Archive", AccountID: "account-1", Date: time.Now()}, - } - - ta := NewTrashArchive(nil, archiveEmails, accounts) - - // Switch to archive view - ta.Update(tea.KeyPressMsg{Code: tea.KeyTab}) - - // Simulate pressing 'd' to delete - _, cmd := ta.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) - if cmd == nil { - t.Fatal("Expected a command, but got nil") - } - - msg := cmd() - deleteMsg, ok := msg.(DeleteEmailMsg) - if !ok { - t.Fatalf("Expected DeleteEmailMsg, got %T", msg) - } - - if deleteMsg.UID != 200 { - t.Errorf("Expected UID 200, got %d", deleteMsg.UID) - } - - if deleteMsg.Mailbox != MailboxArchive { - t.Errorf("Expected Mailbox MailboxArchive, got %v", deleteMsg.Mailbox) - } -} - -// TestTrashArchiveMultipleAccounts verifies tabs are created for multiple accounts. -func TestTrashArchiveMultipleAccounts(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test1@example.com", FetchEmail: "fetch1@example.com"}, - {ID: "account-2", Email: "test2@example.com", FetchEmail: "fetch2@example.com"}, - } - - trashEmails := []fetcher.Email{ - {UID: 1, From: "a@example.com", Subject: "Trash 1", AccountID: "account-1"}, - {UID: 2, From: "b@example.com", Subject: "Trash 2", AccountID: "account-2"}, - } - - ta := NewTrashArchive(trashEmails, nil, accounts) - - // Both trash and archive inboxes should have 3 tabs (ALL + 2 accounts) - if len(ta.trashInbox.tabs) != 3 { - t.Errorf("Expected 3 tabs in trashInbox, got %d", len(ta.trashInbox.tabs)) - } - - if len(ta.archiveInbox.tabs) != 3 { - t.Errorf("Expected 3 tabs in archiveInbox, got %d", len(ta.archiveInbox.tabs)) - } - - // Verify FetchEmail is used for tab labels - if ta.trashInbox.tabs[1].Label != "fetch1@example.com" { - t.Errorf("Expected tab label 'fetch1@example.com', got %q", ta.trashInbox.tabs[1].Label) - } -} - -// TestTrashInboxMailboxKind verifies that trash inbox has correct mailbox kind. -func TestTrashInboxMailboxKind(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - inbox := NewTrashInbox(nil, accounts) - - if inbox.GetMailbox() != MailboxTrash { - t.Errorf("Expected MailboxTrash, got %v", inbox.GetMailbox()) - } -} - -// TestArchiveInboxMailboxKind verifies that archive inbox has correct mailbox kind. -func TestArchiveInboxMailboxKind(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - inbox := NewArchiveInbox(nil, accounts) - - if inbox.GetMailbox() != MailboxArchive { - t.Errorf("Expected MailboxArchive, got %v", inbox.GetMailbox()) - } -} - -// TestInboxGetBaseTitle verifies correct titles for different mailbox kinds. -func TestInboxGetBaseTitle(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "test@example.com"}, - } - - tests := []struct { - name string - mailbox MailboxKind - expected string - }{ - {"Inbox", MailboxInbox, "Inbox"}, - {"Sent", MailboxSent, "Sent"}, - {"Trash", MailboxTrash, "Trash"}, - {"Archive", MailboxArchive, "Archive"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - inbox := NewInboxWithMailbox(nil, accounts, tt.mailbox) - title := inbox.getBaseTitle() - if title != tt.expected { - t.Errorf("Expected title %q, got %q", tt.expected, title) - } - }) - } -} - -// TestInboxFetchEmailUsedForTabs verifies that FetchEmail is used for tab labels. -func TestInboxFetchEmailUsedForTabs(t *testing.T) { - accounts := []config.Account{ - {ID: "account-1", Email: "login@example.com", FetchEmail: "display@example.com"}, - {ID: "account-2", Email: "login2@example.com"}, // No FetchEmail, should fallback to Email - } - - inbox := NewInbox(nil, accounts) - - // First account should use FetchEmail - if inbox.tabs[1].Label != "display@example.com" { - t.Errorf("Expected tab label 'display@example.com', got %q", inbox.tabs[1].Label) - } - - // Second account should fallback to Email - if inbox.tabs[2].Label != "login2@example.com" { - t.Errorf("Expected tab label 'login2@example.com', got %q", inbox.tabs[2].Label) - } -}