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