detached e056b3c

refactor(cli): simplify purge UI with single sessions toggle

- Replace individual session selection with 'Sessions' toggle item
- Show sessions below Data item, greyed out when Data is selected
- Remove redundant max_listed_sessions config (use max_exited_sessions)
- Update tests for new all-or-nothing session selection

Closes #27
commit
e056b3c58e16aa3660f343f99b766af2b3d916ab
author
bnema · · 2025-12-25 18:18 UTC
parent
d7d6f84
signature
GPG Unverified2B82207D370127A0 The key is unknown
4 files · +166 -255
M internal/cli/cmd/sessions.go +1 -1
@@ -61,7 +61,7 @@ func runSessions(_ *cobra.Command, _ []string) error {
6161 RestoreUC: app.RestoreUC,
6262 DeleteSessionUC: app.DeleteSessionUC,
6363 CurrentSession: currentSessionID,
64- MaxListedSessions: app.Config.Session.MaxListedSessions,
64+ MaxExitedSessions: app.Config.Session.MaxExitedSessions,
6565 })
6666
6767 p := tea.NewProgram(m, tea.WithAltScreen())
@@ -61,7 +61,7 @@ func runSessions(_ *cobra.Command, _ []string) error {
61 RestoreUC: app.RestoreUC,61 RestoreUC: app.RestoreUC,
62 DeleteSessionUC: app.DeleteSessionUC,62 DeleteSessionUC: app.DeleteSessionUC,
63 CurrentSession: currentSessionID,63 CurrentSession: currentSessionID,
64- MaxListedSessions: app.Config.Session.MaxListedSessions,64+ MaxExitedSessions: app.Config.Session.MaxExitedSessions,
65 })65 })
66 66
67 p := tea.NewProgram(m, tea.WithAltScreen())67 p := tea.NewProgram(m, tea.WithAltScreen())
M internal/cli/model/sessions.go +2 -2
@@ -115,12 +115,12 @@ type SessionsModelConfig struct {
115115 RestoreUC *usecase.RestoreSessionUseCase
116116 DeleteSessionUC *usecase.DeleteSessionUseCase
117117 CurrentSession entity.SessionID
118- MaxListedSessions int
118+ MaxExitedSessions int
119119 }
120120
121121 // NewSessionsModel creates a new sessions browser model.
122122 func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {
123- maxListed := cfg.MaxListedSessions
123+ maxListed := cfg.MaxExitedSessions
124124 if maxListed <= 0 {
125125 maxListed = 50 // fallback default
126126 }
@@ -115,12 +115,12 @@ type SessionsModelConfig struct {
115 RestoreUC *usecase.RestoreSessionUseCase115 RestoreUC *usecase.RestoreSessionUseCase
116 DeleteSessionUC *usecase.DeleteSessionUseCase116 DeleteSessionUC *usecase.DeleteSessionUseCase
117 CurrentSession entity.SessionID117 CurrentSession entity.SessionID
118- MaxListedSessions int118+ MaxExitedSessions int
119 }119 }
120 120
121 // NewSessionsModel creates a new sessions browser model.121 // NewSessionsModel creates a new sessions browser model.
122 func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {122 func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {
123- maxListed := cfg.MaxListedSessions123+ maxListed := cfg.MaxExitedSessions
124 if maxListed <= 0 {124 if maxListed <= 0 {
125 maxListed = 50 // fallback default125 maxListed = 50 // fallback default
126 }126 }
M internal/cli/styles/purge.go +136 -232
@@ -3,7 +3,6 @@ package styles
33 import (
44 "fmt"
55 "strings"
6- "time"
76
87 "github.com/charmbracelet/bubbles/key"
98 tea "github.com/charmbracelet/bubbletea"
@@ -18,44 +17,71 @@ type PurgeItem struct {
1817 Selected bool
1918 }
2019
21-// PurgeModel is the multi-select purge modal with sessions support.
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.
2222 type PurgeModel struct {
2323 Items []PurgeItem
2424 Sessions []entity.SessionPurgeItem
2525 SessionsSize int64 // Total size of all session snapshots
26- Cursor int
26+ Cursor int // Flat cursor index across all rows
2727 Confirmed bool
2828 Canceled bool
2929 theme *Theme
3030
31- // Track cursor position: false = items section, true = sessions section
32- inSessionsSection bool
31+ // allSessionsSelected indicates if all sessions should be purged
32+ allSessionsSelected bool
3333 }
3434
3535 // PurgeKeyMap defines keybindings for purge modal.
3636 type PurgeKeyMap struct {
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
37+ Up key.Binding
38+ Down key.Binding
39+ Toggle key.Binding
40+ ToggleAll key.Binding
41+ Confirm key.Binding
42+ Cancel key.Binding
4443 }
4544
4645 // DefaultPurgeKeyMap returns default keybindings.
4746 func DefaultPurgeKeyMap() PurgeKeyMap {
4847 return PurgeKeyMap{
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")),
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")),
5654 }
5755 }
5856
57+// rowType indicates what kind of row we're dealing with
58+type rowType int
59+
60+const (
61+ rowTypeItem rowType = iota
62+ rowTypeSessions
63+)
64+
65+// row represents a single row in the flat list
66+type 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
73+func (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+
5985 // NewPurge creates a new purge modal with the given targets.
6086 func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
6187 items := make([]PurgeItem, 0, len(targets))
@@ -97,8 +123,6 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
97123 m.toggleCurrent()
98124 case key.Matches(msg, keys.ToggleAll):
99125 m.toggleAll()
100- case key.Matches(msg, keys.ToggleSessions):
101- m.ToggleAllSessions()
102126 case key.Matches(msg, keys.Confirm):
103127 m.Confirmed = true
104128 case key.Matches(msg, keys.Cancel):
@@ -119,73 +143,53 @@ func (m PurgeModel) isDataSelected() bool {
119143 return false
120144 }
121145
122-// SessionsEnabled returns true if sessions can be individually selected.
146+// SessionsEnabled returns true if sessions can be selected.
123147 // Sessions are disabled when Data is selected (database will be removed).
124148 func (m PurgeModel) SessionsEnabled() bool {
125149 return !m.isDataSelected()
126150 }
127151
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
152+// hasSessionsItem returns true if there are sessions available to purge.
153+func (m PurgeModel) hasSessionsItem() bool {
154+ return len(m.Sessions) > 0
140155 }
141156
142157 func (m *PurgeModel) moveCursor(delta int) {
143- total := m.totalSelectableCount()
144- if total == 0 {
158+ rows := m.buildRows()
159+ if len(rows) == 0 {
145160 return
146161 }
147162
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})
159- }
160- }
161- if m.SessionsEnabled() {
162- for i := range m.Sessions {
163- positions = append(positions, position{inSessions: true, index: i})
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)
164168 }
165169 }
166170
167- if len(positions) == 0 {
171+ if len(selectableIndices) == 0 {
168172 return
169173 }
170174
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
175+ // Find current position in selectable list
176+ currentPos := 0
177+ for i, idx := range selectableIndices {
178+ if idx == m.Cursor {
179+ currentPos = i
176180 break
177181 }
178182 }
179183
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
184+ // Move
185+ newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices)
186+ m.Cursor = selectableIndices[newPos]
184187 }
185188
186189 func (m PurgeModel) firstSelectableIndex() int {
187- for i, it := range m.Items {
188- if it.Exists {
190+ rows := m.buildRows()
191+ for i, r := range rows {
192+ if r.Exists {
189193 return i
190194 }
191195 }
@@ -193,27 +197,26 @@ func (m PurgeModel) firstSelectableIndex() int {
193197 }
194198
195199 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- }
200+ rows := m.buildRows()
201+ if m.Cursor < 0 || m.Cursor >= len(rows) {
203202 return
204203 }
205204
206- if m.Cursor < 0 || m.Cursor >= len(m.Items) {
205+ r := rows[m.Cursor]
206+ if !r.Exists {
207207 return
208208 }
209- if !m.Items[m.Cursor].Exists {
210- return
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
211215 }
212- m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected
213216 }
214217
215218 func (m *PurgeModel) toggleAll() {
216- // Toggle all items
219+ // Check if any selectable item is unselected
217220 anyUnselected := false
218221 for _, it := range m.Items {
219222 if it.Exists && !it.Selected {
@@ -221,46 +224,27 @@ func (m *PurgeModel) toggleAll() {
221224 break
222225 }
223226 }
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- }
227+ if m.hasSessionsItem() && m.SessionsEnabled() && !m.allSessionsSelected {
228+ anyUnselected = true
232229 }
233230
231+ // Set all to the opposite state
234232 for i := range m.Items {
235- if !m.Items[i].Exists {
236- continue
233+ if m.Items[i].Exists {
234+ m.Items[i].Selected = anyUnselected
237235 }
238- m.Items[i].Selected = anyUnselected
239236 }
240- if m.SessionsEnabled() {
241- for i := range m.Sessions {
242- m.Sessions[i].Selected = anyUnselected
243- }
237+ if m.hasSessionsItem() && m.SessionsEnabled() {
238+ m.allSessionsSelected = anyUnselected
244239 }
245240 }
246241
247242 // ToggleAllSessions toggles the selection state of all sessions.
248243 func (m *PurgeModel) ToggleAllSessions() {
249- if !m.SessionsEnabled() || len(m.Sessions) == 0 {
244+ if !m.hasSessionsItem() || !m.SessionsEnabled() {
250245 return
251246 }
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- }
247+ m.allSessionsSelected = !m.allSessionsSelected
264248 }
265249
266250 // View implements tea.Model.
@@ -270,34 +254,21 @@ func (m PurgeModel) View() string {
270254 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
271255 subtitle := t.Subtle.Render("Select items to remove")
272256
273- // Application Data section
274- dataSectionHeader := t.Subtle.Render("── Application Data ──")
257+ // Build rows and render
258+ rows := m.buildRows()
275259 var itemRows []string
276- for i, it := range m.Items {
277- itemRows = append(itemRows, m.renderItemRow(i, it))
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- }
292-
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))
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))
297267 }
298- sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...)
299268 }
300269
270+ itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)
271+
301272 selectedCount := m.SelectedCount()
302273 size := m.SelectedSize()
303274 var summary string
@@ -314,35 +285,30 @@ func (m PurgeModel) View() string {
314285 summary = t.Subtle.Render("0 selected")
315286 }
316287
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"
288+ helpText := "↑/↓ j/k move • space toggle • a all • enter • esc"
322289 help := t.Subtle.Render(helpText)
323290
324291 contentParts := []string{
325292 header,
326293 subtitle,
327294 "",
328- dataSectionHeader,
329295 itemsList,
296+ "",
297+ summary,
298+ "",
299+ help,
330300 }
331- if sessionsList != "" {
332- contentParts = append(contentParts, "", sessionsList)
333- }
334- contentParts = append(contentParts, "", summary, "", help)
335301
336302 content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
337303
338304 return t.Box.Render(content)
339305 }
340306
341-func (m PurgeModel) renderItemRow(i int, it PurgeItem) string {
307+func (m PurgeModel) renderItemRow(_ int, it PurgeItem, isCursor bool) string {
342308 t := m.theme
343309
344310 cursor := " "
345- if !m.inSessionsSection && i == m.Cursor {
311+ if isCursor && it.Exists {
346312 cursor = IconCursor + " "
347313 }
348314
@@ -391,11 +357,13 @@ func (m PurgeModel) renderItemRow(i int, it PurgeItem) string {
391357 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
392358 }
393359
394-func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled bool) string {
360+// renderSessionsItem renders the single "Sessions" item.
361+func (m PurgeModel) renderSessionsItem(isCursor bool) string {
395362 t := m.theme
363+ enabled := m.SessionsEnabled()
396364
397365 cursor := " "
398- if m.inSessionsSection && i == m.Cursor && enabled {
366+ if isCursor && enabled {
399367 cursor = IconCursor + " "
400368 }
401369
@@ -406,44 +374,31 @@ func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled b
406374 detailStyle := t.Subtle
407375
408376 if enabled {
409- if s.Selected {
377+ if m.allSessionsSelected {
410378 checkbox = IconCheckboxChecked
411379 }
412380 checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent)
413381 cursorStyle = lipgloss.NewStyle().Foreground(t.Accent)
414382 labelStyle = t.Normal
415- detailStyle = t.Subtle
416383 }
417384
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"
385+ // Format: Sessions (5 inactive, 12.3 MB) or (included with Data)
386+ label := fmt.Sprintf("%s Sessions", IconLogs)
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))
427392 }
428393
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-
394+ const labelPadWidth = 12
438395 return lipgloss.JoinHorizontal(
439396 lipgloss.Left,
440397 cursorStyle.Render(cursor),
441398 checkboxStyle.Render(checkbox),
442399 " ",
443- idPart,
444- tabPart,
445- panePart,
446- timePart,
400+ labelStyle.Render(padRight(label, labelPadWidth)),
401+ detailStyle.Render(detail),
447402 )
448403 }
449404
@@ -493,45 +448,6 @@ func formatSize(bytes int64) string {
493448 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
494449 }
495450
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-
535451 // Done returns true if the modal is complete.
536452 func (m PurgeModel) Done() bool {
537453 return m.Confirmed || m.Canceled
@@ -553,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
553469 return out
554470 }
555471
556-// SelectedCount returns the number of selected items (targets + sessions).
472+// SelectedCount returns the number of selected items (targets + sessions as 1 if selected).
557473 func (m PurgeModel) SelectedCount() int {
558474 count := 0
559475 for _, it := range m.Items {
@@ -561,13 +477,9 @@ func (m PurgeModel) SelectedCount() int {
561477 count++
562478 }
563479 }
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- }
480+ // Count sessions as 1 item if selected (and enabled)
481+ if m.hasSessionsItem() && m.SessionsEnabled() && m.allSessionsSelected {
482+ count++
571483 }
572484 return count
573485 }
@@ -584,35 +496,27 @@ func (m PurgeModel) SelectedSize() int64 {
584496 // If Data is selected, sessions are implicitly included
585497 if m.isDataSelected() {
586498 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- }
499+ } else if m.allSessionsSelected {
500+ // Sessions are explicitly selected
501+ total += m.SessionsSize
600502 }
601503 return total
602504 }
603505
604-// SelectedSessionIDs returns the IDs of selected sessions.
605-// Returns nil if Data is selected (sessions are deleted with the database).
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.
606509 func (m PurgeModel) SelectedSessionIDs() []entity.SessionID {
607510 if m.isDataSelected() {
608511 return nil
609512 }
513+ if !m.allSessionsSelected {
514+ return nil
515+ }
610516
611- var ids []entity.SessionID
517+ ids := make([]entity.SessionID, 0, len(m.Sessions))
612518 for _, s := range m.Sessions {
613- if s.Selected {
614- ids = append(ids, s.Info.Session.ID)
615- }
519+ ids = append(ids, s.Info.Session.ID)
616520 }
617521 return ids
618522 }
@@ -3,7 +3,6 @@ package styles
3 import (3 import (
4 "fmt"4 "fmt"
5 "strings"5 "strings"
6- "time"
7 6
8 "github.com/charmbracelet/bubbles/key"7 "github.com/charmbracelet/bubbles/key"
9 tea "github.com/charmbracelet/bubbletea"8 tea "github.com/charmbracelet/bubbletea"
@@ -18,44 +17,71 @@ type PurgeItem struct {
18 Selected bool17 Selected bool
19 }18 }
20 19
21-// PurgeModel is the multi-select purge modal with sessions support.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.
22 type PurgeModel struct {22 type PurgeModel struct {
23 Items []PurgeItem23 Items []PurgeItem
24 Sessions []entity.SessionPurgeItem24 Sessions []entity.SessionPurgeItem
25 SessionsSize int64 // Total size of all session snapshots25 SessionsSize int64 // Total size of all session snapshots
26- Cursor int26+ Cursor int // Flat cursor index across all rows
27 Confirmed bool27 Confirmed bool
28 Canceled bool28 Canceled bool
29 theme *Theme29 theme *Theme
30 30
31- // Track cursor position: false = items section, true = sessions section31+ // allSessionsSelected indicates if all sessions should be purged
32- inSessionsSection bool32+ allSessionsSelected bool
33 }33 }
34 34
35 // PurgeKeyMap defines keybindings for purge modal.35 // PurgeKeyMap defines keybindings for purge modal.
36 type PurgeKeyMap struct {36 type PurgeKeyMap struct {
37- Up key.Binding37+ Up key.Binding
38- Down key.Binding38+ Down key.Binding
39- Toggle key.Binding39+ Toggle key.Binding
40- ToggleAll key.Binding40+ ToggleAll key.Binding
41- ToggleSessions key.Binding41+ Confirm key.Binding
42- Confirm key.Binding42+ Cancel key.Binding
43- Cancel key.Binding
44 }43 }
45 44
46 // DefaultPurgeKeyMap returns default keybindings.45 // DefaultPurgeKeyMap returns default keybindings.
47 func DefaultPurgeKeyMap() PurgeKeyMap {46 func DefaultPurgeKeyMap() PurgeKeyMap {
48 return PurgeKeyMap{47 return PurgeKeyMap{
49- Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),48+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
50- Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),49+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
51- Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),50+ Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
52- ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),51+ ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),
53- ToggleSessions: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "toggle sessions")),52+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
54- Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),53+ Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
55- Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
56 }54 }
57 }55 }
58 56
57+// rowType indicates what kind of row we're dealing with
58+type rowType int
59+
60+const (
61+ rowTypeItem rowType = iota
62+ rowTypeSessions
63+)
64+
65+// row represents a single row in the flat list
66+type 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
73+func (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+
59 // NewPurge creates a new purge modal with the given targets.85 // NewPurge creates a new purge modal with the given targets.
60 func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {86 func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
61 items := make([]PurgeItem, 0, len(targets))87 items := make([]PurgeItem, 0, len(targets))
@@ -97,8 +123,6 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
97 m.toggleCurrent()123 m.toggleCurrent()
98 case key.Matches(msg, keys.ToggleAll):124 case key.Matches(msg, keys.ToggleAll):
99 m.toggleAll()125 m.toggleAll()
100- case key.Matches(msg, keys.ToggleSessions):
101- m.ToggleAllSessions()
102 case key.Matches(msg, keys.Confirm):126 case key.Matches(msg, keys.Confirm):
103 m.Confirmed = true127 m.Confirmed = true
104 case key.Matches(msg, keys.Cancel):128 case key.Matches(msg, keys.Cancel):
@@ -119,73 +143,53 @@ func (m PurgeModel) isDataSelected() bool {
119 return false143 return false
120 }144 }
121 145
122-// SessionsEnabled returns true if sessions can be individually selected.146+// SessionsEnabled returns true if sessions can be selected.
123 // Sessions are disabled when Data is selected (database will be removed).147 // Sessions are disabled when Data is selected (database will be removed).
124 func (m PurgeModel) SessionsEnabled() bool {148 func (m PurgeModel) SessionsEnabled() bool {
125 return !m.isDataSelected()149 return !m.isDataSelected()
126 }150 }
127 151
128-// totalSelectableCount returns the total number of selectable items across both sections.152+// hasSessionsItem returns true if there are sessions available to purge.
129-func (m PurgeModel) totalSelectableCount() int {153+func (m PurgeModel) hasSessionsItem() bool {
130- count := 0154+ return len(m.Sessions) > 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 }155 }
141 156
142 func (m *PurgeModel) moveCursor(delta int) {157 func (m *PurgeModel) moveCursor(delta int) {
143- total := m.totalSelectableCount()158+ rows := m.buildRows()
144- if total == 0 {159+ if len(rows) == 0 {
145 return160 return
146 }161 }
147 162
148- // Build a flat list of selectable indices163+ // Build list of selectable row indices
149- // Items first, then sessions (if enabled)164+ var selectableIndices []int
150- type position struct {165+ for i, r := range rows {
151- inSessions bool166+ if r.Exists {
152- index int167+ selectableIndices = append(selectableIndices, i)
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})
159- }
160- }
161- if m.SessionsEnabled() {
162- for i := range m.Sessions {
163- positions = append(positions, position{inSessions: true, index: i})
164 }168 }
165 }169 }
166 170
167- if len(positions) == 0 {171+ if len(selectableIndices) == 0 {
168 return172 return
169 }173 }
170 174
171- // Find current position in the flat list175+ // Find current position in selectable list
172- currentFlat := 0176+ currentPos := 0
173- for i, p := range positions {177+ for i, idx := range selectableIndices {
174- if p.inSessions == m.inSessionsSection && p.index == m.Cursor {178+ if idx == m.Cursor {
175- currentFlat = i179+ currentPos = i
176 break180 break
177 }181 }
178 }182 }
179 183
180- // Move in the flat list184+ // Move
181- newFlat := (currentFlat + delta + len(positions)) % len(positions)185+ newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices)
182- m.inSessionsSection = positions[newFlat].inSessions186+ m.Cursor = selectableIndices[newPos]
183- m.Cursor = positions[newFlat].index
184 }187 }
185 188
186 func (m PurgeModel) firstSelectableIndex() int {189 func (m PurgeModel) firstSelectableIndex() int {
187- for i, it := range m.Items {190+ rows := m.buildRows()
188- if it.Exists {191+ for i, r := range rows {
192+ if r.Exists {
189 return i193 return i
190 }194 }
191 }195 }
@@ -193,27 +197,26 @@ func (m PurgeModel) firstSelectableIndex() int {
193 }197 }
194 198
195 func (m *PurgeModel) toggleCurrent() {199 func (m *PurgeModel) toggleCurrent() {
196- if m.inSessionsSection {200+ rows := m.buildRows()
197- if !m.SessionsEnabled() {201+ if m.Cursor < 0 || m.Cursor >= len(rows) {
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 return202 return
204 }203 }
205 204
206- if m.Cursor < 0 || m.Cursor >= len(m.Items) {205+ r := rows[m.Cursor]
206+ if !r.Exists {
207 return207 return
208 }208 }
209- if !m.Items[m.Cursor].Exists {209+
210- return210+ 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
211 }215 }
212- m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected
213 }216 }
214 217
215 func (m *PurgeModel) toggleAll() {218 func (m *PurgeModel) toggleAll() {
216- // Toggle all items219+ // Check if any selectable item is unselected
217 anyUnselected := false220 anyUnselected := false
218 for _, it := range m.Items {221 for _, it := range m.Items {
219 if it.Exists && !it.Selected {222 if it.Exists && !it.Selected {
@@ -221,46 +224,27 @@ func (m *PurgeModel) toggleAll() {
221 break224 break
222 }225 }
223 }226 }
224- // Also check sessions if enabled227+ if m.hasSessionsItem() && m.SessionsEnabled() && !m.allSessionsSelected {
225- if m.SessionsEnabled() {228+ anyUnselected = true
226- for _, s := range m.Sessions {
227- if !s.Selected {
228- anyUnselected = true
229- break
230- }
231- }
232 }229 }
233 230
231+ // Set all to the opposite state
234 for i := range m.Items {232 for i := range m.Items {
235- if !m.Items[i].Exists {233+ if m.Items[i].Exists {
236- continue234+ m.Items[i].Selected = anyUnselected
237 }235 }
238- m.Items[i].Selected = anyUnselected
239 }236 }
240- if m.SessionsEnabled() {237+ if m.hasSessionsItem() && m.SessionsEnabled() {
241- for i := range m.Sessions {238+ m.allSessionsSelected = anyUnselected
242- m.Sessions[i].Selected = anyUnselected
243- }
244 }239 }
245 }240 }
246 241
247 // ToggleAllSessions toggles the selection state of all sessions.242 // ToggleAllSessions toggles the selection state of all sessions.
248 func (m *PurgeModel) ToggleAllSessions() {243 func (m *PurgeModel) ToggleAllSessions() {
249- if !m.SessionsEnabled() || len(m.Sessions) == 0 {244+ if !m.hasSessionsItem() || !m.SessionsEnabled() {
250 return245 return
251 }246 }
252-247+ m.allSessionsSelected = !m.allSessionsSelected
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- }
264 }248 }
265 249
266 // View implements tea.Model.250 // View implements tea.Model.
@@ -270,34 +254,21 @@ func (m PurgeModel) View() string {
270 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))254 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
271 subtitle := t.Subtle.Render("Select items to remove")255 subtitle := t.Subtle.Render("Select items to remove")
272 256
273- // Application Data section257+ // Build rows and render
274- dataSectionHeader := t.Subtle.Render("── Application Data ──")258+ rows := m.buildRows()
275 var itemRows []string259 var itemRows []string
276- for i, it := range m.Items {260+ for i, r := range rows {
277- itemRows = append(itemRows, m.renderItemRow(i, it))261+ isCursor := i == m.Cursor
278- }262+ switch r.Type {
279- itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)263+ case rowTypeItem:
280-264+ itemRows = append(itemRows, m.renderItemRow(r.ItemIndex, m.Items[r.ItemIndex], isCursor))
281- // Sessions section265+ case rowTypeSessions:
282- var sessionsList string266+ itemRows = append(itemRows, m.renderSessionsItem(isCursor))
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- }
292-
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 }267 }
298- sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...)
299 }268 }
300 269
270+ itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)
271+
301 selectedCount := m.SelectedCount()272 selectedCount := m.SelectedCount()
302 size := m.SelectedSize()273 size := m.SelectedSize()
303 var summary string274 var summary string
@@ -314,35 +285,30 @@ func (m PurgeModel) View() string {
314 summary = t.Subtle.Render("0 selected")285 summary = t.Subtle.Render("0 selected")
315 }286 }
316 287
317- helpText := "↑/↓ j/k move • space toggle • a all"288+ helpText := "↑/↓ j/k move • space toggle • a all • enter • esc"
318- if len(m.Sessions) > 0 && m.SessionsEnabled() {
319- helpText += " • s sessions"
320- }
321- helpText += " • enter • esc"
322 help := t.Subtle.Render(helpText)289 help := t.Subtle.Render(helpText)
323 290
324 contentParts := []string{291 contentParts := []string{
325 header,292 header,
326 subtitle,293 subtitle,
327 "",294 "",
328- dataSectionHeader,
329 itemsList,295 itemsList,
296+ "",
297+ summary,
298+ "",
299+ help,
330 }300 }
331- if sessionsList != "" {
332- contentParts = append(contentParts, "", sessionsList)
333- }
334- contentParts = append(contentParts, "", summary, "", help)
335 301
336 content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)302 content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
337 303
338 return t.Box.Render(content)304 return t.Box.Render(content)
339 }305 }
340 306
341-func (m PurgeModel) renderItemRow(i int, it PurgeItem) string {307+func (m PurgeModel) renderItemRow(_ int, it PurgeItem, isCursor bool) string {
342 t := m.theme308 t := m.theme
343 309
344 cursor := " "310 cursor := " "
345- if !m.inSessionsSection && i == m.Cursor {311+ if isCursor && it.Exists {
346 cursor = IconCursor + " "312 cursor = IconCursor + " "
347 }313 }
348 314
@@ -391,11 +357,13 @@ func (m PurgeModel) renderItemRow(i int, it PurgeItem) string {
391 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)357 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
392 }358 }
393 359
394-func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled bool) string {360+// renderSessionsItem renders the single "Sessions" item.
361+func (m PurgeModel) renderSessionsItem(isCursor bool) string {
395 t := m.theme362 t := m.theme
363+ enabled := m.SessionsEnabled()
396 364
397 cursor := " "365 cursor := " "
398- if m.inSessionsSection && i == m.Cursor && enabled {366+ if isCursor && enabled {
399 cursor = IconCursor + " "367 cursor = IconCursor + " "
400 }368 }
401 369
@@ -406,44 +374,31 @@ func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled b
406 detailStyle := t.Subtle374 detailStyle := t.Subtle
407 375
408 if enabled {376 if enabled {
409- if s.Selected {377+ if m.allSessionsSelected {
410 checkbox = IconCheckboxChecked378 checkbox = IconCheckboxChecked
411 }379 }
412 checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent)380 checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent)
413 cursorStyle = lipgloss.NewStyle().Foreground(t.Accent)381 cursorStyle = lipgloss.NewStyle().Foreground(t.Accent)
414 labelStyle = t.Normal382 labelStyle = t.Normal
415- detailStyle = t.Subtle
416 }383 }
417 384
418- // Format: ...abc1 2 tabs 3 panes 2d ago385+ // Format: Sessions (5 inactive, 12.3 MB) or (included with Data)
419- shortID := s.Info.Session.ShortID()386+ label := fmt.Sprintf("%s Sessions", IconLogs)
420- tabLabel := "tab"387+ var detail string
421- if s.Info.TabCount != 1 {388+ if enabled {
422- tabLabel = "tabs"389+ detail = fmt.Sprintf("(%d inactive, %s)", len(m.Sessions), formatSize(m.SessionsSize))
423- }390+ } else {
424- paneLabel := "pane"391+ detail = fmt.Sprintf("(%d inactive, included with Data)", len(m.Sessions))
425- if s.Info.PaneCount != 1 {
426- paneLabel = "panes"
427 }392 }
428 393
429- const idPadWidth = 8394+ const labelPadWidth = 12
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(395 return lipgloss.JoinHorizontal(
439 lipgloss.Left,396 lipgloss.Left,
440 cursorStyle.Render(cursor),397 cursorStyle.Render(cursor),
441 checkboxStyle.Render(checkbox),398 checkboxStyle.Render(checkbox),
442 " ",399 " ",
443- idPart,400+ labelStyle.Render(padRight(label, labelPadWidth)),
444- tabPart,401+ detailStyle.Render(detail),
445- panePart,
446- timePart,
447 )402 )
448 }403 }
449 404
@@ -493,45 +448,6 @@ func formatSize(bytes int64) string {
493 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])448 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
494 }449 }
495 450
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-
535 // Done returns true if the modal is complete.451 // Done returns true if the modal is complete.
536 func (m PurgeModel) Done() bool {452 func (m PurgeModel) Done() bool {
537 return m.Confirmed || m.Canceled453 return m.Confirmed || m.Canceled
@@ -553,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
553 return out469 return out
554 }470 }
555 471
556-// SelectedCount returns the number of selected items (targets + sessions).472+// SelectedCount returns the number of selected items (targets + sessions as 1 if selected).
557 func (m PurgeModel) SelectedCount() int {473 func (m PurgeModel) SelectedCount() int {
558 count := 0474 count := 0
559 for _, it := range m.Items {475 for _, it := range m.Items {
@@ -561,13 +477,9 @@ func (m PurgeModel) SelectedCount() int {
561 count++477 count++
562 }478 }
563 }479 }
564- // Only count sessions if they're enabled (Data not selected)480+ // Count sessions as 1 item if selected (and enabled)
565- if m.SessionsEnabled() {481+ if m.hasSessionsItem() && m.SessionsEnabled() && m.allSessionsSelected {
566- for _, s := range m.Sessions {482+ count++
567- if s.Selected {
568- count++
569- }
570- }
571 }483 }
572 return count484 return count
573 }485 }
@@ -584,35 +496,27 @@ func (m PurgeModel) SelectedSize() int64 {
584 // If Data is selected, sessions are implicitly included496 // If Data is selected, sessions are implicitly included
585 if m.isDataSelected() {497 if m.isDataSelected() {
586 total += m.SessionsSize498 total += m.SessionsSize
587- } else {499+ } else if m.allSessionsSelected {
588- // Otherwise, count only selected sessions500+ // Sessions are explicitly selected
589- // Note: individual session sizes aren't tracked, only total501+ total += m.SessionsSize
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 }502 }
601 return total503 return total
602 }504 }
603 505
604-// SelectedSessionIDs returns the IDs of selected sessions.506+// SelectedSessionIDs returns the IDs of all sessions if sessions are selected.
605-// Returns nil if Data is selected (sessions are deleted with the database).507+// Returns nil if Data is selected (sessions are deleted with the database)
508+// or if sessions are not selected.
606 func (m PurgeModel) SelectedSessionIDs() []entity.SessionID {509 func (m PurgeModel) SelectedSessionIDs() []entity.SessionID {
607 if m.isDataSelected() {510 if m.isDataSelected() {
608 return nil511 return nil
609 }512 }
513+ if !m.allSessionsSelected {
514+ return nil
515+ }
610 516
611- var ids []entity.SessionID517+ ids := make([]entity.SessionID, 0, len(m.Sessions))
612 for _, s := range m.Sessions {518 for _, s := range m.Sessions {
613- if s.Selected {519+ ids = append(ids, s.Info.Session.ID)
614- ids = append(ids, s.Info.Session.ID)
615- }
616 }520 }
617 return ids521 return ids
618 }522 }
M internal/cli/styles/purge_test.go +27 -20
@@ -68,7 +68,6 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T)
6868 EndedAt: &endedAt,
6969 },
7070 },
71- Selected: true,
7271 },
7372 }
7473
@@ -78,8 +77,9 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T)
7877 ids := m.SelectedSessionIDs()
7978 assert.Nil(t, ids, "should return nil when Data is selected")
8079
81- // Deselect Data
80+ // Deselect Data and select all sessions
8281 m.Items[0].Selected = false
82+ m.ToggleAllSessions() // Now allSessionsSelected = true
8383 ids = m.SelectedSessionIDs()
8484 require.Len(t, ids, 1)
8585 assert.Equal(t, entity.SessionID("session1"), ids[0])
@@ -104,7 +104,6 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) {
104104 EndedAt: &endedAt,
105105 },
106106 },
107- Selected: true,
108107 },
109108 {
110109 Info: entity.SessionInfo{
@@ -115,13 +114,17 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) {
115114 EndedAt: &endedAt,
116115 },
117116 },
118- Selected: false, // Not selected
119117 },
120118 }
121119
122120 m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
123121
124- // 2 targets + 1 selected session = 3
122+ // 2 targets selected, sessions not selected = 2
123+ assert.Equal(t, 2, m.SelectedCount())
124+
125+ // Select all sessions (as a single item)
126+ m.ToggleAllSessions()
127+ // 2 targets + 1 sessions item = 3
125128 assert.Equal(t, 3, m.SelectedCount())
126129 }
127130
@@ -143,7 +146,6 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) {
143146 EndedAt: &endedAt,
144147 },
145148 },
146- Selected: true,
147149 },
148150 {
149151 Info: entity.SessionInfo{
@@ -154,24 +156,27 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) {
154156 EndedAt: &endedAt,
155157 },
156158 },
157- Selected: true,
158159 },
159160 }
160161
161162 m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
162163
163- // All sessions are selected, toggle should deselect all
164+ // Initially sessions are not selected
165+ ids := m.SelectedSessionIDs()
166+ assert.Nil(t, ids, "sessions should not be selected initially")
167+
168+ // Toggle to select all
164169 m.ToggleAllSessions()
165- assert.False(t, m.Sessions[0].Selected)
166- assert.False(t, m.Sessions[1].Selected)
170+ ids = m.SelectedSessionIDs()
171+ require.Len(t, ids, 2, "all sessions should be selected after toggle")
167172
168- // Now toggle again to select all
173+ // Toggle again to deselect all
169174 m.ToggleAllSessions()
170- assert.True(t, m.Sessions[0].Selected)
171- assert.True(t, m.Sessions[1].Selected)
175+ ids = m.SelectedSessionIDs()
176+ assert.Nil(t, ids, "sessions should be deselected after second toggle")
172177 }
173178
174-func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {
179+func TestPurgeModel_SelectedSize_IncludesAllSessionsWhenSelected(t *testing.T) {
175180 theme := testTheme()
176181
177182 targets := []entity.PurgeTarget{
@@ -189,7 +194,6 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {
189194 EndedAt: &endedAt,
190195 },
191196 },
192- Selected: true,
193197 },
194198 {
195199 Info: entity.SessionInfo{
@@ -200,15 +204,19 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {
200204 EndedAt: &endedAt,
201205 },
202206 },
203- Selected: false, // Not selected
204207 },
205208 }
206209
207- // Total sessions size is 1000, 1 of 2 sessions selected = 500
210+ // Total sessions size is 1000
208211 m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
209212
210- // 100 (config) + 500 (1/2 of sessions) = 600
211- assert.Equal(t, int64(600), m.SelectedSize())
213+ // Only config selected = 100
214+ assert.Equal(t, int64(100), m.SelectedSize())
215+
216+ // Select all sessions
217+ m.ToggleAllSessions()
218+ // 100 (config) + 1000 (all sessions) = 1100
219+ assert.Equal(t, int64(1100), m.SelectedSize())
212220 }
213221
214222 func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) {
@@ -229,7 +237,6 @@ func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *tes
229237 EndedAt: &endedAt,
230238 },
231239 },
232- Selected: false, // Not individually selected, but Data is selected
233240 },
234241 }
235242
@@ -68,7 +68,6 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T)
68 EndedAt: &endedAt,68 EndedAt: &endedAt,
69 },69 },
70 },70 },
71- Selected: true,
72 },71 },
73 }72 }
74 73
@@ -78,8 +77,9 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T)
78 ids := m.SelectedSessionIDs()77 ids := m.SelectedSessionIDs()
79 assert.Nil(t, ids, "should return nil when Data is selected")78 assert.Nil(t, ids, "should return nil when Data is selected")
80 79
81- // Deselect Data80+ // Deselect Data and select all sessions
82 m.Items[0].Selected = false81 m.Items[0].Selected = false
82+ m.ToggleAllSessions() // Now allSessionsSelected = true
83 ids = m.SelectedSessionIDs()83 ids = m.SelectedSessionIDs()
84 require.Len(t, ids, 1)84 require.Len(t, ids, 1)
85 assert.Equal(t, entity.SessionID("session1"), ids[0])85 assert.Equal(t, entity.SessionID("session1"), ids[0])
@@ -104,7 +104,6 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) {
104 EndedAt: &endedAt,104 EndedAt: &endedAt,
105 },105 },
106 },106 },
107- Selected: true,
108 },107 },
109 {108 {
110 Info: entity.SessionInfo{109 Info: entity.SessionInfo{
@@ -115,13 +114,17 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) {
115 EndedAt: &endedAt,114 EndedAt: &endedAt,
116 },115 },
117 },116 },
118- Selected: false, // Not selected
119 },117 },
120 }118 }
121 119
122 m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)120 m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
123 121
124- // 2 targets + 1 selected session = 3122+ // 2 targets selected, sessions not selected = 2
123+ assert.Equal(t, 2, m.SelectedCount())
124+
125+ // Select all sessions (as a single item)
126+ m.ToggleAllSessions()
127+ // 2 targets + 1 sessions item = 3
125 assert.Equal(t, 3, m.SelectedCount())128 assert.Equal(t, 3, m.SelectedCount())
126 }129 }
127 130
@@ -143,7 +146,6 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) {
143 EndedAt: &endedAt,146 EndedAt: &endedAt,
144 },147 },
145 },148 },
146- Selected: true,
147 },149 },
148 {150 {
149 Info: entity.SessionInfo{151 Info: entity.SessionInfo{
@@ -154,24 +156,27 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) {
154 EndedAt: &endedAt,156 EndedAt: &endedAt,
155 },157 },
156 },158 },
157- Selected: true,
158 },159 },
159 }160 }
160 161
161 m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)162 m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
162 163
163- // All sessions are selected, toggle should deselect all164+ // Initially sessions are not selected
165+ ids := m.SelectedSessionIDs()
166+ assert.Nil(t, ids, "sessions should not be selected initially")
167+
168+ // Toggle to select all
164 m.ToggleAllSessions()169 m.ToggleAllSessions()
165- assert.False(t, m.Sessions[0].Selected)170+ ids = m.SelectedSessionIDs()
166- assert.False(t, m.Sessions[1].Selected)171+ require.Len(t, ids, 2, "all sessions should be selected after toggle")
167 172
168- // Now toggle again to select all173+ // Toggle again to deselect all
169 m.ToggleAllSessions()174 m.ToggleAllSessions()
170- assert.True(t, m.Sessions[0].Selected)175+ ids = m.SelectedSessionIDs()
171- assert.True(t, m.Sessions[1].Selected)176+ assert.Nil(t, ids, "sessions should be deselected after second toggle")
172 }177 }
173 178
174-func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {179+func TestPurgeModel_SelectedSize_IncludesAllSessionsWhenSelected(t *testing.T) {
175 theme := testTheme()180 theme := testTheme()
176 181
177 targets := []entity.PurgeTarget{182 targets := []entity.PurgeTarget{
@@ -189,7 +194,6 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {
189 EndedAt: &endedAt,194 EndedAt: &endedAt,
190 },195 },
191 },196 },
192- Selected: true,
193 },197 },
194 {198 {
195 Info: entity.SessionInfo{199 Info: entity.SessionInfo{
@@ -200,15 +204,19 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {
200 EndedAt: &endedAt,204 EndedAt: &endedAt,
201 },205 },
202 },206 },
203- Selected: false, // Not selected
204 },207 },
205 }208 }
206 209
207- // Total sessions size is 1000, 1 of 2 sessions selected = 500210+ // Total sessions size is 1000
208 m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)211 m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
209 212
210- // 100 (config) + 500 (1/2 of sessions) = 600213+ // Only config selected = 100
211- assert.Equal(t, int64(600), m.SelectedSize())214+ assert.Equal(t, int64(100), m.SelectedSize())
215+
216+ // Select all sessions
217+ m.ToggleAllSessions()
218+ // 100 (config) + 1000 (all sessions) = 1100
219+ assert.Equal(t, int64(1100), m.SelectedSize())
212 }220 }
213 221
214 func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) {222 func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) {
@@ -229,7 +237,6 @@ func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *tes
229 EndedAt: &endedAt,237 EndedAt: &endedAt,
230 },238 },
231 },239 },
232- Selected: false, // Not individually selected, but Data is selected
233 },240 },
234 }241 }
235 242

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