v0.23.2 bfd5eb4

purge.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close