folder_inbox.go

  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}