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.Attachments = msg.Attachments
357		// Create preview pane with column offset for image rendering
358		previewWidth := m.calculatePreviewWidth()
359		inboxWidth := m.calculateInboxWidth()
360		colOffset := sidebarWidth + 2 + inboxWidth + 2 // borders + padding
361		m.previewPane = NewEmailViewPreview(*email, previewWidth, m.height, colOffset, false)
362		return m, nil
363	}
364
365	// Forward to inbox
366	var cmd tea.Cmd
367	_, cmd = m.inbox.Update(msg)
368
369	// Intercept FetchMoreEmailsMsg from inbox and convert to folder-aware version
370	if cmd != nil {
371		wrappedCmd := m.wrapInboxCmd(cmd)
372		return m, wrappedCmd
373	}
374
375	return m, cmd
376}
377
378// wrapInboxCmd intercepts messages from the inbox and adds folder context.
379func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd {
380	return func() tea.Msg {
381		msg := cmd()
382		switch inner := msg.(type) {
383		case FetchMoreEmailsMsg:
384			return FetchFolderMoreEmailsMsg{
385				Offset:     inner.Offset,
386				AccountID:  inner.AccountID,
387				FolderName: m.currentFolder,
388				Limit:      inner.Limit,
389			}
390		case RequestRefreshMsg:
391			inner.FolderName = m.currentFolder
392			return inner
393		case SearchRequestedMsg:
394			inner.FolderName = m.currentFolder
395			return inner
396		}
397		return msg
398	}
399}
400
401func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) {
402	kb := config.Keybinds
403	switch msg := msg.(type) {
404	case tea.KeyPressMsg:
405		switch msg.String() {
406		case kb.Global.Cancel:
407			m.movingEmail = false
408			return m, nil
409		case "up", kb.Global.NavUp:
410			m.moveTargetIdx--
411			if m.moveTargetIdx < 0 {
412				m.moveTargetIdx = len(m.moveFolderChoices()) - 1
413			}
414			return m, nil
415		case "down", kb.Global.NavDown:
416			m.moveTargetIdx++
417			choices := m.moveFolderChoices()
418			if m.moveTargetIdx >= len(choices) {
419				m.moveTargetIdx = 0
420			}
421			return m, nil
422		case "enter":
423			choices := m.moveFolderChoices()
424			if len(choices) > 0 && m.moveTargetIdx < len(choices) {
425				destFolder := choices[m.moveTargetIdx]
426				m.movingEmail = false
427
428				if len(m.moveUIDs) > 1 {
429					// Batch move
430					uids := m.moveUIDs
431					m.moveUIDs = nil
432
433					// Exit visual mode in inbox
434					m.inbox.visualMode = false
435					m.inbox.selectedUIDs = make(map[uint32]string)
436					m.inbox.selectionOrder = []uint32{}
437					m.inbox.updateListTitle()
438
439					return m, func() tea.Msg {
440						return BatchMoveEmailsMsg{
441							UIDs:         uids,
442							AccountID:    m.moveAccountID,
443							SourceFolder: m.moveSourceFolder,
444							DestFolder:   destFolder,
445						}
446					}
447				} else {
448					// Single move
449					return m, func() tea.Msg {
450						return MoveEmailToFolderMsg{
451							UID:          m.moveUID,
452							AccountID:    m.moveAccountID,
453							SourceFolder: m.moveSourceFolder,
454							DestFolder:   destFolder,
455						}
456					}
457				}
458			}
459		}
460	}
461	return m, nil
462}
463
464// moveFolderChoices returns all folders except the current one.
465func (m *FolderInbox) moveFolderChoices() []string {
466	var choices []string
467	for _, f := range m.folders {
468		if f != m.currentFolder {
469			choices = append(choices, f)
470		}
471	}
472	return choices
473}
474
475func (m *FolderInbox) switchFolder() tea.Cmd {
476	if m.activeFolderIdx >= 0 && m.activeFolderIdx < len(m.folders) {
477		prevFolder := m.currentFolder
478		m.currentFolder = m.folders[m.activeFolderIdx]
479		m.isLoadingEmails = true
480		m.inbox.SetFolderName(m.currentFolder)
481		// Clear current emails while loading
482		m.inbox.SetEmails(nil, m.accounts)
483		folder := m.currentFolder
484		return func() tea.Msg {
485			return SwitchFolderMsg{FolderName: folder, PreviousFolder: prevFolder}
486		}
487	}
488	return nil
489}
490
491func (m *FolderInbox) View() tea.View {
492	// Render sidebar
493	sidebar := m.renderSidebar()
494
495	var content string
496
497	if m.previewPane != nil {
498		// Three-pane layout: folders | inbox | email preview
499		inboxPane := m.renderInboxPane()
500		previewPane := m.renderPreviewPane()
501		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, previewPane)
502	} else if m.previewedUID != 0 {
503		// Split pane loading state (body being fetched)
504		inboxPane := m.renderInboxPane()
505		emptyPreview := m.renderEmptyPreview()
506		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, emptyPreview)
507	} else {
508		// Two-pane layout (original): folders | inbox
509		inboxView := m.inbox.View().Content
510		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView)
511	}
512
513	// If move overlay is active, render it on top
514	if m.movingEmail {
515		content = m.renderWithMoveOverlay(content)
516	}
517
518	return tea.NewView(content)
519}
520
521func (m *FolderInbox) renderSidebar() string {
522	var b strings.Builder
523
524	// Account name as title
525	title := t("folder_inbox.folders_title")
526	if len(m.accounts) > 0 {
527		acc := m.accounts[0]
528		if acc.Name != "" {
529			title = acc.Name
530		} else if acc.FetchEmail != "" {
531			title = acc.FetchEmail
532		}
533	}
534	b.WriteString(sidebarTitleStyle.Render(title))
535	b.WriteString("\n")
536
537	for i, folder := range m.folders {
538		displayName := m.formatFolderName(folder)
539		if i == m.activeFolderIdx {
540			b.WriteString(activeFolderStyle.Width(sidebarWidth - 4).Render(displayName))
541		} else {
542			b.WriteString(folderStyle.Render(displayName))
543		}
544		if i < len(m.folders)-1 {
545			b.WriteString("\n")
546		}
547	}
548
549	sidebarHeight := m.height
550	if sidebarHeight < 1 {
551		sidebarHeight = 20
552	}
553
554	return sidebarStyle.Height(sidebarHeight - 2).Render(b.String())
555}
556
557// formatFolderName makes IMAP folder names more readable.
558func (m *FolderInbox) formatFolderName(name string) string {
559	// Strip common IMAP prefixes for cleaner display
560	name = strings.TrimPrefix(name, "[Gmail]/")
561	name = strings.TrimPrefix(name, "[Google Mail]/")
562	// Truncate to fit sidebar
563	maxLen := sidebarWidth - 5
564	if len(name) > maxLen {
565		name = name[:maxLen-1] + "\u2026"
566	}
567	return name
568}
569
570func (m *FolderInbox) renderWithMoveOverlay(content string) string {
571	choices := m.moveFolderChoices()
572	if len(choices) == 0 {
573		return content
574	}
575
576	var b strings.Builder
577	title := t("folder_inbox.move_to_folder")
578	if len(m.moveUIDs) > 1 {
579		title = tn("folder_inbox.move_multiple", len(m.moveUIDs), map[string]interface{}{
580			"count": len(m.moveUIDs),
581		})
582	}
583	b.WriteString(moveOverlayTitleStyle.Render(title))
584	b.WriteString("\n")
585
586	for i, folder := range choices {
587		displayName := m.formatFolderName(folder)
588		if i == m.moveTargetIdx {
589			b.WriteString(moveSelectedItemStyle.Render("> " + displayName))
590		} else {
591			b.WriteString(moveItemStyle.Render("  " + displayName))
592		}
593		if i < len(choices)-1 {
594			b.WriteString("\n")
595		}
596	}
597
598	b.WriteString("\n\n")
599	b.WriteString(helpStyle.Render(t("folder_inbox.help")))
600
601	overlay := moveOverlayStyle.Render(b.String())
602
603	// Place overlay in the center of content
604	contentLines := strings.Split(content, "\n")
605	overlayLines := strings.Split(overlay, "\n")
606	contentHeight := len(contentLines)
607	overlayHeight := len(overlayLines)
608	overlayWidth := lipgloss.Width(overlay)
609
610	startRow := (contentHeight - overlayHeight) / 2
611	if startRow < 0 {
612		startRow = 0
613	}
614	startCol := (m.width - overlayWidth) / 2
615	if startCol < 0 {
616		startCol = 0
617	}
618
619	// Overlay the box on top of the content
620	for i, overlayLine := range overlayLines {
621		row := startRow + i
622		if row >= len(contentLines) {
623			break
624		}
625		line := contentLines[row]
626		lineWidth := lipgloss.Width(line)
627
628		// Build the new line: prefix + overlay + suffix
629		if startCol >= lineWidth {
630			contentLines[row] = line + strings.Repeat(" ", startCol-lineWidth) + overlayLine
631		} else {
632			// We need to place the overlay at startCol
633			// Due to ANSI escape codes, we can't simply slice the string
634			// Instead, place the overlay line padded to the left
635			contentLines[row] = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, overlayLine)
636		}
637	}
638
639	return strings.Join(contentLines, "\n")
640}
641
642// SetFolders updates the folder list.
643func (m *FolderInbox) SetFolders(folders []string) {
644	m.folders = sortFolders(folders)
645	// Keep current folder if it still exists (search sorted list)
646	found := false
647	for i, f := range m.folders {
648		if f == m.currentFolder {
649			m.activeFolderIdx = i
650			found = true
651			break
652		}
653	}
654	if !found && len(m.folders) > 0 {
655		m.activeFolderIdx = 0
656		m.currentFolder = m.folders[0]
657	}
658}
659
660// SetEmails updates the inbox emails.
661func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
662	m.accounts = accounts
663	m.inbox.SetEmails(emails, accounts)
664}
665
666// GetCurrentFolder returns the currently selected folder name.
667func (m *FolderInbox) GetCurrentFolder() string {
668	return m.currentFolder
669}
670
671// GetInbox returns the embedded inbox.
672func (m *FolderInbox) GetInbox() *Inbox {
673	return m.inbox
674}
675
676// GetAccounts returns the accounts.
677func (m *FolderInbox) GetAccounts() []config.Account {
678	return m.accounts
679}
680
681// RemoveEmail removes an email from the embedded inbox.
682func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) {
683	m.inbox.RemoveEmail(uid, accountID)
684}
685
686// updateHelpKeys refreshes the inbox help keys based on preview state
687func (m *FolderInbox) updateHelpKeys() {
688	bindings := []key.Binding{
689		key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
690		key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
691		key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
692	}
693	if m.previewPane != nil || m.previewedUID != 0 {
694		bindings = append(bindings,
695			key.NewBinding(key.WithKeys("]"), key.WithHelp("]/[", "switch pane")),
696			key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close preview")),
697		)
698	}
699	m.inbox.extraShortHelpKeys = bindings
700}
701
702// SetLoadingEmails sets the loading state.
703func (m *FolderInbox) SetLoadingEmails(loading bool) {
704	m.isLoadingEmails = loading
705	if loading {
706		m.inbox.isFetching = true
707	} else {
708		m.inbox.isFetching = false
709	}
710	m.inbox.list.Title = m.inbox.getTitle()
711}
712
713// SetRefreshing sets the refreshing state (used when user presses "r").
714func (m *FolderInbox) SetRefreshing(refreshing bool) {
715	m.inbox.isRefreshing = refreshing
716	m.inbox.list.Title = m.inbox.getTitle()
717}
718
719// GetFolders returns the current folder list.
720func (m *FolderInbox) GetFolders() []string {
721	return m.folders
722}
723
724// Helper to get the formatted inbox title
725func folderInboxTitle(folder string) string {
726	return fmt.Sprintf("Folder: %s", folder)
727}
728
729// renderInboxPane renders inbox with border for split pane mode
730func (m *FolderInbox) renderInboxPane() string {
731	inboxWidth := m.calculateInboxWidth()
732
733	borderColor := unfocusedBorderColor
734	if m.focusedPane == FocusInbox {
735		borderColor = focusedBorderColor
736	}
737
738	paneStyle := inboxPaneStyle.
739		BorderForeground(borderColor).
740		Width(inboxWidth).
741		Height(m.height)
742
743	m.inbox.SetSize(inboxWidth-2, m.height)
744	return paneStyle.Render(m.inbox.View().Content)
745}
746
747// renderPreviewPane renders email preview with border
748func (m *FolderInbox) renderPreviewPane() string {
749	if m.previewPane == nil {
750		return m.renderEmptyPreview()
751	}
752
753	previewWidth := m.calculatePreviewWidth()
754
755	borderColor := unfocusedBorderColor
756	if m.focusedPane == FocusPreview {
757		borderColor = focusedBorderColor
758	}
759
760	paneStyle := previewPaneStyle.
761		BorderForeground(borderColor).
762		Width(previewWidth).
763		Height(m.height)
764
765	return paneStyle.Render(m.previewPane.View().Content)
766}
767
768// renderEmptyPreview renders placeholder when no email selected
769func (m *FolderInbox) renderEmptyPreview() string {
770	previewWidth := m.calculatePreviewWidth()
771
772	emptyStyle := lipgloss.NewStyle().
773		Width(previewWidth).
774		Height(m.height).
775		Align(lipgloss.Center, lipgloss.Center).
776		Foreground(lipgloss.Color("240"))
777
778	return emptyStyle.Render("Loading...")
779}
780
781// OpenSplitPreview opens the split preview pane for a specific email.
782// email may be non-nil for hits coming from search results (which are not in
783// m.inbox.allEmails); when set, it is used as a fallback by findEmailByUID
784// so the preview can render without a follow-up lookup.
785func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string, email *fetcher.Email) {
786	m.previewPane = nil // Will be created when body arrives
787	m.previewedUID = uid
788	m.previewedAccountID = accountID
789	m.previewSearchEmail = email
790	m.focusedPane = FocusPreview
791	// Recalculate inbox width for split mode
792	inboxWidth := m.calculateInboxWidth()
793	m.inbox.SetSize(inboxWidth-2, m.height)
794	m.updateHelpKeys()
795}
796
797// closeSplitPreview closes the preview pane and returns to inbox-only
798func (m *FolderInbox) closeSplitPreview() {
799	ClearKittyGraphics()
800	m.previewPane = nil
801	m.previewedUID = 0
802	m.previewedAccountID = ""
803	m.previewSearchEmail = nil
804	m.focusedPane = FocusInbox
805	// Restore full inbox width
806	inboxWidth := m.width - sidebarWidth - 3
807	if inboxWidth < 20 {
808		inboxWidth = 20
809	}
810	m.inbox.SetSize(inboxWidth, m.height)
811	m.updateHelpKeys()
812}
813
814// findEmailByUID finds email in inbox by UID and account ID. Falls back to
815// the email handed in by OpenSplitPreview so search hits that are not in
816// allEmails (cross-folder or uncached) still render in the preview pane.
817func (m *FolderInbox) findEmailByUID(uid uint32, accountID string) *fetcher.Email {
818	for i := range m.inbox.allEmails {
819		if m.inbox.allEmails[i].UID == uid && m.inbox.allEmails[i].AccountID == accountID {
820			return &m.inbox.allEmails[i]
821		}
822	}
823	if m.previewSearchEmail != nil &&
824		m.previewSearchEmail.UID == uid &&
825		m.previewSearchEmail.AccountID == accountID {
826		return m.previewSearchEmail
827	}
828	return nil
829}
830
831// calculatePreviewWidth calculates width for preview pane
832func (m *FolderInbox) calculatePreviewWidth() int {
833	remainingWidth := m.width - sidebarWidth - 4 // 4 for borders
834	inboxWidth := int(float64(remainingWidth) * 0.4)
835	if inboxWidth < 30 {
836		inboxWidth = 30
837	}
838	previewWidth := remainingWidth - inboxWidth
839	if previewWidth < 40 {
840		previewWidth = 40
841	}
842	return previewWidth
843}
844
845// calculateInboxWidth calculates width for inbox pane in split mode
846func (m *FolderInbox) calculateInboxWidth() int {
847	remainingWidth := m.width - sidebarWidth - 4
848	inboxWidth := int(float64(remainingWidth) * 0.4)
849	if inboxWidth < 30 {
850		inboxWidth = 30
851	}
852	return inboxWidth
853}