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 Unverified
2B82207D370127A0The 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 { | |||
| 61 | 61 | RestoreUC: app.RestoreUC, | |
| 62 | 62 | DeleteSessionUC: app.DeleteSessionUC, | |
| 63 | 63 | CurrentSession: currentSessionID, | |
| 64 | - | MaxListedSessions: app.Config.Session.MaxListedSessions, | |
| 64 | + | MaxExitedSessions: app.Config.Session.MaxExitedSessions, | |
| 65 | 65 | }) | |
| 66 | 66 | ||
| 67 | 67 | 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 { | |||
| 115 | 115 | RestoreUC *usecase.RestoreSessionUseCase | |
| 116 | 116 | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 117 | 117 | CurrentSession entity.SessionID | |
| 118 | - | MaxListedSessions int | |
| 118 | + | MaxExitedSessions int | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | // NewSessionsModel creates a new sessions browser model. | |
| 122 | 122 | func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel { | |
| 123 | - | maxListed := cfg.MaxListedSessions | |
| 123 | + | maxListed := cfg.MaxExitedSessions | |
| 124 | 124 | if maxListed <= 0 { | |
| 125 | 125 | maxListed = 50 // fallback default | |
| 126 | 126 | } | |
| @@ -115,12 +115,12 @@ type SessionsModelConfig struct { | |||||
| 115 | RestoreUC *usecase.RestoreSessionUseCase | 115 | RestoreUC *usecase.RestoreSessionUseCase | ||
| 116 | DeleteSessionUC *usecase.DeleteSessionUseCase | 116 | DeleteSessionUC *usecase.DeleteSessionUseCase | ||
| 117 | CurrentSession entity.SessionID | 117 | CurrentSession entity.SessionID | ||
| 118 | - | MaxListedSessions int | 118 | + | 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.MaxListedSessions | 123 | + | maxListed := cfg.MaxExitedSessions |
| 124 | if maxListed <= 0 { | 124 | if maxListed <= 0 { | ||
| 125 | maxListed = 50 // fallback default | 125 | maxListed = 50 // fallback default | ||
| 126 | } | 126 | } | ||
M internal/cli/styles/purge.go +136 -232
| @@ -3,7 +3,6 @@ package styles | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | - | "time" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/charmbracelet/bubbles/key" | |
| 9 | 8 | tea "github.com/charmbracelet/bubbletea" | |
| @@ -18,44 +17,71 @@ type PurgeItem struct { | |||
| 18 | 17 | 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 | 22 | type PurgeModel struct { | |
| 23 | 23 | Items []PurgeItem | |
| 24 | 24 | Sessions []entity.SessionPurgeItem | |
| 25 | 25 | SessionsSize int64 // Total size of all session snapshots | |
| 26 | - | Cursor int | |
| 26 | + | Cursor int // Flat cursor index across all rows | |
| 27 | 27 | Confirmed bool | |
| 28 | 28 | Canceled bool | |
| 29 | 29 | theme *Theme | |
| 30 | 30 | ||
| 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 | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | // PurgeKeyMap defines keybindings for purge modal. | |
| 36 | 36 | 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 | |
| 44 | 43 | } | |
| 45 | 44 | ||
| 46 | 45 | // DefaultPurgeKeyMap returns default keybindings. | |
| 47 | 46 | func DefaultPurgeKeyMap() PurgeKeyMap { | |
| 48 | 47 | 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")), | |
| 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 | 85 | // NewPurge creates a new purge modal with the given targets. | |
| 60 | 86 | func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | |
| 61 | 87 | items := make([]PurgeItem, 0, len(targets)) | |
| @@ -97,8 +123,6 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) { | |||
| 97 | 123 | m.toggleCurrent() | |
| 98 | 124 | case key.Matches(msg, keys.ToggleAll): | |
| 99 | 125 | m.toggleAll() | |
| 100 | - | case key.Matches(msg, keys.ToggleSessions): | |
| 101 | - | m.ToggleAllSessions() | |
| 102 | 126 | case key.Matches(msg, keys.Confirm): | |
| 103 | 127 | m.Confirmed = true | |
| 104 | 128 | case key.Matches(msg, keys.Cancel): | |
| @@ -119,73 +143,53 @@ func (m PurgeModel) isDataSelected() bool { | |||
| 119 | 143 | 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 | 147 | // Sessions are disabled when Data is selected (database will be removed). | |
| 124 | 148 | func (m PurgeModel) SessionsEnabled() bool { | |
| 125 | 149 | return !m.isDataSelected() | |
| 126 | 150 | } | |
| 127 | 151 | ||
| 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 | |
| 140 | 155 | } | |
| 141 | 156 | ||
| 142 | 157 | func (m *PurgeModel) moveCursor(delta int) { | |
| 143 | - | total := m.totalSelectableCount() | |
| 144 | - | if total == 0 { | |
| 158 | + | rows := m.buildRows() | |
| 159 | + | if len(rows) == 0 { | |
| 145 | 160 | return | |
| 146 | 161 | } | |
| 147 | 162 | ||
| 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) | |
| 164 | 168 | } | |
| 165 | 169 | } | |
| 166 | 170 | ||
| 167 | - | if len(positions) == 0 { | |
| 171 | + | if len(selectableIndices) == 0 { | |
| 168 | 172 | return | |
| 169 | 173 | } | |
| 170 | 174 | ||
| 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 | |
| 176 | 180 | break | |
| 177 | 181 | } | |
| 178 | 182 | } | |
| 179 | 183 | ||
| 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] | |
| 184 | 187 | } | |
| 185 | 188 | ||
| 186 | 189 | 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 { | |
| 189 | 193 | return i | |
| 190 | 194 | } | |
| 191 | 195 | } | |
| @@ -193,27 +197,26 @@ func (m PurgeModel) firstSelectableIndex() int { | |||
| 193 | 197 | } | |
| 194 | 198 | ||
| 195 | 199 | 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) { | |
| 203 | 202 | 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 | 207 | return | |
| 208 | 208 | } | |
| 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 | |
| 211 | 215 | } | |
| 212 | - | m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected | |
| 213 | 216 | } | |
| 214 | 217 | ||
| 215 | 218 | func (m *PurgeModel) toggleAll() { | |
| 216 | - | // Toggle all items | |
| 219 | + | // Check if any selectable item is unselected | |
| 217 | 220 | anyUnselected := false | |
| 218 | 221 | for _, it := range m.Items { | |
| 219 | 222 | if it.Exists && !it.Selected { | |
| @@ -221,46 +224,27 @@ func (m *PurgeModel) toggleAll() { | |||
| 221 | 224 | break | |
| 222 | 225 | } | |
| 223 | 226 | } | |
| 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 | |
| 232 | 229 | } | |
| 233 | 230 | ||
| 231 | + | // Set all to the opposite state | |
| 234 | 232 | 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 | |
| 237 | 235 | } | |
| 238 | - | m.Items[i].Selected = anyUnselected | |
| 239 | 236 | } | |
| 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 | |
| 244 | 239 | } | |
| 245 | 240 | } | |
| 246 | 241 | ||
| 247 | 242 | // ToggleAllSessions toggles the selection state of all sessions. | |
| 248 | 243 | func (m *PurgeModel) ToggleAllSessions() { | |
| 249 | - | if !m.SessionsEnabled() || len(m.Sessions) == 0 { | |
| 244 | + | if !m.hasSessionsItem() || !m.SessionsEnabled() { | |
| 250 | 245 | return | |
| 251 | 246 | } | |
| 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 | |
| 264 | 248 | } | |
| 265 | 249 | ||
| 266 | 250 | // View implements tea.Model. | |
| @@ -270,34 +254,21 @@ func (m PurgeModel) View() string { | |||
| 270 | 254 | header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash)) | |
| 271 | 255 | subtitle := t.Subtle.Render("Select items to remove") | |
| 272 | 256 | ||
| 273 | - | // Application Data section | |
| 274 | - | dataSectionHeader := t.Subtle.Render("── Application Data ──") | |
| 257 | + | // Build rows and render | |
| 258 | + | rows := m.buildRows() | |
| 275 | 259 | 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)) | |
| 297 | 267 | } | |
| 298 | - | sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...) | |
| 299 | 268 | } | |
| 300 | 269 | ||
| 270 | + | itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...) | |
| 271 | + | ||
| 301 | 272 | selectedCount := m.SelectedCount() | |
| 302 | 273 | size := m.SelectedSize() | |
| 303 | 274 | var summary string | |
| @@ -314,35 +285,30 @@ func (m PurgeModel) View() string { | |||
| 314 | 285 | summary = t.Subtle.Render("0 selected") | |
| 315 | 286 | } | |
| 316 | 287 | ||
| 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" | |
| 322 | 289 | help := t.Subtle.Render(helpText) | |
| 323 | 290 | ||
| 324 | 291 | contentParts := []string{ | |
| 325 | 292 | header, | |
| 326 | 293 | subtitle, | |
| 327 | 294 | "", | |
| 328 | - | dataSectionHeader, | |
| 329 | 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 | 302 | content := lipgloss.JoinVertical(lipgloss.Left, contentParts...) | |
| 337 | 303 | ||
| 338 | 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 | 308 | t := m.theme | |
| 343 | 309 | ||
| 344 | 310 | cursor := " " | |
| 345 | - | if !m.inSessionsSection && i == m.Cursor { | |
| 311 | + | if isCursor && it.Exists { | |
| 346 | 312 | cursor = IconCursor + " " | |
| 347 | 313 | } | |
| 348 | 314 | ||
| @@ -391,11 +357,13 @@ func (m PurgeModel) renderItemRow(i int, it PurgeItem) string { | |||
| 391 | 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 | 362 | t := m.theme | |
| 363 | + | enabled := m.SessionsEnabled() | |
| 396 | 364 | ||
| 397 | 365 | cursor := " " | |
| 398 | - | if m.inSessionsSection && i == m.Cursor && enabled { | |
| 366 | + | if isCursor && enabled { | |
| 399 | 367 | cursor = IconCursor + " " | |
| 400 | 368 | } | |
| 401 | 369 | ||
| @@ -406,44 +374,31 @@ func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled b | |||
| 406 | 374 | detailStyle := t.Subtle | |
| 407 | 375 | ||
| 408 | 376 | if enabled { | |
| 409 | - | if s.Selected { | |
| 377 | + | if m.allSessionsSelected { | |
| 410 | 378 | checkbox = IconCheckboxChecked | |
| 411 | 379 | } | |
| 412 | 380 | checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 413 | 381 | cursorStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 414 | 382 | labelStyle = t.Normal | |
| 415 | - | detailStyle = t.Subtle | |
| 416 | 383 | } | |
| 417 | 384 | ||
| 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)) | |
| 427 | 392 | } | |
| 428 | 393 | ||
| 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 | |
| 438 | 395 | return lipgloss.JoinHorizontal( | |
| 439 | 396 | lipgloss.Left, | |
| 440 | 397 | cursorStyle.Render(cursor), | |
| 441 | 398 | checkboxStyle.Render(checkbox), | |
| 442 | 399 | " ", | |
| 443 | - | idPart, | |
| 444 | - | tabPart, | |
| 445 | - | panePart, | |
| 446 | - | timePart, | |
| 400 | + | labelStyle.Render(padRight(label, labelPadWidth)), | |
| 401 | + | detailStyle.Render(detail), | |
| 447 | 402 | ) | |
| 448 | 403 | } | |
| 449 | 404 | ||
| @@ -493,45 +448,6 @@ func formatSize(bytes int64) string { | |||
| 493 | 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 | 451 | // Done returns true if the modal is complete. | |
| 536 | 452 | func (m PurgeModel) Done() bool { | |
| 537 | 453 | return m.Confirmed || m.Canceled | |
| @@ -553,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType { | |||
| 553 | 469 | 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 | 473 | func (m PurgeModel) SelectedCount() int { | |
| 558 | 474 | count := 0 | |
| 559 | 475 | for _, it := range m.Items { | |
| @@ -561,13 +477,9 @@ func (m PurgeModel) SelectedCount() int { | |||
| 561 | 477 | count++ | |
| 562 | 478 | } | |
| 563 | 479 | } | |
| 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++ | |
| 571 | 483 | } | |
| 572 | 484 | return count | |
| 573 | 485 | } | |
| @@ -584,35 +496,27 @@ func (m PurgeModel) SelectedSize() int64 { | |||
| 584 | 496 | // If Data is selected, sessions are implicitly included | |
| 585 | 497 | if m.isDataSelected() { | |
| 586 | 498 | 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 | |
| 600 | 502 | } | |
| 601 | 503 | return total | |
| 602 | 504 | } | |
| 603 | 505 | ||
| 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. | |
| 606 | 509 | func (m PurgeModel) SelectedSessionIDs() []entity.SessionID { | |
| 607 | 510 | if m.isDataSelected() { | |
| 608 | 511 | return nil | |
| 609 | 512 | } | |
| 513 | + | if !m.allSessionsSelected { | |
| 514 | + | return nil | |
| 515 | + | } | |
| 610 | 516 | ||
| 611 | - | var ids []entity.SessionID | |
| 517 | + | ids := make([]entity.SessionID, 0, len(m.Sessions)) | |
| 612 | 518 | 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) | |
| 616 | 520 | } | |
| 617 | 521 | return ids | |
| 618 | 522 | } | |
| @@ -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 bool | 17 | 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 []PurgeItem | 23 | Items []PurgeItem | ||
| 24 | Sessions []entity.SessionPurgeItem | 24 | Sessions []entity.SessionPurgeItem | ||
| 25 | SessionsSize int64 // Total size of all session snapshots | 25 | SessionsSize int64 // Total size of all session snapshots | ||
| 26 | - | Cursor int | 26 | + | Cursor int // Flat cursor index across all rows |
| 27 | Confirmed bool | 27 | Confirmed bool | ||
| 28 | Canceled bool | 28 | Canceled bool | ||
| 29 | theme *Theme | 29 | theme *Theme | ||
| 30 | 30 | ||||
| 31 | - | // Track cursor position: false = items section, true = sessions section | 31 | + | // allSessionsSelected indicates if all sessions should be purged |
| 32 | - | inSessionsSection bool | 32 | + | 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.Binding | 37 | + | Up key.Binding |
| 38 | - | Down key.Binding | 38 | + | Down key.Binding |
| 39 | - | Toggle key.Binding | 39 | + | Toggle key.Binding |
| 40 | - | ToggleAll key.Binding | 40 | + | ToggleAll key.Binding |
| 41 | - | ToggleSessions key.Binding | 41 | + | Confirm key.Binding |
| 42 | - | Confirm key.Binding | 42 | + | 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 = true | 127 | 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 false | 143 | 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 := 0 | 154 | + | 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 | return | 160 | return | ||
| 146 | } | 161 | } | ||
| 147 | 162 | ||||
| 148 | - | // Build a flat list of selectable indices | 163 | + | // 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 bool | 166 | + | if r.Exists { |
| 152 | - | index int | 167 | + | 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 | return | 172 | return | ||
| 169 | } | 173 | } | ||
| 170 | 174 | ||||
| 171 | - | // Find current position in the flat list | 175 | + | // Find current position in selectable list |
| 172 | - | currentFlat := 0 | 176 | + | 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 = i | 179 | + | currentPos = i |
| 176 | break | 180 | break | ||
| 177 | } | 181 | } | ||
| 178 | } | 182 | } | ||
| 179 | 183 | ||||
| 180 | - | // Move in the flat list | 184 | + | // Move |
| 181 | - | newFlat := (currentFlat + delta + len(positions)) % len(positions) | 185 | + | newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices) |
| 182 | - | m.inSessionsSection = positions[newFlat].inSessions | 186 | + | 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 i | 193 | 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 | return | 202 | 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 | return | 207 | return | ||
| 208 | } | 208 | } | ||
| 209 | - | if !m.Items[m.Cursor].Exists { | 209 | + | |
| 210 | - | return | 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 | |||
| 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 items | 219 | + | // Check if any selectable item is unselected |
| 217 | anyUnselected := false | 220 | 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 | break | 224 | break | ||
| 222 | } | 225 | } | ||
| 223 | } | 226 | } | ||
| 224 | - | // Also check sessions if enabled | 227 | + | 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 | - | continue | 234 | + | 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 | return | 245 | 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 section | 257 | + | // Build rows and render |
| 274 | - | dataSectionHeader := t.Subtle.Render("── Application Data ──") | 258 | + | rows := m.buildRows() |
| 275 | var itemRows []string | 259 | 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 section | 265 | + | case rowTypeSessions: |
| 282 | - | var sessionsList string | 266 | + | 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 string | 274 | 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.theme | 308 | 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.theme | 362 | 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.Subtle | 374 | detailStyle := t.Subtle | ||
| 407 | 375 | ||||
| 408 | if enabled { | 376 | if enabled { | ||
| 409 | - | if s.Selected { | 377 | + | if m.allSessionsSelected { |
| 410 | checkbox = IconCheckboxChecked | 378 | 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.Normal | 382 | labelStyle = t.Normal | ||
| 415 | - | detailStyle = t.Subtle | |||
| 416 | } | 383 | } | ||
| 417 | 384 | ||||
| 418 | - | // Format: ...abc1 2 tabs 3 panes 2d ago | 385 | + | // 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 = 8 | 394 | + | 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.Canceled | 453 | return m.Confirmed || m.Canceled | ||
| @@ -553,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType { | |||||
| 553 | return out | 469 | 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 := 0 | 474 | 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 count | 484 | return count | ||
| 573 | } | 485 | } | ||
| @@ -584,35 +496,27 @@ func (m PurgeModel) SelectedSize() int64 { | |||||
| 584 | // If Data is selected, sessions are implicitly included | 496 | // If Data is selected, sessions are implicitly included | ||
| 585 | if m.isDataSelected() { | 497 | if m.isDataSelected() { | ||
| 586 | total += m.SessionsSize | 498 | total += m.SessionsSize | ||
| 587 | - | } else { | 499 | + | } else if m.allSessionsSelected { |
| 588 | - | // Otherwise, count only selected sessions | 500 | + | // Sessions are explicitly selected |
| 589 | - | // Note: individual session sizes aren't tracked, only total | 501 | + | 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 total | 503 | 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 nil | 511 | return nil | ||
| 609 | } | 512 | } | ||
| 513 | + | if !m.allSessionsSelected { | |||
| 514 | + | return nil | |||
| 515 | + | } | |||
| 610 | 516 | ||||
| 611 | - | var ids []entity.SessionID | 517 | + | 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 ids | 521 | return ids | ||
| 618 | } | 522 | } | ||
M internal/cli/styles/purge_test.go +27 -20
| @@ -68,7 +68,6 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) | |||
| 68 | 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 | 77 | ids := m.SelectedSessionIDs() | |
| 79 | 78 | assert.Nil(t, ids, "should return nil when Data is selected") | |
| 80 | 79 | ||
| 81 | - | // Deselect Data | |
| 80 | + | // Deselect Data and select all sessions | |
| 82 | 81 | m.Items[0].Selected = false | |
| 82 | + | m.ToggleAllSessions() // Now allSessionsSelected = true | |
| 83 | 83 | ids = m.SelectedSessionIDs() | |
| 84 | 84 | require.Len(t, ids, 1) | |
| 85 | 85 | assert.Equal(t, entity.SessionID("session1"), ids[0]) | |
| @@ -104,7 +104,6 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) { | |||
| 104 | 104 | EndedAt: &endedAt, | |
| 105 | 105 | }, | |
| 106 | 106 | }, | |
| 107 | - | Selected: true, | |
| 108 | 107 | }, | |
| 109 | 108 | { | |
| 110 | 109 | Info: entity.SessionInfo{ | |
| @@ -115,13 +114,17 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) { | |||
| 115 | 114 | EndedAt: &endedAt, | |
| 116 | 115 | }, | |
| 117 | 116 | }, | |
| 118 | - | Selected: false, // Not selected | |
| 119 | 117 | }, | |
| 120 | 118 | } | |
| 121 | 119 | ||
| 122 | 120 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 123 | 121 | ||
| 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 | |
| 125 | 128 | assert.Equal(t, 3, m.SelectedCount()) | |
| 126 | 129 | } | |
| 127 | 130 | ||
| @@ -143,7 +146,6 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) { | |||
| 143 | 146 | EndedAt: &endedAt, | |
| 144 | 147 | }, | |
| 145 | 148 | }, | |
| 146 | - | Selected: true, | |
| 147 | 149 | }, | |
| 148 | 150 | { | |
| 149 | 151 | Info: entity.SessionInfo{ | |
| @@ -154,24 +156,27 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) { | |||
| 154 | 156 | EndedAt: &endedAt, | |
| 155 | 157 | }, | |
| 156 | 158 | }, | |
| 157 | - | Selected: true, | |
| 158 | 159 | }, | |
| 159 | 160 | } | |
| 160 | 161 | ||
| 161 | 162 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 162 | 163 | ||
| 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 | |
| 164 | 169 | 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") | |
| 167 | 172 | ||
| 168 | - | // Now toggle again to select all | |
| 173 | + | // Toggle again to deselect all | |
| 169 | 174 | 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") | |
| 172 | 177 | } | |
| 173 | 178 | ||
| 174 | - | func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |
| 179 | + | func TestPurgeModel_SelectedSize_IncludesAllSessionsWhenSelected(t *testing.T) { | |
| 175 | 180 | theme := testTheme() | |
| 176 | 181 | ||
| 177 | 182 | targets := []entity.PurgeTarget{ | |
| @@ -189,7 +194,6 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |||
| 189 | 194 | EndedAt: &endedAt, | |
| 190 | 195 | }, | |
| 191 | 196 | }, | |
| 192 | - | Selected: true, | |
| 193 | 197 | }, | |
| 194 | 198 | { | |
| 195 | 199 | Info: entity.SessionInfo{ | |
| @@ -200,15 +204,19 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |||
| 200 | 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 = 500 | |
| 210 | + | // Total sessions size is 1000 | |
| 208 | 211 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000) | |
| 209 | 212 | ||
| 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()) | |
| 212 | 220 | } | |
| 213 | 221 | ||
| 214 | 222 | func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) { | |
| @@ -229,7 +237,6 @@ func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *tes | |||
| 229 | 237 | EndedAt: &endedAt, | |
| 230 | 238 | }, | |
| 231 | 239 | }, | |
| 232 | - | Selected: false, // Not individually selected, but Data is selected | |
| 233 | 240 | }, | |
| 234 | 241 | } | |
| 235 | 242 | ||
| @@ -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 Data | 80 | + | // Deselect Data and select all sessions |
| 82 | m.Items[0].Selected = false | 81 | 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 = 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 | |||
| 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 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 | |||
| 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 all | 173 | + | // 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 = 500 | 210 | + | // 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) = 600 | 213 | + | // 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 | ||||