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