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