folder_inbox.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"maps"
  6	"sort"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/key"
 10	"charm.land/bubbles/v2/list"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/floatpane/matcha/config"
 14	"github.com/floatpane/matcha/fetcher"
 15)
 16
 17const sidebarWidth = 25
 18
 19var (
 20	sidebarStyle = lipgloss.NewStyle().
 21			Width(sidebarWidth).
 22			BorderStyle(lipgloss.NormalBorder()).
 23			BorderRight(true).
 24			PaddingRight(1).
 25			PaddingLeft(1)
 26
 27	sidebarTitleStyle = lipgloss.NewStyle().
 28				Foreground(lipgloss.Color("42")).
 29				Bold(true).
 30				PaddingBottom(1)
 31
 32	folderStyle = lipgloss.NewStyle().
 33			PaddingLeft(1).
 34			PaddingRight(1)
 35
 36	activeFolderStyle = lipgloss.NewStyle().
 37				PaddingLeft(1).
 38				PaddingRight(1).
 39				Background(lipgloss.Color("42")).
 40				Foreground(lipgloss.Color("#000000")).
 41				Bold(true)
 42
 43	moveOverlayStyle = lipgloss.NewStyle().
 44				Border(lipgloss.RoundedBorder()).
 45				BorderForeground(lipgloss.Color("#25A065")).
 46				Padding(1, 2)
 47
 48	moveOverlayTitleStyle = lipgloss.NewStyle().
 49				Foreground(lipgloss.Color("42")).
 50				Bold(true).
 51				PaddingBottom(1)
 52
 53	moveItemStyle = lipgloss.NewStyle().
 54			PaddingLeft(1)
 55
 56	moveSelectedItemStyle = lipgloss.NewStyle().
 57				PaddingLeft(1).
 58				Foreground(lipgloss.Color("42")).
 59				Bold(true)
 60
 61	inboxPaneStyle = lipgloss.NewStyle().
 62			BorderStyle(lipgloss.NormalBorder()).
 63			BorderRight(true).
 64			PaddingRight(1)
 65
 66	previewPaneStyle = lipgloss.NewStyle().
 67				BorderStyle(lipgloss.NormalBorder()).
 68				BorderLeft(true).
 69				PaddingLeft(1)
 70
 71	focusedBorderColor   = lipgloss.Color("42")
 72	unfocusedBorderColor = lipgloss.Color("240")
 73)
 74
 75type PaneType int
 76
 77const (
 78	FocusInbox PaneType = iota
 79	FocusPreview
 80)
 81
 82// FolderInbox combines a folder sidebar with an email list.
 83type FolderInbox struct {
 84	folders         []string
 85	unread          map[string]int
 86	activeFolderIdx int
 87	currentFolder   string
 88	inbox           *Inbox
 89	accounts        []config.Account
 90	width           int
 91	height          int
 92	isLoadingEmails bool
 93
 94	// Move-to-folder overlay state
 95	movingEmail      bool
 96	moveTargetIdx    int
 97	moveUID          uint32   // Legacy: single UID
 98	moveUIDs         []uint32 // Batch: multiple UIDs
 99	moveAccountID    string
100	moveSourceFolder string
101
102	// Image rendering preference, propagated from config.
103	disableImages bool
104
105	// Split pane state
106	previewPane        *EmailView
107	previewedUID       uint32
108	previewedAccountID string
109	// previewSearchEmail holds an Email handed in by OpenSplitPreview for hits
110	// that do not live in m.inbox.allEmails (search results across folders).
111	// findEmailByUID falls back to it when allEmails has no match.
112	previewSearchEmail *fetcher.Email
113	focusedPane        PaneType
114}
115
116func (m *FolderInbox) GetUnreadCountsCopy() map[string]int {
117	if m.unread == nil {
118		return make(map[string]int)
119	}
120	result := make(map[string]int)
121	maps.Copy(result, m.unread)
122	return result
123}
124
125// sortFolders sorts folder names with INBOX always first, then alphabetically.
126func sortFolders(folders []string) []string {
127	sorted := make([]string, len(folders))
128	copy(sorted, folders)
129	sort.SliceStable(sorted, func(i, j int) bool {
130		iUpper := strings.ToUpper(sorted[i])
131		jUpper := strings.ToUpper(sorted[j])
132		if iUpper == keyINBOX {
133			return true
134		}
135		if jUpper == keyINBOX {
136			return false
137		}
138		return sorted[i] < sorted[j]
139	})
140	return sorted
141}
142
143// SetDateFormat propagates the configured date layout to the inner inbox.
144func (m *FolderInbox) SetDateFormat(layout string) {
145	if m.inbox != nil {
146		m.inbox.SetDateFormat(layout)
147	}
148}
149
150// SetDetailedDates propagates the detailed date display toggle.
151func (m *FolderInbox) SetDetailedDates(enabled bool) {
152	if m.inbox != nil {
153		m.inbox.SetDetailedDates(enabled)
154	}
155}
156
157// SetDefaultThreaded propagates the global default threading toggle.
158func (m *FolderInbox) SetDefaultThreaded(v bool) {
159	if m.inbox != nil {
160		m.inbox.SetDefaultThreaded(v)
161	}
162}
163
164// SetDisableImages propagates the global image-display preference. Affects
165// future split-view previews; an already-open preview keeps its current state.
166func (m *FolderInbox) SetDisableImages(v bool) {
167	m.disableImages = v
168}
169
170// NewFolderInbox creates a new FolderInbox with the given folders and accounts.
171func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox {
172	folders = sortFolders(folders)
173	currentFolder := keyINBOX
174	if len(folders) > 0 {
175		currentFolder = folders[0]
176	}
177
178	inbox := NewInbox(nil, accounts)
179	inbox.SetFolderName(currentFolder)
180
181	fi := &FolderInbox{
182		folders:         folders,
183		activeFolderIdx: 0,
184		currentFolder:   currentFolder,
185		inbox:           inbox,
186		accounts:        accounts,
187	}
188	fi.updateHelpKeys()
189	return fi
190}
191
192func (m *FolderInbox) Init() tea.Cmd {
193	return nil
194}
195
196func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
197	// If move overlay is active, handle its input
198	if m.movingEmail {
199		return m.updateMoveOverlay(msg)
200	}
201
202	switch msg := msg.(type) {
203	case tea.KeyPressMsg:
204		// Don't intercept keys while filtering
205		if m.inbox.list.FilterState() == list.Filtering {
206			break
207		}
208
209		// Don't intercept keys while the inbox search overlay is active.
210		// Otherwise folder-level bindings like "m" (move) would shadow text input.
211		if m.inbox.searchOverlay != nil {
212			break
213		}
214
215		kb := config.Keybinds
216
217		// Route input to preview pane when focused
218		if m.previewPane != nil && m.focusedPane == FocusPreview {
219			s := msg.String()
220			if s != kb.Folder.FocusInbox && s != kb.Folder.FocusPreview && s != kb.Global.Cancel && s != "q" {
221				var cmd tea.Cmd
222				_, cmd = m.previewPane.Update(msg)
223				return m, cmd
224			}
225		}
226
227		switch msg.String() {
228		case kb.Folder.FocusPreview:
229			// Switch focus to preview pane
230			if m.previewPane != nil && m.focusedPane == FocusInbox {
231				m.focusedPane = FocusPreview
232				return m, nil
233			}
234		case kb.Folder.FocusInbox:
235			// Switch focus to inbox pane
236			if m.previewPane != nil && m.focusedPane == FocusPreview {
237				m.focusedPane = FocusInbox
238				return m, nil
239			}
240		case kb.Folder.NextFolder:
241			m.activeFolderIdx++
242			if m.activeFolderIdx >= len(m.folders) {
243				m.activeFolderIdx = 0
244			}
245			return m, m.switchFolder()
246		case kb.Folder.PrevFolder:
247			m.activeFolderIdx--
248			if m.activeFolderIdx < 0 {
249				m.activeFolderIdx = len(m.folders) - 1
250			}
251			return m, m.switchFolder()
252		case kb.Global.Cancel:
253			// Close split preview if open
254			if m.previewPane != nil {
255				m.closeSplitPreview()
256				return m, nil
257			}
258			// Otherwise let inbox handle (or parent)
259		case kb.Folder.Move:
260			// Start move-to-folder flow
261			if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 {
262				// Batch move
263				m.movingEmail = true
264				m.moveTargetIdx = 0
265				m.moveUIDs = make([]uint32, len(m.inbox.selectionOrder))
266				copy(m.moveUIDs, m.inbox.selectionOrder)
267				m.moveAccountID = ""
268				for _, acctID := range m.inbox.selectedUIDs {
269					m.moveAccountID = acctID
270					break
271				}
272				m.moveSourceFolder = m.currentFolder
273				return m, nil
274			}
275			// Single move
276			selectedItem, ok := m.inbox.list.SelectedItem().(item)
277			if ok {
278				m.movingEmail = true
279				m.moveTargetIdx = 0
280				m.moveUID = selectedItem.uid
281				m.moveUIDs = []uint32{selectedItem.uid}
282				m.moveAccountID = selectedItem.accountID
283				m.moveSourceFolder = m.currentFolder
284				return m, nil
285			}
286		}
287
288	case tea.WindowSizeMsg:
289		m.width = msg.Width
290		m.height = msg.Height
291		if m.previewPane != nil || m.previewedUID != 0 {
292			// Recalculate pane widths for split mode
293			inboxWidth := m.calculateInboxWidth()
294			previewWidth := m.calculatePreviewWidth()
295			m.inbox.SetSize(inboxWidth-2, msg.Height)
296			if m.previewPane != nil {
297				// Forward resize to EmailView with preview pane dimensions
298				previewMsg := tea.WindowSizeMsg{Width: previewWidth - 2, Height: msg.Height - 2}
299				m.previewPane.Update(previewMsg)
300			}
301		} else {
302			// Original two-pane resize
303			inboxWidth := msg.Width - sidebarWidth - 3
304			if inboxWidth < 20 {
305				inboxWidth = 20
306			}
307			m.inbox.SetSize(inboxWidth, msg.Height)
308		}
309		return m, nil
310
311	case FolderEmailsFetchedMsg:
312		// Ignore stale responses for folders the user has navigated away from
313		if msg.FolderName != m.currentFolder {
314			return m, nil
315		}
316		m.isLoadingEmails = false
317		m.inbox.isFetching = false
318		m.inbox.isRefreshing = false
319		m.inbox.SetEmails(msg.Emails, m.accounts)
320		m.inbox.SetFolderName(msg.FolderName)
321		return m, nil
322
323	case FolderEmailsAppendedMsg:
324		if msg.FolderName != m.currentFolder {
325			return m, nil
326		}
327		m.inbox.isFetching = false
328		m.inbox.list.Title = m.inbox.getTitle()
329		if len(msg.Emails) == 0 {
330			if m.inbox.noMoreByAccount == nil {
331				m.inbox.noMoreByAccount = make(map[string]bool)
332			}
333			m.inbox.noMoreByAccount[msg.AccountID] = true
334			return m, nil
335		}
336		for _, email := range msg.Emails {
337			m.inbox.emailsByAccount[email.AccountID] = append(m.inbox.emailsByAccount[email.AccountID], email)
338			m.inbox.allEmails = append(m.inbox.allEmails, email)
339		}
340		m.inbox.emailCountByAcct[msg.AccountID] = len(m.inbox.emailsByAccount[msg.AccountID])
341		m.inbox.updateList()
342		return m, nil
343
344	case EmailMovedMsg:
345		if msg.Err != nil {
346			// Error handled by main model
347			return m, nil
348		}
349		m.inbox.RemoveEmail(msg.UID, msg.AccountID)
350		// Clear preview if moved email was being previewed
351		if msg.UID == m.previewedUID {
352			m.closeSplitPreview()
353		}
354		return m, nil
355
356	case UpdatePreviewMsg:
357		// Stale update, ignore
358		if msg.UID == m.previewedUID && m.previewPane != nil {
359			return m, nil
360		}
361		m.previewedUID = msg.UID
362		m.previewedAccountID = msg.AccountID
363		// Will trigger fetch in main.go
364		return m, nil
365
366	case PreviewBodyFetchedMsg:
367		// Stale fetch or no preview active
368		if msg.UID != m.previewedUID {
369			return m, nil
370		}
371		if msg.Err != nil {
372			// Show error in preview pane
373			return m, nil
374		}
375		// Find email and create preview
376		email := m.findEmailByUID(msg.UID, msg.AccountID)
377		if email == nil {
378			return m, nil
379		}
380		// Update email with body
381		email.Body = msg.Body
382		email.BodyMIMEType = msg.BodyMIMEType
383		email.Attachments = msg.Attachments
384		// Create preview pane with column offset for image rendering
385		previewWidth := m.calculatePreviewWidth()
386		inboxWidth := m.calculateInboxWidth()
387		colOffset := sidebarWidth + 2 + inboxWidth + 2 // borders + padding
388		m.previewPane = NewEmailViewPreview(*email, previewWidth, m.height, colOffset, m.disableImages)
389		return m, nil
390	}
391
392	// Forward to inbox
393	var cmd tea.Cmd
394	_, cmd = m.inbox.Update(msg)
395
396	// Intercept FetchMoreEmailsMsg from inbox and convert to folder-aware version
397	if cmd != nil {
398		wrappedCmd := m.wrapInboxCmd(cmd)
399		return m, wrappedCmd
400	}
401
402	return m, cmd
403}
404
405// wrapInboxCmd intercepts messages from the inbox and adds folder context.
406func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd {
407	return func() tea.Msg {
408		msg := cmd()
409		switch inner := msg.(type) {
410		case FetchMoreEmailsMsg:
411			return FetchFolderMoreEmailsMsg{
412				Offset:     inner.Offset,
413				AccountID:  inner.AccountID,
414				FolderName: m.currentFolder,
415				Limit:      inner.Limit,
416			}
417		case RequestRefreshMsg:
418			inner.FolderName = m.currentFolder
419			return inner
420		case SearchRequestedMsg:
421			inner.FolderName = m.currentFolder
422			return inner
423		}
424		return msg
425	}
426}
427
428func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) {
429	kb := config.Keybinds
430	if msg, ok := msg.(tea.KeyPressMsg); ok {
431		switch msg.String() {
432		case kb.Global.Cancel:
433			m.movingEmail = false
434			return m, nil
435		case "up", kb.Global.NavUp:
436			m.moveTargetIdx--
437			if m.moveTargetIdx < 0 {
438				m.moveTargetIdx = len(m.moveFolderChoices()) - 1
439			}
440			return m, nil
441		case keyDown, kb.Global.NavDown:
442			m.moveTargetIdx++
443			choices := m.moveFolderChoices()
444			if m.moveTargetIdx >= len(choices) {
445				m.moveTargetIdx = 0
446			}
447			return m, nil
448		case keyEnter:
449			choices := m.moveFolderChoices()
450			if len(choices) > 0 && m.moveTargetIdx < len(choices) {
451				destFolder := choices[m.moveTargetIdx]
452				m.movingEmail = false
453
454				if len(m.moveUIDs) > 1 {
455					// Batch move
456					uids := m.moveUIDs
457					m.moveUIDs = nil
458
459					// Exit visual mode in inbox
460					m.inbox.visualMode = false
461					m.inbox.selectedUIDs = make(map[uint32]string)
462					m.inbox.selectionOrder = []uint32{}
463					m.inbox.updateListTitle()
464
465					return m, func() tea.Msg {
466						return BatchMoveEmailsMsg{
467							UIDs:         uids,
468							AccountID:    m.moveAccountID,
469							SourceFolder: m.moveSourceFolder,
470							DestFolder:   destFolder,
471						}
472					}
473				}
474				// Single move
475				return m, func() tea.Msg {
476					return MoveEmailToFolderMsg{
477						UID:          m.moveUID,
478						AccountID:    m.moveAccountID,
479						SourceFolder: m.moveSourceFolder,
480						DestFolder:   destFolder,
481					}
482				}
483			}
484		}
485	}
486	return m, nil
487}
488
489// moveFolderChoices returns all folders except the current one.
490func (m *FolderInbox) moveFolderChoices() []string {
491	var choices []string
492	for _, f := range m.folders {
493		if f != m.currentFolder {
494			choices = append(choices, f)
495		}
496	}
497	return choices
498}
499
500func (m *FolderInbox) switchFolder() tea.Cmd {
501	if m.activeFolderIdx >= 0 && m.activeFolderIdx < len(m.folders) {
502		prevFolder := m.currentFolder
503		m.currentFolder = m.folders[m.activeFolderIdx]
504		m.isLoadingEmails = true
505		m.inbox.SetFolderName(m.currentFolder)
506		// Clear current emails while loading
507		m.inbox.SetEmails(nil, m.accounts)
508		folder := m.currentFolder
509		return func() tea.Msg {
510			return SwitchFolderMsg{FolderName: folder, PreviousFolder: prevFolder}
511		}
512	}
513	return nil
514}
515
516func (m *FolderInbox) View() tea.View {
517	// Render sidebar
518	sidebar := m.renderSidebar()
519
520	var content string
521
522	switch {
523	case m.previewPane != nil:
524		// Three-pane layout: folders | inbox | email preview
525		inboxPane := m.renderInboxPane()
526		previewPane := m.renderPreviewPane()
527		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, previewPane)
528	case m.previewedUID != 0:
529		// Split pane loading state (body being fetched)
530		inboxPane := m.renderInboxPane()
531		emptyPreview := m.renderEmptyPreview()
532		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, emptyPreview)
533	default:
534		// Two-pane layout (original): folders | inbox
535		inboxView := m.inbox.View().Content
536		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView)
537	}
538
539	// If move overlay is active, render it on top
540	if m.movingEmail {
541		content = m.renderWithMoveOverlay(content)
542	}
543
544	return tea.NewView(content)
545}
546
547func (m *FolderInbox) renderSidebar() string {
548	var b strings.Builder
549
550	// Account name as title
551	title := t("folder_inbox.folders_title")
552	if len(m.accounts) > 0 {
553		acc := m.accounts[0]
554		if acc.Name != "" {
555			title = acc.Name
556		} else if acc.FetchEmail != "" {
557			title = acc.FetchEmail
558		}
559	}
560	b.WriteString(sidebarTitleStyle.Render(title))
561	b.WriteString("\n")
562
563	for i, folder := range m.folders {
564		displayName := m.formatFolderName(folder)
565		unread := m.unread[folder]
566
567		var tab string
568		if unread > 0 {
569			tab = fmt.Sprintf("%s (%d)", displayName, unread)
570		} else {
571			tab = displayName
572		}
573
574		if i == m.activeFolderIdx {
575			b.WriteString(activeFolderStyle.Width(sidebarWidth - 4).Render(tab))
576		} else {
577			b.WriteString(folderStyle.Render(tab))
578		}
579		if i < len(m.folders)-1 {
580			b.WriteString("\n")
581		}
582	}
583
584	sidebarHeight := m.height
585	if sidebarHeight < 1 {
586		sidebarHeight = 20
587	}
588
589	return sidebarStyle.Height(sidebarHeight - 2).Render(b.String())
590}
591
592// formatFolderName makes IMAP folder names more readable.
593func (m *FolderInbox) formatFolderName(name string) string {
594	// Strip common IMAP prefixes for cleaner display
595	name = strings.TrimPrefix(name, "[Gmail]/")
596	name = strings.TrimPrefix(name, "[Google Mail]/")
597	// Truncate to fit sidebar
598	maxLen := sidebarWidth - 5
599	if len(name) > maxLen {
600		name = name[:maxLen-1] + "\u2026"
601	}
602	return name
603}
604
605func (m *FolderInbox) renderWithMoveOverlay(content string) string {
606	choices := m.moveFolderChoices()
607	if len(choices) == 0 {
608		return content
609	}
610
611	var b strings.Builder
612	title := t("folder_inbox.move_to_folder")
613	if len(m.moveUIDs) > 1 {
614		title = tn("folder_inbox.move_multiple", len(m.moveUIDs), map[string]interface{}{
615			keyCount: len(m.moveUIDs),
616		})
617	}
618	b.WriteString(moveOverlayTitleStyle.Render(title))
619	b.WriteString("\n")
620
621	for i, folder := range choices {
622		displayName := m.formatFolderName(folder)
623		if i == m.moveTargetIdx {
624			b.WriteString(moveSelectedItemStyle.Render("> " + displayName))
625		} else {
626			b.WriteString(moveItemStyle.Render("  " + displayName))
627		}
628		if i < len(choices)-1 {
629			b.WriteString("\n")
630		}
631	}
632
633	b.WriteString("\n\n")
634	b.WriteString(helpStyle.Render(t("folder_inbox.help")))
635
636	overlay := moveOverlayStyle.Render(b.String())
637
638	// Place overlay in the center of content
639	contentLines := strings.Split(content, "\n")
640	overlayLines := strings.Split(overlay, "\n")
641	contentHeight := len(contentLines)
642	overlayHeight := len(overlayLines)
643	overlayWidth := lipgloss.Width(overlay)
644
645	startRow := (contentHeight - overlayHeight) / 2
646	if startRow < 0 {
647		startRow = 0
648	}
649	startCol := (m.width - overlayWidth) / 2
650	if startCol < 0 {
651		startCol = 0
652	}
653
654	// Overlay the box on top of the content
655	for i, overlayLine := range overlayLines {
656		row := startRow + i
657		if row >= len(contentLines) {
658			break
659		}
660		line := contentLines[row]
661		lineWidth := lipgloss.Width(line)
662
663		// Build the new line: prefix + overlay + suffix
664		if startCol >= lineWidth {
665			contentLines[row] = line + strings.Repeat(" ", startCol-lineWidth) + overlayLine
666		} else {
667			// We need to place the overlay at startCol
668			// Due to ANSI escape codes, we can't simply slice the string
669			// Instead, place the overlay line padded to the left
670			contentLines[row] = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, overlayLine)
671		}
672	}
673
674	return strings.Join(contentLines, "\n")
675}
676
677// SetFolders updates the folder list.
678func (m *FolderInbox) SetFolders(folders []string) {
679	m.folders = sortFolders(folders)
680	// Keep current folder if it still exists (search sorted list)
681	found := false
682	for i, f := range m.folders {
683		if f == m.currentFolder {
684			m.activeFolderIdx = i
685			found = true
686			break
687		}
688	}
689	if !found && len(m.folders) > 0 {
690		m.activeFolderIdx = 0
691		m.currentFolder = m.folders[0]
692	}
693}
694
695func (m *FolderInbox) SetUnreadCounts(counts map[string]int) {
696	m.unread = counts
697}
698
699func (m *FolderInbox) DecrementUnreadCount(folder string) {
700	if m.unread == nil {
701		return
702	}
703	if m.unread[folder] > 0 {
704		m.unread[folder]--
705	}
706}
707
708// SetEmails updates the inbox emails.
709func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
710	m.accounts = accounts
711	m.inbox.SetEmails(emails, accounts)
712}
713
714// GetCurrentFolder returns the currently selected folder name.
715func (m *FolderInbox) GetCurrentFolder() string {
716	return m.currentFolder
717}
718
719// HasSplitPreview reports whether the split preview pane is currently open.
720func (m *FolderInbox) HasSplitPreview() bool {
721	return m.previewPane != nil
722}
723
724// GetInbox returns the embedded inbox.
725func (m *FolderInbox) GetInbox() *Inbox {
726	return m.inbox
727}
728
729// GetAccounts returns the accounts.
730func (m *FolderInbox) GetAccounts() []config.Account {
731	return m.accounts
732}
733
734// RemoveEmail removes an email from the embedded inbox.
735func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) {
736	m.inbox.RemoveEmail(uid, accountID)
737}
738
739// updateHelpKeys refreshes the inbox help keys based on preview state
740func (m *FolderInbox) updateHelpKeys() {
741	bindings := []key.Binding{
742		key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
743		key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
744		key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
745	}
746	if m.previewPane != nil || m.previewedUID != 0 {
747		bindings = append(bindings,
748			key.NewBinding(key.WithKeys("]"), key.WithHelp("]/[", "switch pane")),
749			key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close preview")),
750		)
751	}
752	m.inbox.extraShortHelpKeys = bindings
753}
754
755// SetLoadingEmails sets the loading state.
756func (m *FolderInbox) SetLoadingEmails(loading bool) {
757	m.isLoadingEmails = loading
758	if loading {
759		m.inbox.isFetching = true
760	} else {
761		m.inbox.isFetching = false
762	}
763	m.inbox.list.Title = m.inbox.getTitle()
764}
765
766// SetRefreshing sets the refreshing state (used when user presses "r").
767func (m *FolderInbox) SetRefreshing(refreshing bool) {
768	m.inbox.isRefreshing = refreshing
769	m.inbox.list.Title = m.inbox.getTitle()
770}
771
772// GetFolders returns the current folder list.
773func (m *FolderInbox) GetFolders() []string {
774	return m.folders
775}
776
777// renderInboxPane renders inbox with border for split pane mode
778func (m *FolderInbox) renderInboxPane() string {
779	inboxWidth := m.calculateInboxWidth()
780
781	borderColor := unfocusedBorderColor
782	if m.focusedPane == FocusInbox {
783		borderColor = focusedBorderColor
784	}
785
786	paneStyle := inboxPaneStyle.
787		BorderForeground(borderColor).
788		Width(inboxWidth).
789		Height(m.height)
790
791	m.inbox.SetSize(inboxWidth-2, m.height)
792	return paneStyle.Render(m.inbox.View().Content)
793}
794
795// renderPreviewPane renders email preview with border
796func (m *FolderInbox) renderPreviewPane() string {
797	if m.previewPane == nil {
798		return m.renderEmptyPreview()
799	}
800
801	previewWidth := m.calculatePreviewWidth()
802
803	borderColor := unfocusedBorderColor
804	if m.focusedPane == FocusPreview {
805		borderColor = focusedBorderColor
806	}
807
808	paneStyle := previewPaneStyle.
809		BorderForeground(borderColor).
810		Width(previewWidth).
811		Height(m.height)
812
813	return paneStyle.Render(m.previewPane.View().Content)
814}
815
816// renderEmptyPreview renders placeholder when no email selected
817func (m *FolderInbox) renderEmptyPreview() string {
818	previewWidth := m.calculatePreviewWidth()
819
820	emptyStyle := lipgloss.NewStyle().
821		Width(previewWidth).
822		Height(m.height).
823		Align(lipgloss.Center, lipgloss.Center).
824		Foreground(lipgloss.Color("240"))
825
826	return emptyStyle.Render("Loading...")
827}
828
829// OpenSplitPreview opens the split preview pane for a specific email.
830// email may be non-nil for hits coming from search results (which are not in
831// m.inbox.allEmails); when set, it is used as a fallback by findEmailByUID
832// so the preview can render without a follow-up lookup.
833func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string, email *fetcher.Email) {
834	m.previewPane = nil // Will be created when body arrives
835	m.previewedUID = uid
836	m.previewedAccountID = accountID
837	m.previewSearchEmail = email
838	m.focusedPane = FocusPreview
839	// Recalculate inbox width for split mode
840	inboxWidth := m.calculateInboxWidth()
841	m.inbox.SetSize(inboxWidth-2, m.height)
842	m.updateHelpKeys()
843}
844
845// closeSplitPreview closes the preview pane and returns to inbox-only
846func (m *FolderInbox) closeSplitPreview() {
847	ClearKittyGraphics()
848	m.previewPane = nil
849	m.previewedUID = 0
850	m.previewedAccountID = ""
851	m.previewSearchEmail = nil
852	m.focusedPane = FocusInbox
853	// Restore full inbox width
854	inboxWidth := m.width - sidebarWidth - 3
855	if inboxWidth < 20 {
856		inboxWidth = 20
857	}
858	m.inbox.SetSize(inboxWidth, m.height)
859	m.updateHelpKeys()
860}
861
862// findEmailByUID finds email in inbox by UID and account ID. Falls back to
863// the email handed in by OpenSplitPreview so search hits that are not in
864// allEmails (cross-folder or uncached) still render in the preview pane.
865func (m *FolderInbox) findEmailByUID(uid uint32, accountID string) *fetcher.Email {
866	for i := range m.inbox.allEmails {
867		if m.inbox.allEmails[i].UID == uid && m.inbox.allEmails[i].AccountID == accountID {
868			return &m.inbox.allEmails[i]
869		}
870	}
871	if m.previewSearchEmail != nil &&
872		m.previewSearchEmail.UID == uid &&
873		m.previewSearchEmail.AccountID == accountID {
874		return m.previewSearchEmail
875	}
876	return nil
877}
878
879// calculatePreviewWidth calculates width for preview pane
880func (m *FolderInbox) calculatePreviewWidth() int {
881	remainingWidth := m.width - sidebarWidth - 4 // 4 for borders
882	inboxWidth := int(float64(remainingWidth) * 0.4)
883	if inboxWidth < 30 {
884		inboxWidth = 30
885	}
886	previewWidth := remainingWidth - inboxWidth
887	if previewWidth < 40 {
888		previewWidth = 40
889	}
890	return previewWidth
891}
892
893// calculateInboxWidth calculates width for inbox pane in split mode
894func (m *FolderInbox) calculateInboxWidth() int {
895	remainingWidth := m.width - sidebarWidth - 4
896	inboxWidth := int(float64(remainingWidth) * 0.4)
897	if inboxWidth < 30 {
898		inboxWidth = 30
899	}
900	return inboxWidth
901}