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