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