Detailed changes
@@ -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()
@@ -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))
}
@@ -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
@@ -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)
@@ -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()
+}
@@ -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()
+}
@@ -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()
+}
@@ -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"
+}
@@ -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()
+}
@@ -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
+}
@@ -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.
@@ -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")),
- }
-}
@@ -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)
- }
-}