folder_inbox.go

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