detached 3630652

feat(ui): add session selection to purge TUI

commit
36306522a5a77b1ee35a7a8d43ce5bce7da7ef48
author
brice · · 2025-12-25 10:17 UTC
parent
795138f
signature
unsigned
2 files · +567 -41
M internal/cli/styles/purge.go +327 -41
@@ -3,6 +3,7 @@ package styles
33 import (
44 "fmt"
55 "strings"
6+ "time"
67
78 "github.com/charmbracelet/bubbles/key"
89 tea "github.com/charmbracelet/bubbletea"
@@ -17,34 +18,41 @@ type PurgeItem struct {
1718 Selected bool
1819 }
1920
20-// PurgeModel is the multi-select purge modal.
21+// PurgeModel is the multi-select purge modal with sessions support.
2122 type PurgeModel struct {
22- Items []PurgeItem
23- Cursor int
24- Confirmed bool
25- Canceled bool
26- theme *Theme
23+ Items []PurgeItem
24+ Sessions []entity.SessionPurgeItem
25+ SessionsSize int64 // Total size of all session snapshots
26+ Cursor int
27+ Confirmed bool
28+ Canceled bool
29+ theme *Theme
30+
31+ // Track cursor position: false = items section, true = sessions section
32+ inSessionsSection bool
2733 }
2834
2935 // PurgeKeyMap defines keybindings for purge modal.
3036 type 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+ Up key.Binding
38+ Down key.Binding
39+ Toggle key.Binding
40+ ToggleAll key.Binding
41+ ToggleSessions key.Binding
42+ Confirm key.Binding
43+ Cancel key.Binding
3744 }
3845
3946 // DefaultPurgeKeyMap returns default keybindings.
4047 func DefaultPurgeKeyMap() PurgeKeyMap {
4148 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")),
49+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
50+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
51+ Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
52+ ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),
53+ ToggleSessions: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "toggle sessions")),
54+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
55+ Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
4856 }
4957 }
5058
@@ -60,6 +68,14 @@ func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
6068 return m
6169 }
6270
71+// NewPurgeWithSessions creates a new purge modal with targets and sessions.
72+func NewPurgeWithSessions(theme *Theme, targets []entity.PurgeTarget, sessions []entity.SessionPurgeItem, sessionsSize int64) PurgeModel {
73+ m := NewPurge(theme, targets)
74+ m.Sessions = sessions
75+ m.SessionsSize = sessionsSize
76+ return m
77+}
78+
6379 // Init implements tea.Model.
6480 func (m PurgeModel) Init() tea.Cmd {
6581 _ = m
@@ -81,6 +97,8 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
8197 m.toggleCurrent()
8298 case key.Matches(msg, keys.ToggleAll):
8399 m.toggleAll()
100+ case key.Matches(msg, keys.ToggleSessions):
101+ m.ToggleAllSessions()
84102 case key.Matches(msg, keys.Confirm):
85103 m.Confirmed = true
86104 case key.Matches(msg, keys.Cancel):
@@ -91,19 +109,78 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
91109 return m, nil
92110 }
93111
112+// isDataSelected returns true if the Data target is selected.
113+func (m PurgeModel) isDataSelected() bool {
114+ for _, it := range m.Items {
115+ if it.Type == entity.PurgeTargetData && it.Selected {
116+ return true
117+ }
118+ }
119+ return false
120+}
121+
122+// SessionsEnabled returns true if sessions can be individually selected.
123+// Sessions are disabled when Data is selected (database will be removed).
124+func (m PurgeModel) SessionsEnabled() bool {
125+ return !m.isDataSelected()
126+}
127+
128+// totalSelectableCount returns the total number of selectable items across both sections.
129+func (m PurgeModel) totalSelectableCount() int {
130+ count := 0
131+ for _, it := range m.Items {
132+ if it.Exists {
133+ count++
134+ }
135+ }
136+ if m.SessionsEnabled() {
137+ count += len(m.Sessions)
138+ }
139+ return count
140+}
141+
94142 func (m *PurgeModel) moveCursor(delta int) {
95- if len(m.Items) == 0 {
143+ total := m.totalSelectableCount()
144+ if total == 0 {
96145 return
97146 }
98147
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
148+ // Build a flat list of selectable indices
149+ // Items first, then sessions (if enabled)
150+ type position struct {
151+ inSessions bool
152+ index int
153+ }
154+ var positions []position
155+
156+ for i, it := range m.Items {
157+ if it.Exists {
158+ positions = append(positions, position{inSessions: false, index: i})
105159 }
106160 }
161+ if m.SessionsEnabled() {
162+ for i := range m.Sessions {
163+ positions = append(positions, position{inSessions: true, index: i})
164+ }
165+ }
166+
167+ if len(positions) == 0 {
168+ return
169+ }
170+
171+ // Find current position in the flat list
172+ currentFlat := 0
173+ for i, p := range positions {
174+ if p.inSessions == m.inSessionsSection && p.index == m.Cursor {
175+ currentFlat = i
176+ break
177+ }
178+ }
179+
180+ // Move in the flat list
181+ newFlat := (currentFlat + delta + len(positions)) % len(positions)
182+ m.inSessionsSection = positions[newFlat].inSessions
183+ m.Cursor = positions[newFlat].index
107184 }
108185
109186 func (m PurgeModel) firstSelectableIndex() int {
@@ -116,6 +193,16 @@ func (m PurgeModel) firstSelectableIndex() int {
116193 }
117194
118195 func (m *PurgeModel) toggleCurrent() {
196+ if m.inSessionsSection {
197+ if !m.SessionsEnabled() {
198+ return
199+ }
200+ if m.Cursor >= 0 && m.Cursor < len(m.Sessions) {
201+ m.Sessions[m.Cursor].Selected = !m.Sessions[m.Cursor].Selected
202+ }
203+ return
204+ }
205+
119206 if m.Cursor < 0 || m.Cursor >= len(m.Items) {
120207 return
121208 }
@@ -126,6 +213,7 @@ func (m *PurgeModel) toggleCurrent() {
126213 }
127214
128215 func (m *PurgeModel) toggleAll() {
216+ // Toggle all items
129217 anyUnselected := false
130218 for _, it := range m.Items {
131219 if it.Exists && !it.Selected {
@@ -133,6 +221,15 @@ func (m *PurgeModel) toggleAll() {
133221 break
134222 }
135223 }
224+ // Also check sessions if enabled
225+ if m.SessionsEnabled() {
226+ for _, s := range m.Sessions {
227+ if !s.Selected {
228+ anyUnselected = true
229+ break
230+ }
231+ }
232+ }
136233
137234 for i := range m.Items {
138235 if !m.Items[i].Exists {
@@ -140,6 +237,30 @@ func (m *PurgeModel) toggleAll() {
140237 }
141238 m.Items[i].Selected = anyUnselected
142239 }
240+ if m.SessionsEnabled() {
241+ for i := range m.Sessions {
242+ m.Sessions[i].Selected = anyUnselected
243+ }
244+ }
245+}
246+
247+// ToggleAllSessions toggles the selection state of all sessions.
248+func (m *PurgeModel) ToggleAllSessions() {
249+ if !m.SessionsEnabled() || len(m.Sessions) == 0 {
250+ return
251+ }
252+
253+ anyUnselected := false
254+ for _, s := range m.Sessions {
255+ if !s.Selected {
256+ anyUnselected = true
257+ break
258+ }
259+ }
260+
261+ for i := range m.Sessions {
262+ m.Sessions[i].Selected = anyUnselected
263+ }
143264 }
144265
145266 // View implements tea.Model.
@@ -149,12 +270,33 @@ func (m PurgeModel) View() string {
149270 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
150271 subtitle := t.Subtle.Render("Select items to remove")
151272
152- var rows []string
273+ // Application Data section
274+ dataSectionHeader := t.Subtle.Render("── Application Data ──")
275+ var itemRows []string
153276 for i, it := range m.Items {
154- rows = append(rows, m.renderRow(i, it))
277+ itemRows = append(itemRows, m.renderItemRow(i, it))
155278 }
279+ itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)
280+
281+ // Sessions section
282+ var sessionsList string
283+ if len(m.Sessions) > 0 {
284+ sessionsEnabled := m.SessionsEnabled()
285+ var sessionHeader string
286+ if sessionsEnabled {
287+ sessionHeader = t.Subtle.Render(fmt.Sprintf("── Inactive Sessions (%d total, %s) ──",
288+ len(m.Sessions), formatSize(m.SessionsSize)))
289+ } else {
290+ sessionHeader = t.Subtle.Render("── Inactive Sessions (database will be removed) ──")
291+ }
156292
157- list := lipgloss.JoinVertical(lipgloss.Left, rows...)
293+ var sessionRows []string
294+ sessionRows = append(sessionRows, sessionHeader)
295+ for i, s := range m.Sessions {
296+ sessionRows = append(sessionRows, m.renderSessionRow(i, s, sessionsEnabled))
297+ }
298+ sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...)
299+ }
158300
159301 selectedCount := m.SelectedCount()
160302 size := m.SelectedSize()
@@ -172,28 +314,35 @@ func (m PurgeModel) View() string {
172314 summary = t.Subtle.Render("0 selected")
173315 }
174316
175- help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel")
317+ helpText := "↑/↓ j/k move • space toggle • a all"
318+ if len(m.Sessions) > 0 && m.SessionsEnabled() {
319+ helpText += " • s sessions"
320+ }
321+ helpText += " • enter • esc"
322+ help := t.Subtle.Render(helpText)
176323
177- content := lipgloss.JoinVertical(
178- lipgloss.Left,
324+ contentParts := []string{
179325 header,
180326 subtitle,
181327 "",
182- list,
183- "",
184- summary,
185- "",
186- help,
187- )
328+ dataSectionHeader,
329+ itemsList,
330+ }
331+ if sessionsList != "" {
332+ contentParts = append(contentParts, "", sessionsList)
333+ }
334+ contentParts = append(contentParts, "", summary, "", help)
335+
336+ content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
188337
189338 return t.Box.Render(content)
190339 }
191340
192-func (m PurgeModel) renderRow(i int, it PurgeItem) string {
341+func (m PurgeModel) renderItemRow(i int, it PurgeItem) string {
193342 t := m.theme
194343
195344 cursor := " "
196- if i == m.Cursor {
345+ if !m.inSessionsSection && i == m.Cursor {
197346 cursor = IconCursor + " "
198347 }
199348
@@ -242,6 +391,62 @@ func (m PurgeModel) renderRow(i int, it PurgeItem) string {
242391 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
243392 }
244393
394+func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled bool) string {
395+ t := m.theme
396+
397+ cursor := " "
398+ if m.inSessionsSection && i == m.Cursor && enabled {
399+ cursor = IconCursor + " "
400+ }
401+
402+ checkbox := IconCheckboxEmpty
403+ checkboxStyle := t.Subtle
404+ cursorStyle := t.Subtle
405+ labelStyle := t.Subtle
406+ detailStyle := t.Subtle
407+
408+ if enabled {
409+ if s.Selected {
410+ checkbox = IconCheckboxChecked
411+ }
412+ checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent)
413+ cursorStyle = lipgloss.NewStyle().Foreground(t.Accent)
414+ labelStyle = t.Normal
415+ detailStyle = t.Subtle
416+ }
417+
418+ // Format: ...abc1 2 tabs 3 panes 2d ago
419+ shortID := s.Info.Session.ShortID()
420+ tabLabel := "tab"
421+ if s.Info.TabCount != 1 {
422+ tabLabel = "tabs"
423+ }
424+ paneLabel := "pane"
425+ if s.Info.PaneCount != 1 {
426+ paneLabel = "panes"
427+ }
428+
429+ const idPadWidth = 8
430+ const tabPadWidth = 8
431+ const panePadWidth = 9
432+
433+ idPart := labelStyle.Render(padRight("..."+shortID, idPadWidth))
434+ tabPart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.TabCount, tabLabel), tabPadWidth))
435+ panePart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.PaneCount, paneLabel), panePadWidth))
436+ timePart := detailStyle.Render(getRelativeTime(s.Info.UpdatedAt))
437+
438+ return lipgloss.JoinHorizontal(
439+ lipgloss.Left,
440+ cursorStyle.Render(cursor),
441+ checkboxStyle.Render(checkbox),
442+ " ",
443+ idPart,
444+ tabPart,
445+ panePart,
446+ timePart,
447+ )
448+}
449+
245450 func purgeLabel(t entity.PurgeTargetType) string {
246451 switch t {
247452 case entity.PurgeTargetConfig:
@@ -288,6 +493,45 @@ func formatSize(bytes int64) string {
288493 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
289494 }
290495
496+func getRelativeTime(t time.Time) string {
497+ now := time.Now()
498+ diff := now.Sub(t)
499+
500+ const (
501+ hoursPerDay = 24
502+ daysPerWeek = 7
503+ )
504+
505+ switch {
506+ case diff < time.Minute:
507+ return "just now"
508+ case diff < time.Hour:
509+ mins := int(diff.Minutes())
510+ if mins == 1 {
511+ return "1m ago"
512+ }
513+ return fmt.Sprintf("%dm ago", mins)
514+ case diff < hoursPerDay*time.Hour:
515+ hours := int(diff.Hours())
516+ if hours == 1 {
517+ return "1h ago"
518+ }
519+ return fmt.Sprintf("%dh ago", hours)
520+ case diff < daysPerWeek*hoursPerDay*time.Hour:
521+ days := int(diff.Hours() / hoursPerDay)
522+ if days == 1 {
523+ return "1d ago"
524+ }
525+ return fmt.Sprintf("%dd ago", days)
526+ default:
527+ weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
528+ if weeks == 1 {
529+ return "1w ago"
530+ }
531+ return fmt.Sprintf("%dw ago", weeks)
532+ }
533+}
534+
291535 // Done returns true if the modal is complete.
292536 func (m PurgeModel) Done() bool {
293537 return m.Confirmed || m.Canceled
@@ -309,7 +553,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
309553 return out
310554 }
311555
312-// SelectedCount returns the number of selected, existing items.
556+// SelectedCount returns the number of selected items (targets + sessions).
313557 func (m PurgeModel) SelectedCount() int {
314558 count := 0
315559 for _, it := range m.Items {
@@ -317,10 +561,19 @@ func (m PurgeModel) SelectedCount() int {
317561 count++
318562 }
319563 }
564+ // Only count sessions if they're enabled (Data not selected)
565+ if m.SessionsEnabled() {
566+ for _, s := range m.Sessions {
567+ if s.Selected {
568+ count++
569+ }
570+ }
571+ }
320572 return count
321573 }
322574
323-// SelectedSize returns the total size of selected, existing items.
575+// SelectedSize returns the total size of selected items.
576+// When Data is selected, includes SessionsSize since DB will be removed.
324577 func (m PurgeModel) SelectedSize() int64 {
325578 var total int64
326579 for _, it := range m.Items {
@@ -328,5 +581,38 @@ func (m PurgeModel) SelectedSize() int64 {
328581 total += it.Size
329582 }
330583 }
584+ // If Data is selected, sessions are implicitly included
585+ if m.isDataSelected() {
586+ total += m.SessionsSize
587+ } else {
588+ // Otherwise, count only selected sessions
589+ // Note: individual session sizes aren't tracked, only total
590+ // When sessions are selected, we include the total size proportionally
591+ selectedCount := 0
592+ for _, s := range m.Sessions {
593+ if s.Selected {
594+ selectedCount++
595+ }
596+ }
597+ if len(m.Sessions) > 0 && selectedCount > 0 {
598+ total += m.SessionsSize * int64(selectedCount) / int64(len(m.Sessions))
599+ }
600+ }
331601 return total
332602 }
603+
604+// SelectedSessionIDs returns the IDs of selected sessions.
605+// Returns nil if Data is selected (sessions are deleted with the database).
606+func (m PurgeModel) SelectedSessionIDs() []entity.SessionID {
607+ if m.isDataSelected() {
608+ return nil
609+ }
610+
611+ var ids []entity.SessionID
612+ for _, s := range m.Sessions {
613+ if s.Selected {
614+ ids = append(ids, s.Info.Session.ID)
615+ }
616+ }
617+ return ids
618+}
@@ -3,6 +3,7 @@ package styles
3 import (3 import (
4 "fmt"4 "fmt"
5 "strings"5 "strings"
6+ "time"
6 7
7 "github.com/charmbracelet/bubbles/key"8 "github.com/charmbracelet/bubbles/key"
8 tea "github.com/charmbracelet/bubbletea"9 tea "github.com/charmbracelet/bubbletea"
@@ -17,34 +18,41 @@ type PurgeItem struct {
17 Selected bool18 Selected bool
18 }19 }
19 20
20-// PurgeModel is the multi-select purge modal.21+// PurgeModel is the multi-select purge modal with sessions support.
21 type PurgeModel struct {22 type PurgeModel struct {
22- Items []PurgeItem23+ Items []PurgeItem
23- Cursor int24+ Sessions []entity.SessionPurgeItem
24- Confirmed bool25+ SessionsSize int64 // Total size of all session snapshots
25- Canceled bool26+ Cursor int
26- theme *Theme27+ Confirmed bool
28+ Canceled bool
29+ theme *Theme
30+
31+ // Track cursor position: false = items section, true = sessions section
32+ inSessionsSection bool
27 }33 }
28 34
29 // PurgeKeyMap defines keybindings for purge modal.35 // PurgeKeyMap defines keybindings for purge modal.
30 type PurgeKeyMap struct {36 type PurgeKeyMap struct {
31- Up key.Binding37+ Up key.Binding
32- Down key.Binding38+ Down key.Binding
33- Toggle key.Binding39+ Toggle key.Binding
34- ToggleAll key.Binding40+ ToggleAll key.Binding
35- Confirm key.Binding41+ ToggleSessions key.Binding
36- Cancel key.Binding42+ Confirm key.Binding
43+ Cancel key.Binding
37 }44 }
38 45
39 // DefaultPurgeKeyMap returns default keybindings.46 // DefaultPurgeKeyMap returns default keybindings.
40 func DefaultPurgeKeyMap() PurgeKeyMap {47 func DefaultPurgeKeyMap() PurgeKeyMap {
41 return PurgeKeyMap{48 return PurgeKeyMap{
42- Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),49+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
43- Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),50+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
44- Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),51+ Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
45- ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),52+ ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),
46- Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),53+ ToggleSessions: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "toggle sessions")),
47- Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),54+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
55+ Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
48 }56 }
49 }57 }
50 58
@@ -60,6 +68,14 @@ func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
60 return m68 return m
61 }69 }
62 70
71+// NewPurgeWithSessions creates a new purge modal with targets and sessions.
72+func NewPurgeWithSessions(theme *Theme, targets []entity.PurgeTarget, sessions []entity.SessionPurgeItem, sessionsSize int64) PurgeModel {
73+ m := NewPurge(theme, targets)
74+ m.Sessions = sessions
75+ m.SessionsSize = sessionsSize
76+ return m
77+}
78+
63 // Init implements tea.Model.79 // Init implements tea.Model.
64 func (m PurgeModel) Init() tea.Cmd {80 func (m PurgeModel) Init() tea.Cmd {
65 _ = m81 _ = m
@@ -81,6 +97,8 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
81 m.toggleCurrent()97 m.toggleCurrent()
82 case key.Matches(msg, keys.ToggleAll):98 case key.Matches(msg, keys.ToggleAll):
83 m.toggleAll()99 m.toggleAll()
100+ case key.Matches(msg, keys.ToggleSessions):
101+ m.ToggleAllSessions()
84 case key.Matches(msg, keys.Confirm):102 case key.Matches(msg, keys.Confirm):
85 m.Confirmed = true103 m.Confirmed = true
86 case key.Matches(msg, keys.Cancel):104 case key.Matches(msg, keys.Cancel):
@@ -91,19 +109,78 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
91 return m, nil109 return m, nil
92 }110 }
93 111
112+// isDataSelected returns true if the Data target is selected.
113+func (m PurgeModel) isDataSelected() bool {
114+ for _, it := range m.Items {
115+ if it.Type == entity.PurgeTargetData && it.Selected {
116+ return true
117+ }
118+ }
119+ return false
120+}
121+
122+// SessionsEnabled returns true if sessions can be individually selected.
123+// Sessions are disabled when Data is selected (database will be removed).
124+func (m PurgeModel) SessionsEnabled() bool {
125+ return !m.isDataSelected()
126+}
127+
128+// totalSelectableCount returns the total number of selectable items across both sections.
129+func (m PurgeModel) totalSelectableCount() int {
130+ count := 0
131+ for _, it := range m.Items {
132+ if it.Exists {
133+ count++
134+ }
135+ }
136+ if m.SessionsEnabled() {
137+ count += len(m.Sessions)
138+ }
139+ return count
140+}
141+
94 func (m *PurgeModel) moveCursor(delta int) {142 func (m *PurgeModel) moveCursor(delta int) {
95- if len(m.Items) == 0 {143+ total := m.totalSelectableCount()
144+ if total == 0 {
96 return145 return
97 }146 }
98 147
99- idx := m.Cursor148+ // Build a flat list of selectable indices
100- for i := 0; i < len(m.Items); i++ {149+ // Items first, then sessions (if enabled)
101- idx = (idx + delta + len(m.Items)) % len(m.Items)150+ type position struct {
102- if m.Items[idx].Exists {151+ inSessions bool
103- m.Cursor = idx152+ index int
104- return153+ }
154+ var positions []position
155+
156+ for i, it := range m.Items {
157+ if it.Exists {
158+ positions = append(positions, position{inSessions: false, index: i})
105 }159 }
106 }160 }
161+ if m.SessionsEnabled() {
162+ for i := range m.Sessions {
163+ positions = append(positions, position{inSessions: true, index: i})
164+ }
165+ }
166+
167+ if len(positions) == 0 {
168+ return
169+ }
170+
171+ // Find current position in the flat list
172+ currentFlat := 0
173+ for i, p := range positions {
174+ if p.inSessions == m.inSessionsSection && p.index == m.Cursor {
175+ currentFlat = i
176+ break
177+ }
178+ }
179+
180+ // Move in the flat list
181+ newFlat := (currentFlat + delta + len(positions)) % len(positions)
182+ m.inSessionsSection = positions[newFlat].inSessions
183+ m.Cursor = positions[newFlat].index
107 }184 }
108 185
109 func (m PurgeModel) firstSelectableIndex() int {186 func (m PurgeModel) firstSelectableIndex() int {
@@ -116,6 +193,16 @@ func (m PurgeModel) firstSelectableIndex() int {
116 }193 }
117 194
118 func (m *PurgeModel) toggleCurrent() {195 func (m *PurgeModel) toggleCurrent() {
196+ if m.inSessionsSection {
197+ if !m.SessionsEnabled() {
198+ return
199+ }
200+ if m.Cursor >= 0 && m.Cursor < len(m.Sessions) {
201+ m.Sessions[m.Cursor].Selected = !m.Sessions[m.Cursor].Selected
202+ }
203+ return
204+ }
205+
119 if m.Cursor < 0 || m.Cursor >= len(m.Items) {206 if m.Cursor < 0 || m.Cursor >= len(m.Items) {
120 return207 return
121 }208 }
@@ -126,6 +213,7 @@ func (m *PurgeModel) toggleCurrent() {
126 }213 }
127 214
128 func (m *PurgeModel) toggleAll() {215 func (m *PurgeModel) toggleAll() {
216+ // Toggle all items
129 anyUnselected := false217 anyUnselected := false
130 for _, it := range m.Items {218 for _, it := range m.Items {
131 if it.Exists && !it.Selected {219 if it.Exists && !it.Selected {
@@ -133,6 +221,15 @@ func (m *PurgeModel) toggleAll() {
133 break221 break
134 }222 }
135 }223 }
224+ // Also check sessions if enabled
225+ if m.SessionsEnabled() {
226+ for _, s := range m.Sessions {
227+ if !s.Selected {
228+ anyUnselected = true
229+ break
230+ }
231+ }
232+ }
136 233
137 for i := range m.Items {234 for i := range m.Items {
138 if !m.Items[i].Exists {235 if !m.Items[i].Exists {
@@ -140,6 +237,30 @@ func (m *PurgeModel) toggleAll() {
140 }237 }
141 m.Items[i].Selected = anyUnselected238 m.Items[i].Selected = anyUnselected
142 }239 }
240+ if m.SessionsEnabled() {
241+ for i := range m.Sessions {
242+ m.Sessions[i].Selected = anyUnselected
243+ }
244+ }
245+}
246+
247+// ToggleAllSessions toggles the selection state of all sessions.
248+func (m *PurgeModel) ToggleAllSessions() {
249+ if !m.SessionsEnabled() || len(m.Sessions) == 0 {
250+ return
251+ }
252+
253+ anyUnselected := false
254+ for _, s := range m.Sessions {
255+ if !s.Selected {
256+ anyUnselected = true
257+ break
258+ }
259+ }
260+
261+ for i := range m.Sessions {
262+ m.Sessions[i].Selected = anyUnselected
263+ }
143 }264 }
144 265
145 // View implements tea.Model.266 // View implements tea.Model.
@@ -149,12 +270,33 @@ func (m PurgeModel) View() string {
149 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))270 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
150 subtitle := t.Subtle.Render("Select items to remove")271 subtitle := t.Subtle.Render("Select items to remove")
151 272
152- var rows []string273+ // Application Data section
274+ dataSectionHeader := t.Subtle.Render("── Application Data ──")
275+ var itemRows []string
153 for i, it := range m.Items {276 for i, it := range m.Items {
154- rows = append(rows, m.renderRow(i, it))277+ itemRows = append(itemRows, m.renderItemRow(i, it))
155 }278 }
279+ itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)
280+
281+ // Sessions section
282+ var sessionsList string
283+ if len(m.Sessions) > 0 {
284+ sessionsEnabled := m.SessionsEnabled()
285+ var sessionHeader string
286+ if sessionsEnabled {
287+ sessionHeader = t.Subtle.Render(fmt.Sprintf("── Inactive Sessions (%d total, %s) ──",
288+ len(m.Sessions), formatSize(m.SessionsSize)))
289+ } else {
290+ sessionHeader = t.Subtle.Render("── Inactive Sessions (database will be removed) ──")
291+ }
156 292
157- list := lipgloss.JoinVertical(lipgloss.Left, rows...)293+ var sessionRows []string
294+ sessionRows = append(sessionRows, sessionHeader)
295+ for i, s := range m.Sessions {
296+ sessionRows = append(sessionRows, m.renderSessionRow(i, s, sessionsEnabled))
297+ }
298+ sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...)
299+ }
158 300
159 selectedCount := m.SelectedCount()301 selectedCount := m.SelectedCount()
160 size := m.SelectedSize()302 size := m.SelectedSize()
@@ -172,28 +314,35 @@ func (m PurgeModel) View() string {
172 summary = t.Subtle.Render("0 selected")314 summary = t.Subtle.Render("0 selected")
173 }315 }
174 316
175- help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel")317+ helpText := "↑/↓ j/k move • space toggle • a all"
318+ if len(m.Sessions) > 0 && m.SessionsEnabled() {
319+ helpText += " • s sessions"
320+ }
321+ helpText += " • enter • esc"
322+ help := t.Subtle.Render(helpText)
176 323
177- content := lipgloss.JoinVertical(324+ contentParts := []string{
178- lipgloss.Left,
179 header,325 header,
180 subtitle,326 subtitle,
181 "",327 "",
182- list,328+ dataSectionHeader,
183- "",329+ itemsList,
184- summary,330+ }
185- "",331+ if sessionsList != "" {
186- help,332+ contentParts = append(contentParts, "", sessionsList)
187- )333+ }
334+ contentParts = append(contentParts, "", summary, "", help)
335+
336+ content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
188 337
189 return t.Box.Render(content)338 return t.Box.Render(content)
190 }339 }
191 340
192-func (m PurgeModel) renderRow(i int, it PurgeItem) string {341+func (m PurgeModel) renderItemRow(i int, it PurgeItem) string {
193 t := m.theme342 t := m.theme
194 343
195 cursor := " "344 cursor := " "
196- if i == m.Cursor {345+ if !m.inSessionsSection && i == m.Cursor {
197 cursor = IconCursor + " "346 cursor = IconCursor + " "
198 }347 }
199 348
@@ -242,6 +391,62 @@ func (m PurgeModel) renderRow(i int, it PurgeItem) string {
242 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)391 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
243 }392 }
244 393
394+func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled bool) string {
395+ t := m.theme
396+
397+ cursor := " "
398+ if m.inSessionsSection && i == m.Cursor && enabled {
399+ cursor = IconCursor + " "
400+ }
401+
402+ checkbox := IconCheckboxEmpty
403+ checkboxStyle := t.Subtle
404+ cursorStyle := t.Subtle
405+ labelStyle := t.Subtle
406+ detailStyle := t.Subtle
407+
408+ if enabled {
409+ if s.Selected {
410+ checkbox = IconCheckboxChecked
411+ }
412+ checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent)
413+ cursorStyle = lipgloss.NewStyle().Foreground(t.Accent)
414+ labelStyle = t.Normal
415+ detailStyle = t.Subtle
416+ }
417+
418+ // Format: ...abc1 2 tabs 3 panes 2d ago
419+ shortID := s.Info.Session.ShortID()
420+ tabLabel := "tab"
421+ if s.Info.TabCount != 1 {
422+ tabLabel = "tabs"
423+ }
424+ paneLabel := "pane"
425+ if s.Info.PaneCount != 1 {
426+ paneLabel = "panes"
427+ }
428+
429+ const idPadWidth = 8
430+ const tabPadWidth = 8
431+ const panePadWidth = 9
432+
433+ idPart := labelStyle.Render(padRight("..."+shortID, idPadWidth))
434+ tabPart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.TabCount, tabLabel), tabPadWidth))
435+ panePart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.PaneCount, paneLabel), panePadWidth))
436+ timePart := detailStyle.Render(getRelativeTime(s.Info.UpdatedAt))
437+
438+ return lipgloss.JoinHorizontal(
439+ lipgloss.Left,
440+ cursorStyle.Render(cursor),
441+ checkboxStyle.Render(checkbox),
442+ " ",
443+ idPart,
444+ tabPart,
445+ panePart,
446+ timePart,
447+ )
448+}
449+
245 func purgeLabel(t entity.PurgeTargetType) string {450 func purgeLabel(t entity.PurgeTargetType) string {
246 switch t {451 switch t {
247 case entity.PurgeTargetConfig:452 case entity.PurgeTargetConfig:
@@ -288,6 +493,45 @@ func formatSize(bytes int64) string {
288 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])493 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
289 }494 }
290 495
496+func getRelativeTime(t time.Time) string {
497+ now := time.Now()
498+ diff := now.Sub(t)
499+
500+ const (
501+ hoursPerDay = 24
502+ daysPerWeek = 7
503+ )
504+
505+ switch {
506+ case diff < time.Minute:
507+ return "just now"
508+ case diff < time.Hour:
509+ mins := int(diff.Minutes())
510+ if mins == 1 {
511+ return "1m ago"
512+ }
513+ return fmt.Sprintf("%dm ago", mins)
514+ case diff < hoursPerDay*time.Hour:
515+ hours := int(diff.Hours())
516+ if hours == 1 {
517+ return "1h ago"
518+ }
519+ return fmt.Sprintf("%dh ago", hours)
520+ case diff < daysPerWeek*hoursPerDay*time.Hour:
521+ days := int(diff.Hours() / hoursPerDay)
522+ if days == 1 {
523+ return "1d ago"
524+ }
525+ return fmt.Sprintf("%dd ago", days)
526+ default:
527+ weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
528+ if weeks == 1 {
529+ return "1w ago"
530+ }
531+ return fmt.Sprintf("%dw ago", weeks)
532+ }
533+}
534+
291 // Done returns true if the modal is complete.535 // Done returns true if the modal is complete.
292 func (m PurgeModel) Done() bool {536 func (m PurgeModel) Done() bool {
293 return m.Confirmed || m.Canceled537 return m.Confirmed || m.Canceled
@@ -309,7 +553,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
309 return out553 return out
310 }554 }
311 555
312-// SelectedCount returns the number of selected, existing items.556+// SelectedCount returns the number of selected items (targets + sessions).
313 func (m PurgeModel) SelectedCount() int {557 func (m PurgeModel) SelectedCount() int {
314 count := 0558 count := 0
315 for _, it := range m.Items {559 for _, it := range m.Items {
@@ -317,10 +561,19 @@ func (m PurgeModel) SelectedCount() int {
317 count++561 count++
318 }562 }
319 }563 }
564+ // Only count sessions if they're enabled (Data not selected)
565+ if m.SessionsEnabled() {
566+ for _, s := range m.Sessions {
567+ if s.Selected {
568+ count++
569+ }
570+ }
571+ }
320 return count572 return count
321 }573 }
322 574
323-// SelectedSize returns the total size of selected, existing items.575+// SelectedSize returns the total size of selected items.
576+// When Data is selected, includes SessionsSize since DB will be removed.
324 func (m PurgeModel) SelectedSize() int64 {577 func (m PurgeModel) SelectedSize() int64 {
325 var total int64578 var total int64
326 for _, it := range m.Items {579 for _, it := range m.Items {
@@ -328,5 +581,38 @@ func (m PurgeModel) SelectedSize() int64 {
328 total += it.Size581 total += it.Size
329 }582 }
330 }583 }
584+ // If Data is selected, sessions are implicitly included
585+ if m.isDataSelected() {
586+ total += m.SessionsSize
587+ } else {
588+ // Otherwise, count only selected sessions
589+ // Note: individual session sizes aren't tracked, only total
590+ // When sessions are selected, we include the total size proportionally
591+ selectedCount := 0
592+ for _, s := range m.Sessions {
593+ if s.Selected {
594+ selectedCount++
595+ }
596+ }
597+ if len(m.Sessions) > 0 && selectedCount > 0 {
598+ total += m.SessionsSize * int64(selectedCount) / int64(len(m.Sessions))
599+ }
600+ }
331 return total601 return total
332 }602 }
603+
604+// SelectedSessionIDs returns the IDs of selected sessions.
605+// Returns nil if Data is selected (sessions are deleted with the database).
606+func (m PurgeModel) SelectedSessionIDs() []entity.SessionID {
607+ if m.isDataSelected() {
608+ return nil
609+ }
610+
611+ var ids []entity.SessionID
612+ for _, s := range m.Sessions {
613+ if s.Selected {
614+ ids = append(ids, s.Info.Session.ID)
615+ }
616+ }
617+ return ids
618+}
A internal/cli/styles/purge_test.go +240 -0
@@ -0,0 +1,240 @@
1+package styles_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/cli/styles"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func testTheme() *styles.Theme {
15+ return styles.NewTheme(config.DefaultConfig())
16+}
17+
18+func TestPurgeModel_SessionsDisabledWhenDataSelected(t *testing.T) {
19+ theme := testTheme()
20+
21+ targets := []entity.PurgeTarget{
22+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
23+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000},
24+ }
25+
26+ endedAt := time.Now().Add(-time.Hour)
27+ sessions := []entity.SessionPurgeItem{
28+ {
29+ Info: entity.SessionInfo{
30+ Session: &entity.Session{
31+ ID: "session1",
32+ Type: entity.SessionTypeBrowser,
33+ StartedAt: time.Now().Add(-2 * time.Hour),
34+ EndedAt: &endedAt,
35+ },
36+ TabCount: 2,
37+ PaneCount: 3,
38+ },
39+ Selected: true,
40+ },
41+ }
42+
43+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
44+
45+ // Initially Data is selected (default for existing items)
46+ assert.False(t, m.SessionsEnabled(), "sessions should be disabled when Data is selected")
47+
48+ // Deselect Data
49+ m.Items[1].Selected = false
50+ assert.True(t, m.SessionsEnabled(), "sessions should be enabled when Data is not selected")
51+}
52+
53+func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) {
54+ theme := testTheme()
55+
56+ targets := []entity.PurgeTarget{
57+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000},
58+ }
59+
60+ endedAt := time.Now().Add(-time.Hour)
61+ sessions := []entity.SessionPurgeItem{
62+ {
63+ Info: entity.SessionInfo{
64+ Session: &entity.Session{
65+ ID: "session1",
66+ Type: entity.SessionTypeBrowser,
67+ StartedAt: time.Now().Add(-2 * time.Hour),
68+ EndedAt: &endedAt,
69+ },
70+ },
71+ Selected: true,
72+ },
73+ }
74+
75+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
76+
77+ // Data is selected, so sessions should return nil
78+ ids := m.SelectedSessionIDs()
79+ assert.Nil(t, ids, "should return nil when Data is selected")
80+
81+ // Deselect Data
82+ m.Items[0].Selected = false
83+ ids = m.SelectedSessionIDs()
84+ require.Len(t, ids, 1)
85+ assert.Equal(t, entity.SessionID("session1"), ids[0])
86+}
87+
88+func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) {
89+ theme := testTheme()
90+
91+ targets := []entity.PurgeTarget{
92+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
93+ {Type: entity.PurgeTargetCache, Path: "/cache", Exists: true, Size: 200},
94+ }
95+
96+ endedAt := time.Now().Add(-time.Hour)
97+ sessions := []entity.SessionPurgeItem{
98+ {
99+ Info: entity.SessionInfo{
100+ Session: &entity.Session{
101+ ID: "session1",
102+ Type: entity.SessionTypeBrowser,
103+ StartedAt: time.Now().Add(-2 * time.Hour),
104+ EndedAt: &endedAt,
105+ },
106+ },
107+ Selected: true,
108+ },
109+ {
110+ Info: entity.SessionInfo{
111+ Session: &entity.Session{
112+ ID: "session2",
113+ Type: entity.SessionTypeBrowser,
114+ StartedAt: time.Now().Add(-3 * time.Hour),
115+ EndedAt: &endedAt,
116+ },
117+ },
118+ Selected: false, // Not selected
119+ },
120+ }
121+
122+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
123+
124+ // 2 targets + 1 selected session = 3
125+ assert.Equal(t, 3, m.SelectedCount())
126+}
127+
128+func TestPurgeModel_ToggleAllSessions(t *testing.T) {
129+ theme := testTheme()
130+
131+ targets := []entity.PurgeTarget{
132+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
133+ }
134+
135+ endedAt := time.Now().Add(-time.Hour)
136+ sessions := []entity.SessionPurgeItem{
137+ {
138+ Info: entity.SessionInfo{
139+ Session: &entity.Session{
140+ ID: "session1",
141+ Type: entity.SessionTypeBrowser,
142+ StartedAt: time.Now().Add(-2 * time.Hour),
143+ EndedAt: &endedAt,
144+ },
145+ },
146+ Selected: true,
147+ },
148+ {
149+ Info: entity.SessionInfo{
150+ Session: &entity.Session{
151+ ID: "session2",
152+ Type: entity.SessionTypeBrowser,
153+ StartedAt: time.Now().Add(-3 * time.Hour),
154+ EndedAt: &endedAt,
155+ },
156+ },
157+ Selected: true,
158+ },
159+ }
160+
161+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
162+
163+ // All sessions are selected, toggle should deselect all
164+ m.ToggleAllSessions()
165+ assert.False(t, m.Sessions[0].Selected)
166+ assert.False(t, m.Sessions[1].Selected)
167+
168+ // Now toggle again to select all
169+ m.ToggleAllSessions()
170+ assert.True(t, m.Sessions[0].Selected)
171+ assert.True(t, m.Sessions[1].Selected)
172+}
173+
174+func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {
175+ theme := testTheme()
176+
177+ targets := []entity.PurgeTarget{
178+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
179+ }
180+
181+ endedAt := time.Now().Add(-time.Hour)
182+ sessions := []entity.SessionPurgeItem{
183+ {
184+ Info: entity.SessionInfo{
185+ Session: &entity.Session{
186+ ID: "session1",
187+ Type: entity.SessionTypeBrowser,
188+ StartedAt: time.Now().Add(-2 * time.Hour),
189+ EndedAt: &endedAt,
190+ },
191+ },
192+ Selected: true,
193+ },
194+ {
195+ Info: entity.SessionInfo{
196+ Session: &entity.Session{
197+ ID: "session2",
198+ Type: entity.SessionTypeBrowser,
199+ StartedAt: time.Now().Add(-3 * time.Hour),
200+ EndedAt: &endedAt,
201+ },
202+ },
203+ Selected: false, // Not selected
204+ },
205+ }
206+
207+ // Total sessions size is 1000, 1 of 2 sessions selected = 500
208+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
209+
210+ // 100 (config) + 500 (1/2 of sessions) = 600
211+ assert.Equal(t, int64(600), m.SelectedSize())
212+}
213+
214+func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) {
215+ theme := testTheme()
216+
217+ targets := []entity.PurgeTarget{
218+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 100},
219+ }
220+
221+ endedAt := time.Now().Add(-time.Hour)
222+ sessions := []entity.SessionPurgeItem{
223+ {
224+ Info: entity.SessionInfo{
225+ Session: &entity.Session{
226+ ID: "session1",
227+ Type: entity.SessionTypeBrowser,
228+ StartedAt: time.Now().Add(-2 * time.Hour),
229+ EndedAt: &endedAt,
230+ },
231+ },
232+ Selected: false, // Not individually selected, but Data is selected
233+ },
234+ }
235+
236+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
237+
238+ // 100 (data) + 1000 (all sessions because DB is being deleted) = 1100
239+ assert.Equal(t, int64(1100), m.SelectedSize())
240+}
@@ -0,0 +1,240 @@
1+package styles_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/cli/styles"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func testTheme() *styles.Theme {
15+ return styles.NewTheme(config.DefaultConfig())
16+}
17+
18+func TestPurgeModel_SessionsDisabledWhenDataSelected(t *testing.T) {
19+ theme := testTheme()
20+
21+ targets := []entity.PurgeTarget{
22+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
23+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000},
24+ }
25+
26+ endedAt := time.Now().Add(-time.Hour)
27+ sessions := []entity.SessionPurgeItem{
28+ {
29+ Info: entity.SessionInfo{
30+ Session: &entity.Session{
31+ ID: "session1",
32+ Type: entity.SessionTypeBrowser,
33+ StartedAt: time.Now().Add(-2 * time.Hour),
34+ EndedAt: &endedAt,
35+ },
36+ TabCount: 2,
37+ PaneCount: 3,
38+ },
39+ Selected: true,
40+ },
41+ }
42+
43+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
44+
45+ // Initially Data is selected (default for existing items)
46+ assert.False(t, m.SessionsEnabled(), "sessions should be disabled when Data is selected")
47+
48+ // Deselect Data
49+ m.Items[1].Selected = false
50+ assert.True(t, m.SessionsEnabled(), "sessions should be enabled when Data is not selected")
51+}
52+
53+func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) {
54+ theme := testTheme()
55+
56+ targets := []entity.PurgeTarget{
57+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000},
58+ }
59+
60+ endedAt := time.Now().Add(-time.Hour)
61+ sessions := []entity.SessionPurgeItem{
62+ {
63+ Info: entity.SessionInfo{
64+ Session: &entity.Session{
65+ ID: "session1",
66+ Type: entity.SessionTypeBrowser,
67+ StartedAt: time.Now().Add(-2 * time.Hour),
68+ EndedAt: &endedAt,
69+ },
70+ },
71+ Selected: true,
72+ },
73+ }
74+
75+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
76+
77+ // Data is selected, so sessions should return nil
78+ ids := m.SelectedSessionIDs()
79+ assert.Nil(t, ids, "should return nil when Data is selected")
80+
81+ // Deselect Data
82+ m.Items[0].Selected = false
83+ ids = m.SelectedSessionIDs()
84+ require.Len(t, ids, 1)
85+ assert.Equal(t, entity.SessionID("session1"), ids[0])
86+}
87+
88+func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) {
89+ theme := testTheme()
90+
91+ targets := []entity.PurgeTarget{
92+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
93+ {Type: entity.PurgeTargetCache, Path: "/cache", Exists: true, Size: 200},
94+ }
95+
96+ endedAt := time.Now().Add(-time.Hour)
97+ sessions := []entity.SessionPurgeItem{
98+ {
99+ Info: entity.SessionInfo{
100+ Session: &entity.Session{
101+ ID: "session1",
102+ Type: entity.SessionTypeBrowser,
103+ StartedAt: time.Now().Add(-2 * time.Hour),
104+ EndedAt: &endedAt,
105+ },
106+ },
107+ Selected: true,
108+ },
109+ {
110+ Info: entity.SessionInfo{
111+ Session: &entity.Session{
112+ ID: "session2",
113+ Type: entity.SessionTypeBrowser,
114+ StartedAt: time.Now().Add(-3 * time.Hour),
115+ EndedAt: &endedAt,
116+ },
117+ },
118+ Selected: false, // Not selected
119+ },
120+ }
121+
122+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
123+
124+ // 2 targets + 1 selected session = 3
125+ assert.Equal(t, 3, m.SelectedCount())
126+}
127+
128+func TestPurgeModel_ToggleAllSessions(t *testing.T) {
129+ theme := testTheme()
130+
131+ targets := []entity.PurgeTarget{
132+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
133+ }
134+
135+ endedAt := time.Now().Add(-time.Hour)
136+ sessions := []entity.SessionPurgeItem{
137+ {
138+ Info: entity.SessionInfo{
139+ Session: &entity.Session{
140+ ID: "session1",
141+ Type: entity.SessionTypeBrowser,
142+ StartedAt: time.Now().Add(-2 * time.Hour),
143+ EndedAt: &endedAt,
144+ },
145+ },
146+ Selected: true,
147+ },
148+ {
149+ Info: entity.SessionInfo{
150+ Session: &entity.Session{
151+ ID: "session2",
152+ Type: entity.SessionTypeBrowser,
153+ StartedAt: time.Now().Add(-3 * time.Hour),
154+ EndedAt: &endedAt,
155+ },
156+ },
157+ Selected: true,
158+ },
159+ }
160+
161+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
162+
163+ // All sessions are selected, toggle should deselect all
164+ m.ToggleAllSessions()
165+ assert.False(t, m.Sessions[0].Selected)
166+ assert.False(t, m.Sessions[1].Selected)
167+
168+ // Now toggle again to select all
169+ m.ToggleAllSessions()
170+ assert.True(t, m.Sessions[0].Selected)
171+ assert.True(t, m.Sessions[1].Selected)
172+}
173+
174+func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {
175+ theme := testTheme()
176+
177+ targets := []entity.PurgeTarget{
178+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
179+ }
180+
181+ endedAt := time.Now().Add(-time.Hour)
182+ sessions := []entity.SessionPurgeItem{
183+ {
184+ Info: entity.SessionInfo{
185+ Session: &entity.Session{
186+ ID: "session1",
187+ Type: entity.SessionTypeBrowser,
188+ StartedAt: time.Now().Add(-2 * time.Hour),
189+ EndedAt: &endedAt,
190+ },
191+ },
192+ Selected: true,
193+ },
194+ {
195+ Info: entity.SessionInfo{
196+ Session: &entity.Session{
197+ ID: "session2",
198+ Type: entity.SessionTypeBrowser,
199+ StartedAt: time.Now().Add(-3 * time.Hour),
200+ EndedAt: &endedAt,
201+ },
202+ },
203+ Selected: false, // Not selected
204+ },
205+ }
206+
207+ // Total sessions size is 1000, 1 of 2 sessions selected = 500
208+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
209+
210+ // 100 (config) + 500 (1/2 of sessions) = 600
211+ assert.Equal(t, int64(600), m.SelectedSize())
212+}
213+
214+func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) {
215+ theme := testTheme()
216+
217+ targets := []entity.PurgeTarget{
218+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 100},
219+ }
220+
221+ endedAt := time.Now().Add(-time.Hour)
222+ sessions := []entity.SessionPurgeItem{
223+ {
224+ Info: entity.SessionInfo{
225+ Session: &entity.Session{
226+ ID: "session1",
227+ Type: entity.SessionTypeBrowser,
228+ StartedAt: time.Now().Add(-2 * time.Hour),
229+ EndedAt: &endedAt,
230+ },
231+ },
232+ Selected: false, // Not individually selected, but Data is selected
233+ },
234+ }
235+
236+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
237+
238+ // 100 (data) + 1000 (all sessions because DB is being deleted) = 1100
239+ assert.Equal(t, int64(1100), m.SelectedSize())
240+}

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