1package styles
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/key"
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10
11 "github.com/bnema/dumber/internal/domain/entity"
12)
13
14// PurgeItem wraps entity.PurgeTarget with selection state for the UI.
15type PurgeItem struct {
16 entity.PurgeTarget
17 Selected bool
18}
19
20// PurgeModel is the multi-select purge modal.
21type PurgeModel struct {
22 Items []PurgeItem
23 Cursor int
24 Confirmed bool
25 Canceled bool
26 theme *Theme
27}
28
29// PurgeKeyMap defines keybindings for purge modal.
30type PurgeKeyMap struct {
31 Up key.Binding
32 Down key.Binding
33 Toggle key.Binding
34 ToggleAll key.Binding
35 Confirm key.Binding
36 Cancel key.Binding
37}
38
39// DefaultPurgeKeyMap returns default keybindings.
40func DefaultPurgeKeyMap() PurgeKeyMap {
41 return PurgeKeyMap{
42 Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
43 Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
44 Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
45 ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),
46 Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
47 Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
48 }
49}
50
51// NewPurge creates a new purge modal with the given targets.
52func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
53 items := make([]PurgeItem, 0, len(targets))
54 for _, t := range targets {
55 items = append(items, PurgeItem{PurgeTarget: t, Selected: t.Exists})
56 }
57
58 m := PurgeModel{Items: items, Cursor: 0, theme: theme}
59 m.Cursor = m.firstSelectableIndex()
60 return m
61}
62
63// Init implements tea.Model.
64func (m PurgeModel) Init() tea.Cmd {
65 _ = m
66 return nil
67}
68
69// Update implements tea.Model.
70func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
71 keys := DefaultPurgeKeyMap()
72
73 switch msg := msg.(type) {
74 case tea.KeyMsg:
75 switch {
76 case key.Matches(msg, keys.Up):
77 m.moveCursor(-1)
78 case key.Matches(msg, keys.Down):
79 m.moveCursor(1)
80 case key.Matches(msg, keys.Toggle):
81 m.toggleCurrent()
82 case key.Matches(msg, keys.ToggleAll):
83 m.toggleAll()
84 case key.Matches(msg, keys.Confirm):
85 m.Confirmed = true
86 case key.Matches(msg, keys.Cancel):
87 m.Canceled = true
88 }
89 }
90
91 return m, nil
92}
93
94func (m *PurgeModel) moveCursor(delta int) {
95 if len(m.Items) == 0 {
96 return
97 }
98
99 idx := m.Cursor
100 for i := 0; i < len(m.Items); i++ {
101 idx = (idx + delta + len(m.Items)) % len(m.Items)
102 if m.Items[idx].Exists {
103 m.Cursor = idx
104 return
105 }
106 }
107}
108
109func (m PurgeModel) firstSelectableIndex() int {
110 for i, it := range m.Items {
111 if it.Exists {
112 return i
113 }
114 }
115 return 0
116}
117
118func (m *PurgeModel) toggleCurrent() {
119 if m.Cursor < 0 || m.Cursor >= len(m.Items) {
120 return
121 }
122 if !m.Items[m.Cursor].Exists {
123 return
124 }
125 m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected
126}
127
128func (m *PurgeModel) toggleAll() {
129 anyUnselected := false
130 for _, it := range m.Items {
131 if it.Exists && !it.Selected {
132 anyUnselected = true
133 break
134 }
135 }
136
137 for i := range m.Items {
138 if !m.Items[i].Exists {
139 continue
140 }
141 m.Items[i].Selected = anyUnselected
142 }
143}
144
145// View implements tea.Model.
146func (m PurgeModel) View() string {
147 t := m.theme
148
149 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
150 subtitle := t.Subtle.Render("Select items to remove")
151
152 var rows []string
153 for i, it := range m.Items {
154 rows = append(rows, m.renderRow(i, it))
155 }
156
157 list := lipgloss.JoinVertical(lipgloss.Left, rows...)
158
159 selectedCount := m.SelectedCount()
160 size := m.SelectedSize()
161 var summary string
162 if selectedCount > 0 {
163 summary = lipgloss.JoinHorizontal(
164 lipgloss.Left,
165 t.WarningStyle.Render(IconWarning),
166 " ",
167 t.Subtle.Render(fmt.Sprintf("%d selected", selectedCount)),
168 " ",
169 t.Subtle.Render(fmt.Sprintf("(%s)", formatSize(size))),
170 )
171 } else {
172 summary = t.Subtle.Render("0 selected")
173 }
174
175 help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel")
176
177 content := lipgloss.JoinVertical(
178 lipgloss.Left,
179 header,
180 subtitle,
181 "",
182 list,
183 "",
184 summary,
185 "",
186 help,
187 )
188
189 return t.Box.Render(content)
190}
191
192func (m PurgeModel) renderRow(i int, it PurgeItem) string {
193 t := m.theme
194
195 cursor := " "
196 if i == m.Cursor {
197 cursor = IconCursor + " "
198 }
199
200 checkbox := IconCheckboxEmpty
201 if it.Selected {
202 checkbox = IconCheckboxChecked
203 }
204
205 label := purgeLabel(it.Type)
206
207 pathStyle := t.Subtle
208 labelStyle := t.Normal
209 sizeStyle := t.Subtle
210 checkboxStyle := lipgloss.NewStyle().Foreground(t.Accent)
211 cursorStyle := lipgloss.NewStyle().Foreground(t.Accent)
212
213 missing := ""
214 if !it.Exists {
215 checkbox = IconCheckboxEmpty
216 checkboxStyle = t.Subtle
217 cursorStyle = t.Subtle
218 labelStyle = t.Subtle
219 pathStyle = t.Subtle
220 sizeStyle = t.Subtle
221 missing = t.Subtle.Render("(not found)")
222 }
223
224 size := ""
225 if it.Exists {
226 size = sizeStyle.Render(formatSize(it.Size))
227 }
228
229 const labelPadWidth = 12
230 left := lipgloss.JoinHorizontal(
231 lipgloss.Left,
232 cursorStyle.Render(cursor),
233 checkboxStyle.Render(checkbox),
234 " ",
235 labelStyle.Render(padRight(label, labelPadWidth)),
236 pathStyle.Render(it.Path),
237 )
238
239 if it.Exists {
240 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", size)
241 }
242 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
243}
244
245func purgeLabel(t entity.PurgeTargetType) string {
246 switch t {
247 case entity.PurgeTargetConfig:
248 return fmt.Sprintf("%s Config", IconConfig)
249 case entity.PurgeTargetData:
250 return fmt.Sprintf("%s Data", IconDatabase)
251 case entity.PurgeTargetState:
252 return fmt.Sprintf("%s State", IconLogs)
253 case entity.PurgeTargetCache:
254 return fmt.Sprintf("%s Cache", IconCache)
255 case entity.PurgeTargetFilterJSON:
256 return fmt.Sprintf("%s Filter JSON", IconFilter)
257 case entity.PurgeTargetFilterStore:
258 return fmt.Sprintf("%s Filter Store", IconFilter)
259 case entity.PurgeTargetFilterCache:
260 return fmt.Sprintf("%s Filter Cache", IconFilter)
261 case entity.PurgeTargetDesktopFile:
262 return fmt.Sprintf("%s Desktop file", IconDesktop)
263 case entity.PurgeTargetIcon:
264 return fmt.Sprintf("%s Icon", IconImage)
265 default:
266 return "Item"
267 }
268}
269
270func padRight(s string, width int) string {
271 r := []rune(s)
272 if len(r) >= width {
273 return s
274 }
275 return s + strings.Repeat(" ", width-len(r))
276}
277
278func formatSize(bytes int64) string {
279 const unit = 1024
280 if bytes < unit {
281 return fmt.Sprintf("%d B", bytes)
282 }
283 div, exp := int64(unit), 0
284 for n := bytes / unit; n >= unit; n /= unit {
285 div *= unit
286 exp++
287 }
288 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
289}
290
291// Done returns true if the modal is complete.
292func (m PurgeModel) Done() bool {
293 return m.Confirmed || m.Canceled
294}
295
296// SelectedTypes returns the selected target types.
297func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
298 selected := make(map[entity.PurgeTargetType]struct{})
299 for _, it := range m.Items {
300 if it.Exists && it.Selected {
301 selected[it.Type] = struct{}{}
302 }
303 }
304
305 out := make([]entity.PurgeTargetType, 0, len(selected))
306 for tt := range selected {
307 out = append(out, tt)
308 }
309 return out
310}
311
312// SelectedCount returns the number of selected, existing items.
313func (m PurgeModel) SelectedCount() int {
314 count := 0
315 for _, it := range m.Items {
316 if it.Exists && it.Selected {
317 count++
318 }
319 }
320 return count
321}
322
323// SelectedSize returns the total size of selected, existing items.
324func (m PurgeModel) SelectedSize() int64 {
325 var total int64
326 for _, it := range m.Items {
327 if it.Exists && it.Selected {
328 total += it.Size
329 }
330 }
331 return total
332}