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