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// SetEmails updates the inbox emails.
677func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
678 m.accounts = accounts
679 m.inbox.SetEmails(emails, accounts)
680}
681
682// GetCurrentFolder returns the currently selected folder name.
683func (m *FolderInbox) GetCurrentFolder() string {
684 return m.currentFolder
685}
686
687// HasSplitPreview reports whether the split preview pane is currently open.
688func (m *FolderInbox) HasSplitPreview() bool {
689 return m.previewPane != nil
690}
691
692// GetInbox returns the embedded inbox.
693func (m *FolderInbox) GetInbox() *Inbox {
694 return m.inbox
695}
696
697// GetAccounts returns the accounts.
698func (m *FolderInbox) GetAccounts() []config.Account {
699 return m.accounts
700}
701
702// RemoveEmail removes an email from the embedded inbox.
703func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) {
704 m.inbox.RemoveEmail(uid, accountID)
705}
706
707// updateHelpKeys refreshes the inbox help keys based on preview state
708func (m *FolderInbox) updateHelpKeys() {
709 bindings := []key.Binding{
710 key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
711 key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
712 key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
713 }
714 if m.previewPane != nil || m.previewedUID != 0 {
715 bindings = append(bindings,
716 key.NewBinding(key.WithKeys("]"), key.WithHelp("]/[", "switch pane")),
717 key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close preview")),
718 )
719 }
720 m.inbox.extraShortHelpKeys = bindings
721}
722
723// SetLoadingEmails sets the loading state.
724func (m *FolderInbox) SetLoadingEmails(loading bool) {
725 m.isLoadingEmails = loading
726 if loading {
727 m.inbox.isFetching = true
728 } else {
729 m.inbox.isFetching = false
730 }
731 m.inbox.list.Title = m.inbox.getTitle()
732}
733
734// SetRefreshing sets the refreshing state (used when user presses "r").
735func (m *FolderInbox) SetRefreshing(refreshing bool) {
736 m.inbox.isRefreshing = refreshing
737 m.inbox.list.Title = m.inbox.getTitle()
738}
739
740// GetFolders returns the current folder list.
741func (m *FolderInbox) GetFolders() []string {
742 return m.folders
743}
744
745// renderInboxPane renders inbox with border for split pane mode
746func (m *FolderInbox) renderInboxPane() string {
747 inboxWidth := m.calculateInboxWidth()
748
749 borderColor := unfocusedBorderColor
750 if m.focusedPane == FocusInbox {
751 borderColor = focusedBorderColor
752 }
753
754 paneStyle := inboxPaneStyle.
755 BorderForeground(borderColor).
756 Width(inboxWidth).
757 Height(m.height)
758
759 m.inbox.SetSize(inboxWidth-2, m.height)
760 return paneStyle.Render(m.inbox.View().Content)
761}
762
763// renderPreviewPane renders email preview with border
764func (m *FolderInbox) renderPreviewPane() string {
765 if m.previewPane == nil {
766 return m.renderEmptyPreview()
767 }
768
769 previewWidth := m.calculatePreviewWidth()
770
771 borderColor := unfocusedBorderColor
772 if m.focusedPane == FocusPreview {
773 borderColor = focusedBorderColor
774 }
775
776 paneStyle := previewPaneStyle.
777 BorderForeground(borderColor).
778 Width(previewWidth).
779 Height(m.height)
780
781 return paneStyle.Render(m.previewPane.View().Content)
782}
783
784// renderEmptyPreview renders placeholder when no email selected
785func (m *FolderInbox) renderEmptyPreview() string {
786 previewWidth := m.calculatePreviewWidth()
787
788 emptyStyle := lipgloss.NewStyle().
789 Width(previewWidth).
790 Height(m.height).
791 Align(lipgloss.Center, lipgloss.Center).
792 Foreground(lipgloss.Color("240"))
793
794 return emptyStyle.Render("Loading...")
795}
796
797// OpenSplitPreview opens the split preview pane for a specific email.
798// email may be non-nil for hits coming from search results (which are not in
799// m.inbox.allEmails); when set, it is used as a fallback by findEmailByUID
800// so the preview can render without a follow-up lookup.
801func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string, email *fetcher.Email) {
802 m.previewPane = nil // Will be created when body arrives
803 m.previewedUID = uid
804 m.previewedAccountID = accountID
805 m.previewSearchEmail = email
806 m.focusedPane = FocusPreview
807 // Recalculate inbox width for split mode
808 inboxWidth := m.calculateInboxWidth()
809 m.inbox.SetSize(inboxWidth-2, m.height)
810 m.updateHelpKeys()
811}
812
813// closeSplitPreview closes the preview pane and returns to inbox-only
814func (m *FolderInbox) closeSplitPreview() {
815 ClearKittyGraphics()
816 m.previewPane = nil
817 m.previewedUID = 0
818 m.previewedAccountID = ""
819 m.previewSearchEmail = nil
820 m.focusedPane = FocusInbox
821 // Restore full inbox width
822 inboxWidth := m.width - sidebarWidth - 3
823 if inboxWidth < 20 {
824 inboxWidth = 20
825 }
826 m.inbox.SetSize(inboxWidth, m.height)
827 m.updateHelpKeys()
828}
829
830// findEmailByUID finds email in inbox by UID and account ID. Falls back to
831// the email handed in by OpenSplitPreview so search hits that are not in
832// allEmails (cross-folder or uncached) still render in the preview pane.
833func (m *FolderInbox) findEmailByUID(uid uint32, accountID string) *fetcher.Email {
834 for i := range m.inbox.allEmails {
835 if m.inbox.allEmails[i].UID == uid && m.inbox.allEmails[i].AccountID == accountID {
836 return &m.inbox.allEmails[i]
837 }
838 }
839 if m.previewSearchEmail != nil &&
840 m.previewSearchEmail.UID == uid &&
841 m.previewSearchEmail.AccountID == accountID {
842 return m.previewSearchEmail
843 }
844 return nil
845}
846
847// calculatePreviewWidth calculates width for preview pane
848func (m *FolderInbox) calculatePreviewWidth() int {
849 remainingWidth := m.width - sidebarWidth - 4 // 4 for borders
850 inboxWidth := int(float64(remainingWidth) * 0.4)
851 if inboxWidth < 30 {
852 inboxWidth = 30
853 }
854 previewWidth := remainingWidth - inboxWidth
855 if previewWidth < 40 {
856 previewWidth = 40
857 }
858 return previewWidth
859}
860
861// calculateInboxWidth calculates width for inbox pane in split mode
862func (m *FolderInbox) calculateInboxWidth() int {
863 remainingWidth := m.width - sidebarWidth - 4
864 inboxWidth := int(float64(remainingWidth) * 0.4)
865 if inboxWidth < 30 {
866 inboxWidth = 30
867 }
868 return inboxWidth
869}