folder_inbox.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"maps"
  6	"sort"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/key"
 10	"charm.land/bubbles/v2/list"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	overlay "github.com/floatpane/bubble-overlay"
 14	"github.com/floatpane/matcha/config"
 15	"github.com/floatpane/matcha/fetcher"
 16)
 17
 18const sidebarWidth = 25
 19
 20var (
 21	sidebarStyle = lipgloss.NewStyle().
 22			Width(sidebarWidth).
 23			BorderStyle(lipgloss.NormalBorder()).
 24			BorderRight(true).
 25			PaddingRight(1).
 26			PaddingLeft(1)
 27
 28	sidebarTitleStyle = lipgloss.NewStyle().
 29				Foreground(lipgloss.Color("42")).
 30				Bold(true).
 31				PaddingBottom(1)
 32
 33	folderStyle = lipgloss.NewStyle().
 34			PaddingLeft(1).
 35			PaddingRight(1)
 36
 37	activeFolderStyle = lipgloss.NewStyle().
 38				PaddingLeft(1).
 39				PaddingRight(1).
 40				Background(lipgloss.Color("42")).
 41				Foreground(lipgloss.Color("#000000")).
 42				Bold(true)
 43
 44	moveOverlayStyle = lipgloss.NewStyle().
 45				Border(lipgloss.RoundedBorder()).
 46				BorderForeground(lipgloss.Color("#25A065")).
 47				Padding(1, 2)
 48
 49	moveOverlayTitleStyle = lipgloss.NewStyle().
 50				Foreground(lipgloss.Color("42")).
 51				Bold(true).
 52				PaddingBottom(1)
 53
 54	moveItemStyle = lipgloss.NewStyle().
 55			PaddingLeft(1)
 56
 57	moveSelectedItemStyle = lipgloss.NewStyle().
 58				PaddingLeft(1).
 59				Foreground(lipgloss.Color("42")).
 60				Bold(true)
 61
 62	inboxPaneStyle = lipgloss.NewStyle().
 63			BorderStyle(lipgloss.NormalBorder()).
 64			BorderRight(true).
 65			PaddingRight(1)
 66
 67	previewPaneStyle = lipgloss.NewStyle().
 68				BorderStyle(lipgloss.NormalBorder()).
 69				BorderLeft(true).
 70				PaddingLeft(1)
 71
 72	focusedBorderColor   = lipgloss.Color("42")
 73	unfocusedBorderColor = lipgloss.Color("240")
 74)
 75
 76type PaneType int
 77
 78const (
 79	FocusInbox PaneType = iota
 80	FocusPreview
 81)
 82
 83// FolderInbox combines a folder sidebar with an email list.
 84type FolderInbox struct {
 85	folders         []string
 86	unread          map[string]int
 87	activeFolderIdx int
 88	currentFolder   string
 89	inbox           *Inbox
 90	accounts        []config.Account
 91	width           int
 92	height          int
 93	isLoadingEmails bool
 94
 95	// Move-to-folder overlay state
 96	movingEmail      bool
 97	moveTargetIdx    int
 98	moveUID          uint32   // Legacy: single UID
 99	moveUIDs         []uint32 // Batch: multiple UIDs
100	moveAccountID    string
101	moveSourceFolder string
102
103	// Image rendering preference, propagated from config.
104	disableImages bool
105
106	// Split pane state
107	previewPane        *EmailView
108	previewedUID       uint32
109	previewedAccountID string
110	// previewSearchEmail holds an Email handed in by OpenSplitPreview for hits
111	// that do not live in m.inbox.allEmails (search results across folders).
112	// findEmailByUID falls back to it when allEmails has no match.
113	previewSearchEmail *fetcher.Email
114	focusedPane        PaneType
115}
116
117func (m *FolderInbox) GetUnreadCountsCopy() map[string]int {
118	if m.unread == nil {
119		return make(map[string]int)
120	}
121	result := make(map[string]int)
122	maps.Copy(result, m.unread)
123	return result
124}
125
126// sortFolders sorts folder names with INBOX always first, then alphabetically.
127func sortFolders(folders []string) []string {
128	sorted := make([]string, len(folders))
129	copy(sorted, folders)
130	sort.SliceStable(sorted, func(i, j int) bool {
131		iUpper := strings.ToUpper(sorted[i])
132		jUpper := strings.ToUpper(sorted[j])
133		if iUpper == keyINBOX {
134			return true
135		}
136		if jUpper == keyINBOX {
137			return false
138		}
139		return sorted[i] < sorted[j]
140	})
141	return sorted
142}
143
144// SetDateFormat propagates the configured date layout to the inner inbox.
145func (m *FolderInbox) SetDateFormat(layout string) {
146	if m.inbox != nil {
147		m.inbox.SetDateFormat(layout)
148	}
149}
150
151// SetDetailedDates propagates the detailed date display toggle.
152func (m *FolderInbox) SetDetailedDates(enabled bool) {
153	if m.inbox != nil {
154		m.inbox.SetDetailedDates(enabled)
155	}
156}
157
158// SetDefaultThreaded propagates the global default threading toggle.
159func (m *FolderInbox) SetDefaultThreaded(v bool) {
160	if m.inbox != nil {
161		m.inbox.SetDefaultThreaded(v)
162	}
163}
164
165// SetDisableImages propagates the global image-display preference. Affects
166// future split-view previews; an already-open preview keeps its current state.
167func (m *FolderInbox) SetDisableImages(v bool) {
168	m.disableImages = v
169}
170
171// NewFolderInbox creates a new FolderInbox with the given folders and accounts.
172func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox {
173	folders = sortFolders(folders)
174	currentFolder := keyINBOX
175	if len(folders) > 0 {
176		currentFolder = folders[0]
177	}
178
179	inbox := NewInbox(nil, accounts)
180	inbox.SetFolderName(currentFolder)
181
182	fi := &FolderInbox{
183		folders:         folders,
184		activeFolderIdx: 0,
185		currentFolder:   currentFolder,
186		inbox:           inbox,
187		accounts:        accounts,
188	}
189	fi.updateHelpKeys()
190	return fi
191}
192
193func (m *FolderInbox) Init() tea.Cmd {
194	return nil
195}
196
197func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
198	// If move overlay is active, handle its input
199	if m.movingEmail {
200		return m.updateMoveOverlay(msg)
201	}
202
203	switch msg := msg.(type) {
204	case tea.KeyPressMsg:
205		// Don't intercept keys while filtering
206		if m.inbox.list.FilterState() == list.Filtering {
207			break
208		}
209
210		// Don't intercept keys while the inbox search overlay is active.
211		// Otherwise folder-level bindings like "m" (move) would shadow text input.
212		if m.inbox.searchOverlay != nil {
213			break
214		}
215
216		kb := config.Keybinds
217
218		// Route input to preview pane when focused
219		if m.previewPane != nil && m.focusedPane == FocusPreview {
220			s := msg.String()
221			if s != kb.Folder.FocusInbox && s != kb.Folder.FocusPreview && s != kb.Global.Cancel && s != "q" {
222				var cmd tea.Cmd
223				_, cmd = m.previewPane.Update(msg)
224				return m, cmd
225			}
226		}
227
228		switch msg.String() {
229		case kb.Folder.FocusPreview:
230			// Switch focus to preview pane
231			if m.previewPane != nil && m.focusedPane == FocusInbox {
232				m.focusedPane = FocusPreview
233				return m, nil
234			}
235		case kb.Folder.FocusInbox:
236			// Switch focus to inbox pane
237			if m.previewPane != nil && m.focusedPane == FocusPreview {
238				m.focusedPane = FocusInbox
239				return m, nil
240			}
241		case kb.Folder.NextFolder:
242			m.activeFolderIdx++
243			if m.activeFolderIdx >= len(m.folders) {
244				m.activeFolderIdx = 0
245			}
246			return m, m.switchFolder()
247		case kb.Folder.PrevFolder:
248			m.activeFolderIdx--
249			if m.activeFolderIdx < 0 {
250				m.activeFolderIdx = len(m.folders) - 1
251			}
252			return m, m.switchFolder()
253		case kb.Global.Cancel:
254			// Close split preview if open
255			if m.previewPane != nil {
256				m.closeSplitPreview()
257				return m, nil
258			}
259			// Otherwise let inbox handle (or parent)
260		case kb.Folder.Move:
261			// Start move-to-folder flow
262			if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 {
263				// Batch move
264				m.movingEmail = true
265				m.moveTargetIdx = 0
266				m.moveUIDs = make([]uint32, len(m.inbox.selectionOrder))
267				copy(m.moveUIDs, m.inbox.selectionOrder)
268				m.moveAccountID = ""
269				for _, acctID := range m.inbox.selectedUIDs {
270					m.moveAccountID = acctID
271					break
272				}
273				m.moveSourceFolder = m.currentFolder
274				return m, nil
275			}
276			// Single move
277			selectedItem, ok := m.inbox.list.SelectedItem().(item)
278			if ok {
279				m.movingEmail = true
280				m.moveTargetIdx = 0
281				m.moveUID = selectedItem.uid
282				m.moveUIDs = []uint32{selectedItem.uid}
283				m.moveAccountID = selectedItem.accountID
284				m.moveSourceFolder = m.currentFolder
285				return m, nil
286			}
287		}
288
289	case tea.WindowSizeMsg:
290		m.width = msg.Width
291		m.height = msg.Height
292		if m.previewPane != nil || m.previewedUID != 0 {
293			// Recalculate pane widths for split mode
294			inboxWidth := m.calculateInboxWidth()
295			previewWidth := m.calculatePreviewWidth()
296			m.inbox.SetSize(inboxWidth-2, msg.Height)
297			if m.previewPane != nil {
298				// Forward resize to EmailView with preview pane dimensions
299				previewMsg := tea.WindowSizeMsg{Width: previewWidth - 2, Height: msg.Height - 2}
300				m.previewPane.Update(previewMsg)
301			}
302		} else {
303			// Original two-pane resize
304			inboxWidth := msg.Width - sidebarWidth - 3
305			if inboxWidth < 20 {
306				inboxWidth = 20
307			}
308			m.inbox.SetSize(inboxWidth, msg.Height)
309		}
310		return m, nil
311
312	case FolderEmailsFetchedMsg:
313		// Ignore stale responses for folders the user has navigated away from
314		if msg.FolderName != m.currentFolder {
315			return m, nil
316		}
317		m.isLoadingEmails = false
318		m.inbox.isFetching = false
319		m.inbox.isRefreshing = false
320		m.inbox.SetEmails(msg.Emails, m.accounts)
321		m.inbox.SetFolderName(msg.FolderName)
322		return m, nil
323
324	case FolderEmailsAppendedMsg:
325		if msg.FolderName != m.currentFolder {
326			return m, nil
327		}
328		m.inbox.isFetching = false
329		m.inbox.list.Title = m.inbox.getTitle()
330		if len(msg.Emails) == 0 {
331			if m.inbox.noMoreByAccount == nil {
332				m.inbox.noMoreByAccount = make(map[string]bool)
333			}
334			m.inbox.noMoreByAccount[msg.AccountID] = true
335			return m, nil
336		}
337		for _, email := range msg.Emails {
338			m.inbox.emailsByAccount[email.AccountID] = append(m.inbox.emailsByAccount[email.AccountID], email)
339			m.inbox.allEmails = append(m.inbox.allEmails, email)
340		}
341		m.inbox.emailCountByAcct[msg.AccountID] = len(m.inbox.emailsByAccount[msg.AccountID])
342		m.inbox.updateList()
343		return m, nil
344
345	case EmailMovedMsg:
346		if msg.Err != nil {
347			// Error handled by main model
348			return m, nil
349		}
350		m.inbox.RemoveEmail(msg.UID, msg.AccountID)
351		// Clear preview if moved email was being previewed
352		if msg.UID == m.previewedUID {
353			m.closeSplitPreview()
354		}
355		return m, nil
356
357	case UpdatePreviewMsg:
358		// Stale update, ignore
359		if msg.UID == m.previewedUID && m.previewPane != nil {
360			return m, nil
361		}
362		m.previewedUID = msg.UID
363		m.previewedAccountID = msg.AccountID
364		// Will trigger fetch in main.go
365		return m, nil
366
367	case PreviewBodyFetchedMsg:
368		// Stale fetch or no preview active
369		if msg.UID != m.previewedUID {
370			return m, nil
371		}
372		if msg.Err != nil {
373			// Show error in preview pane
374			return m, nil
375		}
376		// Find email and create preview
377		email := m.findEmailByUID(msg.UID, msg.AccountID)
378		if email == nil {
379			return m, nil
380		}
381		// Update email with body
382		email.Body = msg.Body
383		email.BodyMIMEType = msg.BodyMIMEType
384		email.Attachments = msg.Attachments
385		// Create preview pane with column offset for image rendering
386		previewWidth := m.calculatePreviewWidth()
387		inboxWidth := m.calculateInboxWidth()
388		colOffset := sidebarWidth + 2 + inboxWidth + 2 // borders + padding
389		m.previewPane = NewEmailViewPreview(*email, previewWidth, m.height, colOffset, m.disableImages)
390		return m, nil
391	}
392
393	// Forward to inbox
394	var cmd tea.Cmd
395	_, cmd = m.inbox.Update(msg)
396
397	// Intercept FetchMoreEmailsMsg from inbox and convert to folder-aware version
398	if cmd != nil {
399		wrappedCmd := m.wrapInboxCmd(cmd)
400		return m, wrappedCmd
401	}
402
403	return m, cmd
404}
405
406// wrapInboxCmd intercepts messages from the inbox and adds folder context.
407func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd {
408	return func() tea.Msg {
409		msg := cmd()
410		switch inner := msg.(type) {
411		case FetchMoreEmailsMsg:
412			return FetchFolderMoreEmailsMsg{
413				Offset:     inner.Offset,
414				AccountID:  inner.AccountID,
415				FolderName: m.currentFolder,
416				Limit:      inner.Limit,
417			}
418		case RequestRefreshMsg:
419			inner.FolderName = m.currentFolder
420			return inner
421		case SearchRequestedMsg:
422			inner.FolderName = m.currentFolder
423			return inner
424		}
425		return msg
426	}
427}
428
429func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) {
430	kb := config.Keybinds
431	if msg, ok := msg.(tea.KeyPressMsg); ok {
432		switch msg.String() {
433		case kb.Global.Cancel:
434			m.movingEmail = false
435			return m, nil
436		case "up", kb.Global.NavUp:
437			m.moveTargetIdx--
438			if m.moveTargetIdx < 0 {
439				m.moveTargetIdx = len(m.moveFolderChoices()) - 1
440			}
441			return m, nil
442		case keyDown, kb.Global.NavDown:
443			m.moveTargetIdx++
444			choices := m.moveFolderChoices()
445			if m.moveTargetIdx >= len(choices) {
446				m.moveTargetIdx = 0
447			}
448			return m, nil
449		case keyEnter:
450			choices := m.moveFolderChoices()
451			if len(choices) > 0 && m.moveTargetIdx < len(choices) {
452				destFolder := choices[m.moveTargetIdx]
453				m.movingEmail = false
454
455				if len(m.moveUIDs) > 1 {
456					// Batch move
457					uids := m.moveUIDs
458					m.moveUIDs = nil
459
460					// Exit visual mode in inbox
461					m.inbox.visualMode = false
462					m.inbox.selectedUIDs = make(map[uint32]string)
463					m.inbox.selectionOrder = []uint32{}
464					m.inbox.updateListTitle()
465
466					return m, func() tea.Msg {
467						return BatchMoveEmailsMsg{
468							UIDs:         uids,
469							AccountID:    m.moveAccountID,
470							SourceFolder: m.moveSourceFolder,
471							DestFolder:   destFolder,
472						}
473					}
474				}
475				// Single move
476				return m, func() tea.Msg {
477					return MoveEmailToFolderMsg{
478						UID:          m.moveUID,
479						AccountID:    m.moveAccountID,
480						SourceFolder: m.moveSourceFolder,
481						DestFolder:   destFolder,
482					}
483				}
484			}
485		}
486	}
487	return m, nil
488}
489
490// moveFolderChoices returns all folders except the current one.
491func (m *FolderInbox) moveFolderChoices() []string {
492	var choices []string
493	for _, f := range m.folders {
494		if f != m.currentFolder {
495			choices = append(choices, f)
496		}
497	}
498	return choices
499}
500
501func (m *FolderInbox) switchFolder() tea.Cmd {
502	if m.activeFolderIdx >= 0 && m.activeFolderIdx < len(m.folders) {
503		prevFolder := m.currentFolder
504		m.currentFolder = m.folders[m.activeFolderIdx]
505		m.isLoadingEmails = true
506		m.inbox.SetFolderName(m.currentFolder)
507		// Clear current emails while loading
508		m.inbox.SetEmails(nil, m.accounts)
509		folder := m.currentFolder
510		return func() tea.Msg {
511			return SwitchFolderMsg{FolderName: folder, PreviousFolder: prevFolder}
512		}
513	}
514	return nil
515}
516
517func (m *FolderInbox) View() tea.View {
518	// Render sidebar
519	sidebar := m.renderSidebar()
520
521	var content string
522
523	switch {
524	case m.previewPane != nil:
525		// Three-pane layout: folders | inbox | email preview
526		inboxPane := m.renderInboxPane()
527		previewPane := m.renderPreviewPane()
528		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, previewPane)
529	case m.previewedUID != 0:
530		// Split pane loading state (body being fetched)
531		inboxPane := m.renderInboxPane()
532		emptyPreview := m.renderEmptyPreview()
533		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxPane, emptyPreview)
534	default:
535		// Two-pane layout (original): folders | inbox
536		inboxView := m.inbox.View().Content
537		content = lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView)
538	}
539
540	// If move overlay is active, render it on top
541	if m.movingEmail {
542		content = m.renderWithMoveOverlay(content)
543	}
544
545	return tea.NewView(content)
546}
547
548func (m *FolderInbox) renderSidebar() string {
549	var b strings.Builder
550
551	// Account name as title
552	title := t("folder_inbox.folders_title")
553	if len(m.accounts) > 0 {
554		acc := m.accounts[0]
555		if acc.Name != "" {
556			title = acc.Name
557		} else if acc.FetchEmail != "" {
558			title = acc.FetchEmail
559		}
560	}
561	b.WriteString(sidebarTitleStyle.Render(title))
562	b.WriteString("\n")
563
564	for i, folder := range m.folders {
565		displayName := m.formatFolderName(folder)
566		unread := m.unread[folder]
567
568		var tab string
569		if unread > 0 {
570			tab = fmt.Sprintf("%s (%d)", displayName, unread)
571		} else {
572			tab = displayName
573		}
574
575		if i == m.activeFolderIdx {
576			b.WriteString(activeFolderStyle.Width(sidebarWidth - 4).Render(tab))
577		} else {
578			b.WriteString(folderStyle.Render(tab))
579		}
580		if i < len(m.folders)-1 {
581			b.WriteString("\n")
582		}
583	}
584
585	sidebarHeight := m.height
586	if sidebarHeight < 1 {
587		sidebarHeight = 20
588	}
589
590	return sidebarStyle.Height(sidebarHeight - 2).Render(b.String())
591}
592
593// formatFolderName makes IMAP folder names more readable.
594func (m *FolderInbox) formatFolderName(name string) string {
595	// Strip common IMAP prefixes for cleaner display
596	name = strings.TrimPrefix(name, "[Gmail]/")
597	name = strings.TrimPrefix(name, "[Google Mail]/")
598	// Truncate to fit sidebar
599	maxLen := sidebarWidth - 5
600	if len(name) > maxLen {
601		name = name[:maxLen-1] + "\u2026"
602	}
603	return name
604}
605
606func (m *FolderInbox) renderWithMoveOverlay(content string) string {
607	choices := m.moveFolderChoices()
608	if len(choices) == 0 {
609		return content
610	}
611
612	var b strings.Builder
613	title := t("folder_inbox.move_to_folder")
614	if len(m.moveUIDs) > 1 {
615		title = tn("folder_inbox.move_multiple", len(m.moveUIDs), map[string]interface{}{
616			keyCount: len(m.moveUIDs),
617		})
618	}
619	b.WriteString(moveOverlayTitleStyle.Render(title))
620	b.WriteString("\n")
621
622	for i, folder := range choices {
623		displayName := m.formatFolderName(folder)
624		if i == m.moveTargetIdx {
625			b.WriteString(moveSelectedItemStyle.Render("> " + displayName))
626		} else {
627			b.WriteString(moveItemStyle.Render("  " + displayName))
628		}
629		if i < len(choices)-1 {
630			b.WriteString("\n")
631		}
632	}
633
634	b.WriteString("\n\n")
635	b.WriteString(helpStyle.Render(t("folder_inbox.help")))
636
637	box := moveOverlayStyle.Render(b.String())
638
639	// Composite the box as a floating layer centered over the content, the same
640	// way the command palette does. The background is preserved on all sides;
641	// only the box's own cells are replaced.
642	return overlay.Center(content, box, m.width, m.height)
643}
644
645// SetFolders updates the folder list.
646func (m *FolderInbox) SetFolders(folders []string) {
647	m.folders = sortFolders(folders)
648	// Keep current folder if it still exists (search sorted list)
649	found := false
650	for i, f := range m.folders {
651		if f == m.currentFolder {
652			m.activeFolderIdx = i
653			found = true
654			break
655		}
656	}
657	if !found && len(m.folders) > 0 {
658		m.activeFolderIdx = 0
659		m.currentFolder = m.folders[0]
660	}
661}
662
663func (m *FolderInbox) SetUnreadCounts(counts map[string]int) {
664	m.unread = counts
665}
666
667func (m *FolderInbox) DecrementUnreadCount(folder string) {
668	if m.unread == nil {
669		return
670	}
671	if m.unread[folder] > 0 {
672		m.unread[folder]--
673	}
674}
675
676// IncrementUnreadCount increases the unread counter for a folder. It is used to
677// restore the counter when a delete/archive action is undone.
678func (m *FolderInbox) IncrementUnreadCount(folder string) {
679	if m.unread == nil {
680		m.unread = make(map[string]int)
681	}
682	m.unread[folder]++
683}
684
685// SetEmails updates the inbox emails.
686func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
687	m.accounts = accounts
688	m.inbox.SetEmails(emails, accounts)
689}
690
691// GetCurrentFolder returns the currently selected folder name.
692func (m *FolderInbox) GetCurrentFolder() string {
693	return m.currentFolder
694}
695
696// HasSplitPreview reports whether the split preview pane is currently open.
697func (m *FolderInbox) HasSplitPreview() bool {
698	return m.previewPane != nil
699}
700
701// GetInbox returns the embedded inbox.
702func (m *FolderInbox) GetInbox() *Inbox {
703	return m.inbox
704}
705
706// GetAccounts returns the accounts.
707func (m *FolderInbox) GetAccounts() []config.Account {
708	return m.accounts
709}
710
711// RemoveEmail removes an email from the embedded inbox.
712func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) {
713	m.inbox.RemoveEmail(uid, accountID)
714}
715
716// updateHelpKeys refreshes the inbox help keys based on preview state
717func (m *FolderInbox) updateHelpKeys() {
718	bindings := []key.Binding{
719		key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
720		key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
721		key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
722	}
723	if m.previewPane != nil || m.previewedUID != 0 {
724		bindings = append(bindings,
725			key.NewBinding(key.WithKeys("]"), key.WithHelp("]/[", "switch pane")),
726			key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close preview")),
727		)
728	}
729	m.inbox.extraShortHelpKeys = bindings
730}
731
732// SetLoadingEmails sets the loading state.
733func (m *FolderInbox) SetLoadingEmails(loading bool) {
734	m.isLoadingEmails = loading
735	if loading {
736		m.inbox.isFetching = true
737	} else {
738		m.inbox.isFetching = false
739	}
740	m.inbox.list.Title = m.inbox.getTitle()
741}
742
743// SetRefreshing sets the refreshing state (used when user presses "r").
744func (m *FolderInbox) SetRefreshing(refreshing bool) {
745	m.inbox.isRefreshing = refreshing
746	m.inbox.list.Title = m.inbox.getTitle()
747}
748
749// GetFolders returns the current folder list.
750func (m *FolderInbox) GetFolders() []string {
751	return m.folders
752}
753
754// renderInboxPane renders inbox with border for split pane mode
755func (m *FolderInbox) renderInboxPane() string {
756	inboxWidth := m.calculateInboxWidth()
757
758	borderColor := unfocusedBorderColor
759	if m.focusedPane == FocusInbox {
760		borderColor = focusedBorderColor
761	}
762
763	paneStyle := inboxPaneStyle.
764		BorderForeground(borderColor).
765		Width(inboxWidth).
766		Height(m.height)
767
768	m.inbox.SetSize(inboxWidth-2, m.height)
769	return paneStyle.Render(m.inbox.View().Content)
770}
771
772// renderPreviewPane renders email preview with border
773func (m *FolderInbox) renderPreviewPane() string {
774	if m.previewPane == nil {
775		return m.renderEmptyPreview()
776	}
777
778	previewWidth := m.calculatePreviewWidth()
779
780	borderColor := unfocusedBorderColor
781	if m.focusedPane == FocusPreview {
782		borderColor = focusedBorderColor
783	}
784
785	paneStyle := previewPaneStyle.
786		BorderForeground(borderColor).
787		Width(previewWidth).
788		Height(m.height)
789
790	return paneStyle.Render(m.previewPane.View().Content)
791}
792
793// renderEmptyPreview renders placeholder when no email selected
794func (m *FolderInbox) renderEmptyPreview() string {
795	previewWidth := m.calculatePreviewWidth()
796
797	emptyStyle := lipgloss.NewStyle().
798		Width(previewWidth).
799		Height(m.height).
800		Align(lipgloss.Center, lipgloss.Center).
801		Foreground(lipgloss.Color("240"))
802
803	return emptyStyle.Render("Loading...")
804}
805
806// OpenSplitPreview opens the split preview pane for a specific email.
807// email may be non-nil for hits coming from search results (which are not in
808// m.inbox.allEmails); when set, it is used as a fallback by findEmailByUID
809// so the preview can render without a follow-up lookup.
810func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string, email *fetcher.Email) {
811	m.previewPane = nil // Will be created when body arrives
812	m.previewedUID = uid
813	m.previewedAccountID = accountID
814	m.previewSearchEmail = email
815	m.focusedPane = FocusPreview
816	// Recalculate inbox width for split mode
817	inboxWidth := m.calculateInboxWidth()
818	m.inbox.SetSize(inboxWidth-2, m.height)
819	m.updateHelpKeys()
820}
821
822// closeSplitPreview closes the preview pane and returns to inbox-only
823func (m *FolderInbox) closeSplitPreview() {
824	ClearKittyGraphics()
825	m.previewPane = nil
826	m.previewedUID = 0
827	m.previewedAccountID = ""
828	m.previewSearchEmail = nil
829	m.focusedPane = FocusInbox
830	// Restore full inbox width
831	inboxWidth := m.width - sidebarWidth - 3
832	if inboxWidth < 20 {
833		inboxWidth = 20
834	}
835	m.inbox.SetSize(inboxWidth, m.height)
836	m.updateHelpKeys()
837}
838
839// findEmailByUID finds email in inbox by UID and account ID. Falls back to
840// the email handed in by OpenSplitPreview so search hits that are not in
841// allEmails (cross-folder or uncached) still render in the preview pane.
842func (m *FolderInbox) findEmailByUID(uid uint32, accountID string) *fetcher.Email {
843	for i := range m.inbox.allEmails {
844		if m.inbox.allEmails[i].UID == uid && m.inbox.allEmails[i].AccountID == accountID {
845			return &m.inbox.allEmails[i]
846		}
847	}
848	if m.previewSearchEmail != nil &&
849		m.previewSearchEmail.UID == uid &&
850		m.previewSearchEmail.AccountID == accountID {
851		return m.previewSearchEmail
852	}
853	return nil
854}
855
856// calculatePreviewWidth calculates width for preview pane
857func (m *FolderInbox) calculatePreviewWidth() int {
858	remainingWidth := m.width - sidebarWidth - 4 // 4 for borders
859	inboxWidth := int(float64(remainingWidth) * 0.4)
860	if inboxWidth < 30 {
861		inboxWidth = 30
862	}
863	previewWidth := remainingWidth - inboxWidth
864	if previewWidth < 40 {
865		previewWidth = 40
866	}
867	return previewWidth
868}
869
870// calculateInboxWidth calculates width for inbox pane in split mode
871func (m *FolderInbox) calculateInboxWidth() int {
872	remainingWidth := m.width - sidebarWidth - 4
873	inboxWidth := int(float64(remainingWidth) * 0.4)
874	if inboxWidth < 30 {
875		inboxWidth = 30
876	}
877	return inboxWidth
878}