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
61// FolderInbox combines a folder sidebar with an email list.
62type FolderInbox struct {
63 folders []string
64 activeFolderIdx int
65 currentFolder string
66 inbox *Inbox
67 accounts []config.Account
68 width int
69 height int
70 isLoadingEmails bool
71
72 // Move-to-folder overlay state
73 movingEmail bool
74 moveTargetIdx int
75 moveUID uint32 // Legacy: single UID
76 moveUIDs []uint32 // Batch: multiple UIDs
77 moveAccountID string
78 moveSourceFolder string
79}
80
81// sortFolders sorts folder names with INBOX always first, then alphabetically.
82func sortFolders(folders []string) []string {
83 sorted := make([]string, len(folders))
84 copy(sorted, folders)
85 sort.SliceStable(sorted, func(i, j int) bool {
86 iUpper := strings.ToUpper(sorted[i])
87 jUpper := strings.ToUpper(sorted[j])
88 if iUpper == "INBOX" {
89 return true
90 }
91 if jUpper == "INBOX" {
92 return false
93 }
94 return sorted[i] < sorted[j]
95 })
96 return sorted
97}
98
99// SetDateFormat propagates the configured date layout to the inner inbox.
100func (m *FolderInbox) SetDateFormat(layout string) {
101 if m.inbox != nil {
102 m.inbox.SetDateFormat(layout)
103 }
104}
105
106// NewFolderInbox creates a new FolderInbox with the given folders and accounts.
107func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox {
108 folders = sortFolders(folders)
109 currentFolder := "INBOX"
110 if len(folders) > 0 {
111 currentFolder = folders[0]
112 }
113
114 inbox := NewInbox(nil, accounts)
115 inbox.SetFolderName(currentFolder)
116 inbox.extraShortHelpKeys = []key.Binding{
117 key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
118 key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
119 key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move")),
120 }
121
122 return &FolderInbox{
123 folders: folders,
124 activeFolderIdx: 0,
125 currentFolder: currentFolder,
126 inbox: inbox,
127 accounts: accounts,
128 }
129}
130
131func (m *FolderInbox) Init() tea.Cmd {
132 return nil
133}
134
135func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
136 // If move overlay is active, handle its input
137 if m.movingEmail {
138 return m.updateMoveOverlay(msg)
139 }
140
141 switch msg := msg.(type) {
142 case tea.KeyPressMsg:
143 // Don't intercept keys while filtering
144 if m.inbox.list.FilterState() == list.Filtering {
145 break
146 }
147 switch msg.String() {
148 case "tab":
149 m.activeFolderIdx++
150 if m.activeFolderIdx >= len(m.folders) {
151 m.activeFolderIdx = 0
152 }
153 return m, m.switchFolder()
154 case "shift+tab":
155 m.activeFolderIdx--
156 if m.activeFolderIdx < 0 {
157 m.activeFolderIdx = len(m.folders) - 1
158 }
159 return m, m.switchFolder()
160 case "m":
161 // Start move-to-folder flow
162 if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 {
163 // Batch move
164 m.movingEmail = true
165 m.moveTargetIdx = 0
166 m.moveUIDs = make([]uint32, len(m.inbox.selectionOrder))
167 copy(m.moveUIDs, m.inbox.selectionOrder)
168 m.moveAccountID = ""
169 for _, acctID := range m.inbox.selectedUIDs {
170 m.moveAccountID = acctID
171 break
172 }
173 m.moveSourceFolder = m.currentFolder
174 return m, nil
175 } else {
176 // Single move
177 selectedItem, ok := m.inbox.list.SelectedItem().(item)
178 if ok {
179 m.movingEmail = true
180 m.moveTargetIdx = 0
181 m.moveUID = selectedItem.uid
182 m.moveUIDs = []uint32{selectedItem.uid}
183 m.moveAccountID = selectedItem.accountID
184 m.moveSourceFolder = m.currentFolder
185 return m, nil
186 }
187 }
188 }
189
190 case tea.WindowSizeMsg:
191 m.width = msg.Width
192 m.height = msg.Height
193 inboxWidth := msg.Width - sidebarWidth - 3 // 3 for border + padding
194 if inboxWidth < 20 {
195 inboxWidth = 20
196 }
197 m.inbox.SetSize(inboxWidth, msg.Height)
198 return m, nil
199
200 case FolderEmailsFetchedMsg:
201 // Ignore stale responses for folders the user has navigated away from
202 if msg.FolderName != m.currentFolder {
203 return m, nil
204 }
205 m.isLoadingEmails = false
206 m.inbox.isFetching = false
207 m.inbox.isRefreshing = false
208 m.inbox.SetEmails(msg.Emails, m.accounts)
209 m.inbox.SetFolderName(msg.FolderName)
210 return m, nil
211
212 case FolderEmailsAppendedMsg:
213 if msg.FolderName != m.currentFolder {
214 return m, nil
215 }
216 m.inbox.isFetching = false
217 m.inbox.list.Title = m.inbox.getTitle()
218 if len(msg.Emails) == 0 {
219 if m.inbox.noMoreByAccount == nil {
220 m.inbox.noMoreByAccount = make(map[string]bool)
221 }
222 m.inbox.noMoreByAccount[msg.AccountID] = true
223 return m, nil
224 }
225 for _, email := range msg.Emails {
226 m.inbox.emailsByAccount[email.AccountID] = append(m.inbox.emailsByAccount[email.AccountID], email)
227 m.inbox.allEmails = append(m.inbox.allEmails, email)
228 }
229 m.inbox.emailCountByAcct[msg.AccountID] = len(m.inbox.emailsByAccount[msg.AccountID])
230 m.inbox.updateList()
231 return m, nil
232
233 case EmailMovedMsg:
234 if msg.Err != nil {
235 // Error handled by main model
236 return m, nil
237 }
238 m.inbox.RemoveEmail(msg.UID, msg.AccountID)
239 return m, nil
240 }
241
242 // Forward to inbox
243 var cmd tea.Cmd
244 _, cmd = m.inbox.Update(msg)
245
246 // Intercept FetchMoreEmailsMsg from inbox and convert to folder-aware version
247 if cmd != nil {
248 wrappedCmd := m.wrapInboxCmd(cmd)
249 return m, wrappedCmd
250 }
251
252 return m, cmd
253}
254
255// wrapInboxCmd intercepts messages from the inbox and adds folder context.
256func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd {
257 return func() tea.Msg {
258 msg := cmd()
259 switch inner := msg.(type) {
260 case FetchMoreEmailsMsg:
261 return FetchFolderMoreEmailsMsg{
262 Offset: inner.Offset,
263 AccountID: inner.AccountID,
264 FolderName: m.currentFolder,
265 Limit: inner.Limit,
266 }
267 case RequestRefreshMsg:
268 inner.FolderName = m.currentFolder
269 return inner
270 }
271 return msg
272 }
273}
274
275func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) {
276 switch msg := msg.(type) {
277 case tea.KeyPressMsg:
278 switch msg.String() {
279 case "esc":
280 m.movingEmail = false
281 return m, nil
282 case "up", "k":
283 m.moveTargetIdx--
284 if m.moveTargetIdx < 0 {
285 m.moveTargetIdx = len(m.moveFolderChoices()) - 1
286 }
287 return m, nil
288 case "down", "j":
289 m.moveTargetIdx++
290 choices := m.moveFolderChoices()
291 if m.moveTargetIdx >= len(choices) {
292 m.moveTargetIdx = 0
293 }
294 return m, nil
295 case "enter":
296 choices := m.moveFolderChoices()
297 if len(choices) > 0 && m.moveTargetIdx < len(choices) {
298 destFolder := choices[m.moveTargetIdx]
299 m.movingEmail = false
300
301 if len(m.moveUIDs) > 1 {
302 // Batch move
303 uids := m.moveUIDs
304 m.moveUIDs = nil
305
306 // Exit visual mode in inbox
307 m.inbox.visualMode = false
308 m.inbox.selectedUIDs = make(map[uint32]string)
309 m.inbox.selectionOrder = []uint32{}
310 m.inbox.updateListTitle()
311
312 return m, func() tea.Msg {
313 return BatchMoveEmailsMsg{
314 UIDs: uids,
315 AccountID: m.moveAccountID,
316 SourceFolder: m.moveSourceFolder,
317 DestFolder: destFolder,
318 }
319 }
320 } else {
321 // Single move
322 return m, func() tea.Msg {
323 return MoveEmailToFolderMsg{
324 UID: m.moveUID,
325 AccountID: m.moveAccountID,
326 SourceFolder: m.moveSourceFolder,
327 DestFolder: destFolder,
328 }
329 }
330 }
331 }
332 }
333 }
334 return m, nil
335}
336
337// moveFolderChoices returns all folders except the current one.
338func (m *FolderInbox) moveFolderChoices() []string {
339 var choices []string
340 for _, f := range m.folders {
341 if f != m.currentFolder {
342 choices = append(choices, f)
343 }
344 }
345 return choices
346}
347
348func (m *FolderInbox) switchFolder() tea.Cmd {
349 if m.activeFolderIdx >= 0 && m.activeFolderIdx < len(m.folders) {
350 prevFolder := m.currentFolder
351 m.currentFolder = m.folders[m.activeFolderIdx]
352 m.isLoadingEmails = true
353 m.inbox.SetFolderName(m.currentFolder)
354 // Clear current emails while loading
355 m.inbox.SetEmails(nil, m.accounts)
356 folder := m.currentFolder
357 return func() tea.Msg {
358 return SwitchFolderMsg{FolderName: folder, PreviousFolder: prevFolder}
359 }
360 }
361 return nil
362}
363
364func (m *FolderInbox) View() tea.View {
365 // Render sidebar
366 sidebar := m.renderSidebar()
367
368 // Render inbox
369 inboxView := m.inbox.View().Content
370
371 // Join horizontally
372 content := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, inboxView)
373
374 // If move overlay is active, render it on top
375 if m.movingEmail {
376 content = m.renderWithMoveOverlay(content)
377 }
378
379 return tea.NewView(content)
380}
381
382func (m *FolderInbox) renderSidebar() string {
383 var b strings.Builder
384
385 // Account name as title
386 title := t("folder_inbox.folders_title")
387 if len(m.accounts) > 0 {
388 acc := m.accounts[0]
389 if acc.Name != "" {
390 title = acc.Name
391 } else if acc.FetchEmail != "" {
392 title = acc.FetchEmail
393 }
394 }
395 b.WriteString(sidebarTitleStyle.Render(title))
396 b.WriteString("\n")
397
398 for i, folder := range m.folders {
399 displayName := m.formatFolderName(folder)
400 if i == m.activeFolderIdx {
401 b.WriteString(activeFolderStyle.Width(sidebarWidth - 4).Render(displayName))
402 } else {
403 b.WriteString(folderStyle.Render(displayName))
404 }
405 if i < len(m.folders)-1 {
406 b.WriteString("\n")
407 }
408 }
409
410 sidebarHeight := m.height
411 if sidebarHeight < 1 {
412 sidebarHeight = 20
413 }
414
415 return sidebarStyle.Height(sidebarHeight - 2).Render(b.String())
416}
417
418// formatFolderName makes IMAP folder names more readable.
419func (m *FolderInbox) formatFolderName(name string) string {
420 // Strip common IMAP prefixes for cleaner display
421 name = strings.TrimPrefix(name, "[Gmail]/")
422 name = strings.TrimPrefix(name, "[Google Mail]/")
423 // Truncate to fit sidebar
424 maxLen := sidebarWidth - 5
425 if len(name) > maxLen {
426 name = name[:maxLen-1] + "\u2026"
427 }
428 return name
429}
430
431func (m *FolderInbox) renderWithMoveOverlay(content string) string {
432 choices := m.moveFolderChoices()
433 if len(choices) == 0 {
434 return content
435 }
436
437 var b strings.Builder
438 title := t("folder_inbox.move_to_folder")
439 if len(m.moveUIDs) > 1 {
440 title = tn("folder_inbox.move_multiple", len(m.moveUIDs), map[string]interface{}{
441 "count": len(m.moveUIDs),
442 })
443 }
444 b.WriteString(moveOverlayTitleStyle.Render(title))
445 b.WriteString("\n")
446
447 for i, folder := range choices {
448 displayName := m.formatFolderName(folder)
449 if i == m.moveTargetIdx {
450 b.WriteString(moveSelectedItemStyle.Render("> " + displayName))
451 } else {
452 b.WriteString(moveItemStyle.Render(" " + displayName))
453 }
454 if i < len(choices)-1 {
455 b.WriteString("\n")
456 }
457 }
458
459 b.WriteString("\n\n")
460 b.WriteString(helpStyle.Render(t("folder_inbox.help")))
461
462 overlay := moveOverlayStyle.Render(b.String())
463
464 // Place overlay in the center of content
465 contentLines := strings.Split(content, "\n")
466 overlayLines := strings.Split(overlay, "\n")
467 contentHeight := len(contentLines)
468 overlayHeight := len(overlayLines)
469 overlayWidth := lipgloss.Width(overlay)
470
471 startRow := (contentHeight - overlayHeight) / 2
472 if startRow < 0 {
473 startRow = 0
474 }
475 startCol := (m.width - overlayWidth) / 2
476 if startCol < 0 {
477 startCol = 0
478 }
479
480 // Overlay the box on top of the content
481 for i, overlayLine := range overlayLines {
482 row := startRow + i
483 if row >= len(contentLines) {
484 break
485 }
486 line := contentLines[row]
487 lineWidth := lipgloss.Width(line)
488
489 // Build the new line: prefix + overlay + suffix
490 if startCol >= lineWidth {
491 contentLines[row] = line + strings.Repeat(" ", startCol-lineWidth) + overlayLine
492 } else {
493 // We need to place the overlay at startCol
494 // Due to ANSI escape codes, we can't simply slice the string
495 // Instead, place the overlay line padded to the left
496 contentLines[row] = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, overlayLine)
497 }
498 }
499
500 return strings.Join(contentLines, "\n")
501}
502
503// SetFolders updates the folder list.
504func (m *FolderInbox) SetFolders(folders []string) {
505 m.folders = sortFolders(folders)
506 // Keep current folder if it still exists (search sorted list)
507 found := false
508 for i, f := range m.folders {
509 if f == m.currentFolder {
510 m.activeFolderIdx = i
511 found = true
512 break
513 }
514 }
515 if !found && len(m.folders) > 0 {
516 m.activeFolderIdx = 0
517 m.currentFolder = m.folders[0]
518 }
519}
520
521// SetEmails updates the inbox emails.
522func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
523 m.accounts = accounts
524 m.inbox.SetEmails(emails, accounts)
525}
526
527// GetCurrentFolder returns the currently selected folder name.
528func (m *FolderInbox) GetCurrentFolder() string {
529 return m.currentFolder
530}
531
532// GetInbox returns the embedded inbox.
533func (m *FolderInbox) GetInbox() *Inbox {
534 return m.inbox
535}
536
537// GetAccounts returns the accounts.
538func (m *FolderInbox) GetAccounts() []config.Account {
539 return m.accounts
540}
541
542// RemoveEmail removes an email from the embedded inbox.
543func (m *FolderInbox) RemoveEmail(uid uint32, accountID string) {
544 m.inbox.RemoveEmail(uid, accountID)
545}
546
547// AdditionalShortHelpKeys returns help key bindings for the folder inbox.
548func (m *FolderInbox) AdditionalShortHelpKeys() []key.Binding {
549 return []key.Binding{
550 key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next folder")),
551 key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev folder")),
552 key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "move to folder")),
553 }
554}
555
556// SetLoadingEmails sets the loading state.
557func (m *FolderInbox) SetLoadingEmails(loading bool) {
558 m.isLoadingEmails = loading
559 if loading {
560 m.inbox.isFetching = true
561 } else {
562 m.inbox.isFetching = false
563 }
564 m.inbox.list.Title = m.inbox.getTitle()
565}
566
567// SetRefreshing sets the refreshing state (used when user presses "r").
568func (m *FolderInbox) SetRefreshing(refreshing bool) {
569 m.inbox.isRefreshing = refreshing
570 m.inbox.list.Title = m.inbox.getTitle()
571}
572
573// GetFolders returns the current folder list.
574func (m *FolderInbox) GetFolders() []string {
575 return m.folders
576}
577
578// Helper to get the formatted inbox title
579func folderInboxTitle(folder string) string {
580 return fmt.Sprintf("Folder: %s", folder)
581}