folder_inbox.go

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