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   // 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}