v0.20.0 c741a39

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

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