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