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