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