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