1package tui
2
3import (
4 "fmt"
5 "strings"
6 "time"
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/theme"
14)
15
16// draftItem represents a draft in the list
17type draftItem struct {
18 draft config.Draft
19}
20
21func (i draftItem) Title() string {
22 if i.draft.Subject != "" {
23 return i.draft.Subject
24 }
25 return "(No subject)"
26}
27
28func (i draftItem) Description() string {
29 to := i.draft.To
30 if to == "" {
31 to = "(No recipient)"
32 }
33 timeAgo := formatTimeAgo(i.draft.UpdatedAt)
34 return fmt.Sprintf("To: %s • %s", to, timeAgo)
35}
36
37func (i draftItem) FilterValue() string {
38 return i.draft.Subject + " " + i.draft.To + " " + i.draft.Body
39}
40
41// formatTimeAgo returns a human-readable time difference
42func formatTimeAgo(t time.Time) string {
43 diff := time.Since(t)
44 switch {
45 case diff < time.Minute:
46 return "just now"
47 case diff < time.Hour:
48 mins := int(diff.Minutes())
49 if mins == 1 {
50 return "1 minute ago"
51 }
52 return fmt.Sprintf("%d minutes ago", mins)
53 case diff < 24*time.Hour:
54 hours := int(diff.Hours())
55 if hours == 1 {
56 return "1 hour ago"
57 }
58 return fmt.Sprintf("%d hours ago", hours)
59 case diff < 7*24*time.Hour:
60 days := int(diff.Hours() / 24)
61 if days == 1 {
62 return "1 day ago"
63 }
64 return fmt.Sprintf("%d days ago", days)
65 default:
66 return t.Local().Format("Jan 2, 2006")
67 }
68}
69
70// Drafts is the model for the drafts list view
71type Drafts struct {
72 list list.Model
73 drafts []config.Draft
74 width int
75 height int
76 confirmDelete bool
77 selectedDraft *config.Draft
78}
79
80// NewDrafts creates a new drafts list view
81func NewDrafts(drafts []config.Draft) *Drafts {
82 items := make([]list.Item, len(drafts))
83 for i, d := range drafts {
84 items[i] = draftItem{draft: d}
85 }
86
87 l := list.New(items, list.NewDefaultDelegate(), 0, 0)
88 l.Title = "Drafts"
89 l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
90 l.SetShowStatusBar(true)
91 l.SetFilteringEnabled(true)
92 l.SetStatusBarItemName("draft", "drafts")
93 l.AdditionalShortHelpKeys = func() []key.Binding {
94 return []key.Binding{
95 key.NewBinding(key.WithKeys("enter"), key.WithHelp("\ue5fe enter", "open")),
96 key.NewBinding(key.WithKeys("d"), key.WithHelp("\uea81 d", "delete")),
97 }
98 }
99 l.KeyMap.Quit.SetEnabled(false)
100
101 return &Drafts{
102 list: l,
103 drafts: drafts,
104 }
105}
106
107func (m *Drafts) Init() tea.Cmd {
108 return nil
109}
110
111func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112 switch msg := msg.(type) {
113 case tea.WindowSizeMsg:
114 m.width = msg.Width
115 m.height = msg.Height
116 m.list.SetWidth(msg.Width)
117 m.list.SetHeight(msg.Height - 4)
118 return m, nil
119
120 case tea.KeyPressMsg:
121 // Handle delete confirmation
122 if m.confirmDelete {
123 switch msg.String() {
124 case "y", "Y":
125 if m.selectedDraft != nil {
126 draftID := m.selectedDraft.ID
127 m.confirmDelete = false
128 m.selectedDraft = nil
129 return m, func() tea.Msg {
130 return DeleteSavedDraftMsg{DraftID: draftID}
131 }
132 }
133 case "n", "N", "esc":
134 m.confirmDelete = false
135 m.selectedDraft = nil
136 }
137 return m, nil
138 }
139
140 // Skip key handling during filtering
141 if m.list.FilterState() == list.Filtering {
142 break
143 }
144
145 switch msg.String() {
146 case "esc":
147 return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
148 case "enter":
149 if item, ok := m.list.SelectedItem().(draftItem); ok {
150 return m, func() tea.Msg {
151 return OpenDraftMsg{Draft: item.draft}
152 }
153 }
154 case "d":
155 if item, ok := m.list.SelectedItem().(draftItem); ok {
156 m.confirmDelete = true
157 m.selectedDraft = &item.draft
158 return m, nil
159 }
160 }
161
162 case DraftDeletedMsg:
163 if msg.Err == nil {
164 // Remove the deleted draft from the list
165 var newDrafts []config.Draft
166 for _, d := range m.drafts {
167 if d.ID != msg.DraftID {
168 newDrafts = append(newDrafts, d)
169 }
170 }
171 m.drafts = newDrafts
172
173 items := make([]list.Item, len(m.drafts))
174 for i, d := range m.drafts {
175 items[i] = draftItem{draft: d}
176 }
177 m.list.SetItems(items)
178 }
179 return m, nil
180 }
181
182 var cmd tea.Cmd
183 m.list, cmd = m.list.Update(msg)
184 return m, cmd
185}
186
187func (m *Drafts) View() tea.View {
188 var b strings.Builder
189
190 if m.confirmDelete {
191 dialog := DialogBoxStyle.Render(
192 lipgloss.JoinVertical(lipgloss.Center,
193 "Delete this draft?",
194 HelpStyle.Render("\n(y/n)"),
195 ),
196 )
197 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
198 }
199
200 if len(m.drafts) == 0 {
201 emptyMsg := lipgloss.NewStyle().
202 Foreground(theme.ActiveTheme.Secondary).
203 Render("No drafts saved.\n\nPress esc to go back.")
204 return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg))
205 }
206
207 // list.View() still returns string in v2
208 b.WriteString(m.list.View())
209 return tea.NewView(b.String())
210}
211
212// SetDrafts updates the drafts list
213func (m *Drafts) SetDrafts(drafts []config.Draft) {
214 m.drafts = drafts
215 items := make([]list.Item, len(drafts))
216 for i, d := range drafts {
217 items[i] = draftItem{draft: d}
218 }
219 m.list.SetItems(items)
220}