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}