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