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}