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 with simplified sessions support.
21// Sessions are handled as a single "purge all" option instead of individual selection.
22type PurgeModel struct {
23 Items []PurgeItem
24 Sessions []entity.SessionPurgeItem
25 SessionsSize int64 // Total size of all session snapshots
26 Cursor int // Flat cursor index across all rows
27 Confirmed bool
28 Canceled bool
29 theme *Theme
30
31 // allSessionsSelected indicates if all sessions should be purged
32 allSessionsSelected bool
33}
34
35// PurgeKeyMap defines keybindings for purge modal.
36type PurgeKeyMap struct {
37 Up key.Binding
38 Down key.Binding
39 Toggle key.Binding
40 ToggleAll key.Binding
41 Confirm key.Binding
42 Cancel key.Binding
43}
44
45// DefaultPurgeKeyMap returns default keybindings.
46func DefaultPurgeKeyMap() PurgeKeyMap {
47 return PurgeKeyMap{
48 Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
49 Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
50 Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
51 ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),
52 Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
53 Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
54 }
55}
56
57// rowType indicates what kind of row we're dealing with
58type rowType int
59
60const (
61 rowTypeItem rowType = iota
62 rowTypeSessions
63)
64
65// row represents a single row in the flat list
66type row struct {
67 Type rowType
68 ItemIndex int // index into Items array (only for rowTypeItem)
69 Exists bool // whether this row is selectable
70}
71
72// buildRows creates a flat list of rows in display order
73func (m PurgeModel) buildRows() []row {
74 var rows []row
75 for i, it := range m.Items {
76 rows = append(rows, row{Type: rowTypeItem, ItemIndex: i, Exists: it.Exists})
77 // Insert sessions row right after Data
78 if it.Type == entity.PurgeTargetData && m.hasSessionsItem() {
79 rows = append(rows, row{Type: rowTypeSessions, Exists: m.SessionsEnabled()})
80 }
81 }
82 return rows
83}
84
85// NewPurge creates a new purge modal with the given targets.
86func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
87 items := make([]PurgeItem, 0, len(targets))
88 for _, t := range targets {
89 items = append(items, PurgeItem{PurgeTarget: t, Selected: t.Exists})
90 }
91
92 m := PurgeModel{Items: items, Cursor: 0, theme: theme}
93 m.Cursor = m.firstSelectableIndex()
94 return m
95}
96
97// NewPurgeWithSessions creates a new purge modal with targets and sessions.
98func NewPurgeWithSessions(theme *Theme, targets []entity.PurgeTarget, sessions []entity.SessionPurgeItem, sessionsSize int64) PurgeModel {
99 m := NewPurge(theme, targets)
100 m.Sessions = sessions
101 m.SessionsSize = sessionsSize
102 return m
103}
104
105// Init implements tea.Model.
106func (m PurgeModel) Init() tea.Cmd {
107 _ = m
108 return nil
109}
110
111// Update implements tea.Model.
112func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
113 keys := DefaultPurgeKeyMap()
114
115 switch msg := msg.(type) {
116 case tea.KeyMsg:
117 switch {
118 case key.Matches(msg, keys.Up):
119 m.moveCursor(-1)
120 case key.Matches(msg, keys.Down):
121 m.moveCursor(1)
122 case key.Matches(msg, keys.Toggle):
123 m.toggleCurrent()
124 case key.Matches(msg, keys.ToggleAll):
125 m.toggleAll()
126 case key.Matches(msg, keys.Confirm):
127 m.Confirmed = true
128 case key.Matches(msg, keys.Cancel):
129 m.Canceled = true
130 }
131 }
132
133 return m, nil
134}
135
136// isDataSelected returns true if the Data target is selected.
137func (m PurgeModel) isDataSelected() bool {
138 for _, it := range m.Items {
139 if it.Type == entity.PurgeTargetData && it.Selected {
140 return true
141 }
142 }
143 return false
144}
145
146// SessionsEnabled returns true if sessions can be selected.
147// Sessions are disabled when Data is selected (database will be removed).
148func (m PurgeModel) SessionsEnabled() bool {
149 return !m.isDataSelected()
150}
151
152// hasSessionsItem returns true if there are sessions available to purge.
153func (m PurgeModel) hasSessionsItem() bool {
154 return len(m.Sessions) > 0
155}
156
157func (m *PurgeModel) moveCursor(delta int) {
158 rows := m.buildRows()
159 if len(rows) == 0 {
160 return
161 }
162
163 // Build list of selectable row indices
164 var selectableIndices []int
165 for i, r := range rows {
166 if r.Exists {
167 selectableIndices = append(selectableIndices, i)
168 }
169 }
170
171 if len(selectableIndices) == 0 {
172 return
173 }
174
175 // Find current position in selectable list
176 currentPos := 0
177 for i, idx := range selectableIndices {
178 if idx == m.Cursor {
179 currentPos = i
180 break
181 }
182 }
183
184 // Move
185 newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices)
186 m.Cursor = selectableIndices[newPos]
187}
188
189func (m PurgeModel) firstSelectableIndex() int {
190 rows := m.buildRows()
191 for i, r := range rows {
192 if r.Exists {
193 return i
194 }
195 }
196 return 0
197}
198
199func (m *PurgeModel) toggleCurrent() {
200 rows := m.buildRows()
201 if m.Cursor < 0 || m.Cursor >= len(rows) {
202 return
203 }
204
205 r := rows[m.Cursor]
206 if !r.Exists {
207 return
208 }
209
210 switch r.Type {
211 case rowTypeItem:
212 m.Items[r.ItemIndex].Selected = !m.Items[r.ItemIndex].Selected
213 case rowTypeSessions:
214 m.allSessionsSelected = !m.allSessionsSelected
215 }
216}
217
218func (m *PurgeModel) toggleAll() {
219 // Check if any selectable item is unselected
220 anyUnselected := false
221 for _, it := range m.Items {
222 if it.Exists && !it.Selected {
223 anyUnselected = true
224 break
225 }
226 }
227 if m.hasSessionsItem() && m.SessionsEnabled() && !m.allSessionsSelected {
228 anyUnselected = true
229 }
230
231 // Set all to the opposite state
232 for i := range m.Items {
233 if m.Items[i].Exists {
234 m.Items[i].Selected = anyUnselected
235 }
236 }
237 if m.hasSessionsItem() && m.SessionsEnabled() {
238 m.allSessionsSelected = anyUnselected
239 }
240}
241
242// ToggleAllSessions toggles the selection state of all sessions.
243func (m *PurgeModel) ToggleAllSessions() {
244 if !m.hasSessionsItem() || !m.SessionsEnabled() {
245 return
246 }
247 m.allSessionsSelected = !m.allSessionsSelected
248}
249
250// View implements tea.Model.
251func (m PurgeModel) View() string {
252 t := m.theme
253
254 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
255 subtitle := t.Subtle.Render("Select items to remove")
256
257 // Build rows and render
258 rows := m.buildRows()
259 var itemRows []string
260 for i, r := range rows {
261 isCursor := i == m.Cursor
262 switch r.Type {
263 case rowTypeItem:
264 itemRows = append(itemRows, m.renderItemRow(r.ItemIndex, m.Items[r.ItemIndex], isCursor))
265 case rowTypeSessions:
266 itemRows = append(itemRows, m.renderSessionsItem(isCursor))
267 }
268 }
269
270 itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)
271
272 selectedCount := m.SelectedCount()
273 size := m.SelectedSize()
274 var summary string
275 if selectedCount > 0 {
276 summary = lipgloss.JoinHorizontal(
277 lipgloss.Left,
278 t.WarningStyle.Render(IconWarning),
279 " ",
280 t.Subtle.Render(fmt.Sprintf("%d selected", selectedCount)),
281 " ",
282 t.Subtle.Render(fmt.Sprintf("(%s)", formatSize(size))),
283 )
284 } else {
285 summary = t.Subtle.Render("0 selected")
286 }
287
288 helpText := "↑/↓ j/k move • space toggle • a all • enter • esc"
289 help := t.Subtle.Render(helpText)
290
291 contentParts := []string{
292 header,
293 subtitle,
294 "",
295 itemsList,
296 "",
297 summary,
298 "",
299 help,
300 }
301
302 content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
303
304 return t.Box.Render(content)
305}
306
307func (m PurgeModel) renderItemRow(_ int, it PurgeItem, isCursor bool) string {
308 t := m.theme
309
310 cursor := " "
311 if isCursor && it.Exists {
312 cursor = IconCursor + " "
313 }
314
315 checkbox := IconCheckboxEmpty
316 if it.Selected {
317 checkbox = IconCheckboxChecked
318 }
319
320 label := purgeLabel(it.Type)
321
322 pathStyle := t.Subtle
323 labelStyle := t.Normal
324 sizeStyle := t.Subtle
325 checkboxStyle := lipgloss.NewStyle().Foreground(t.Accent)
326 cursorStyle := lipgloss.NewStyle().Foreground(t.Accent)
327
328 missing := ""
329 if !it.Exists {
330 checkbox = IconCheckboxEmpty
331 checkboxStyle = t.Subtle
332 cursorStyle = t.Subtle
333 labelStyle = t.Subtle
334 pathStyle = t.Subtle
335 sizeStyle = t.Subtle
336 missing = t.Subtle.Render("(not found)")
337 }
338
339 size := ""
340 if it.Exists {
341 size = sizeStyle.Render(formatSize(it.Size))
342 }
343
344 const labelPadWidth = 12
345 left := lipgloss.JoinHorizontal(
346 lipgloss.Left,
347 cursorStyle.Render(cursor),
348 checkboxStyle.Render(checkbox),
349 " ",
350 labelStyle.Render(padRight(label, labelPadWidth)),
351 pathStyle.Render(it.Path),
352 )
353
354 if it.Exists {
355 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", size)
356 }
357 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
358}
359
360// renderSessionsItem renders the single "Sessions" item.
361func (m PurgeModel) renderSessionsItem(isCursor bool) string {
362 t := m.theme
363 enabled := m.SessionsEnabled()
364
365 cursor := " "
366 if isCursor && enabled {
367 cursor = IconCursor + " "
368 }
369
370 checkbox := IconCheckboxEmpty
371 checkboxStyle := t.Subtle
372 cursorStyle := t.Subtle
373 labelStyle := t.Subtle
374 detailStyle := t.Subtle
375
376 if enabled {
377 if m.allSessionsSelected {
378 checkbox = IconCheckboxChecked
379 }
380 checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent)
381 cursorStyle = lipgloss.NewStyle().Foreground(t.Accent)
382 labelStyle = t.Normal
383 }
384
385 // Format: Sessions (5 inactive, 12.3 MB) or (included with Data)
386 label := fmt.Sprintf("%s Sessions", IconSession)
387 var detail string
388 if enabled {
389 detail = fmt.Sprintf("(%d inactive, %s)", len(m.Sessions), formatSize(m.SessionsSize))
390 } else {
391 detail = fmt.Sprintf("(%d inactive, included with Data)", len(m.Sessions))
392 }
393
394 const labelPadWidth = 12
395 return lipgloss.JoinHorizontal(
396 lipgloss.Left,
397 cursorStyle.Render(cursor),
398 checkboxStyle.Render(checkbox),
399 " ",
400 labelStyle.Render(padRight(label, labelPadWidth)),
401 detailStyle.Render(detail),
402 )
403}
404
405func purgeLabel(t entity.PurgeTargetType) string {
406 switch t {
407 case entity.PurgeTargetConfig:
408 return fmt.Sprintf("%s Config", IconConfig)
409 case entity.PurgeTargetData:
410 return fmt.Sprintf("%s Data", IconDatabase)
411 case entity.PurgeTargetState:
412 return fmt.Sprintf("%s State", IconLogs)
413 case entity.PurgeTargetCache:
414 return fmt.Sprintf("%s Cache", IconCache)
415 case entity.PurgeTargetFilterJSON:
416 return fmt.Sprintf("%s Filter JSON", IconFilter)
417 case entity.PurgeTargetFilterStore:
418 return fmt.Sprintf("%s Filter Store", IconFilter)
419 case entity.PurgeTargetFilterCache:
420 return fmt.Sprintf("%s Filter Cache", IconFilter)
421 case entity.PurgeTargetDesktopFile:
422 return fmt.Sprintf("%s Desktop file", IconDesktop)
423 case entity.PurgeTargetIcon:
424 return fmt.Sprintf("%s Icon", IconImage)
425 default:
426 return "Item"
427 }
428}
429
430func padRight(s string, width int) string {
431 r := []rune(s)
432 if len(r) >= width {
433 return s
434 }
435 return s + strings.Repeat(" ", width-len(r))
436}
437
438func formatSize(bytes int64) string {
439 const unit = 1024
440 if bytes < unit {
441 return fmt.Sprintf("%d B", bytes)
442 }
443 div, exp := int64(unit), 0
444 for n := bytes / unit; n >= unit; n /= unit {
445 div *= unit
446 exp++
447 }
448 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
449}
450
451// Done returns true if the modal is complete.
452func (m PurgeModel) Done() bool {
453 return m.Confirmed || m.Canceled
454}
455
456// SelectedTypes returns the selected target types.
457func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
458 selected := make(map[entity.PurgeTargetType]struct{})
459 for _, it := range m.Items {
460 if it.Exists && it.Selected {
461 selected[it.Type] = struct{}{}
462 }
463 }
464
465 out := make([]entity.PurgeTargetType, 0, len(selected))
466 for tt := range selected {
467 out = append(out, tt)
468 }
469 return out
470}
471
472// SelectedCount returns the number of selected items (targets + sessions as 1 if selected).
473func (m PurgeModel) SelectedCount() int {
474 count := 0
475 for _, it := range m.Items {
476 if it.Exists && it.Selected {
477 count++
478 }
479 }
480 // Count sessions as 1 item if selected (and enabled)
481 if m.hasSessionsItem() && m.SessionsEnabled() && m.allSessionsSelected {
482 count++
483 }
484 return count
485}
486
487// SelectedSize returns the total size of selected items.
488// When Data is selected, includes SessionsSize since DB will be removed.
489func (m PurgeModel) SelectedSize() int64 {
490 var total int64
491 for _, it := range m.Items {
492 if it.Exists && it.Selected {
493 total += it.Size
494 }
495 }
496 // If Data is selected, sessions are implicitly included
497 if m.isDataSelected() {
498 total += m.SessionsSize
499 } else if m.allSessionsSelected {
500 // Sessions are explicitly selected
501 total += m.SessionsSize
502 }
503 return total
504}
505
506// SelectedSessionIDs returns the IDs of all sessions if sessions are selected.
507// Returns nil if Data is selected (sessions are deleted with the database)
508// or if sessions are not selected.
509func (m PurgeModel) SelectedSessionIDs() []entity.SessionID {
510 if m.isDataSelected() {
511 return nil
512 }
513 if !m.allSessionsSelected {
514 return nil
515 }
516
517 ids := make([]entity.SessionID, 0, len(m.Sessions))
518 for _, s := range m.Sessions {
519 ids = append(ids, s.Info.Session.ID)
520 }
521 return ids
522}