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