detached 24473b6

Merge pull request #33 from bnema/dev

feat: session cleanup, dynamic window title, simplified purge UI
commit
24473b61ba038378ea259aee5e6f514e306be3d7
author
bnema · · 2025-12-25 19:15 UTC
committer
GitHub · · 2025-12-25 19:15 UTC
merge of
000ca5b + f4b663e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
23 files · +830 -276
M .github/workflows/ci.yml +1 -1
@@ -2,7 +2,7 @@ name: CI
22
33 on:
44 push:
5- branches: [dev, main]
5+ branches: [main]
66 paths:
77 - '**.go'
88 - 'go.mod'
@@ -2,7 +2,7 @@ name: CI
2 2
3 on:3 on:
4 push:4 push:
5- branches: [dev, main]5+ branches: [main]
6 paths:6 paths:
7 - '**.go'7 - '**.go'
8 - 'go.mod'8 - 'go.mod'
M CHANGELOG.md +4 -2
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
89 - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture.
910 - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true).
1011 - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability.
@@ -17,8 +18,9 @@ All notable changes to this project will be documented in this file.
1718 - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes.
1819 - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes.
1920 - **CLI session commands**: `dumber sessions` opens interactive TUI browser; `dumber sessions list` shows saved sessions; `dumber sessions restore <id>` resurrects a session in a new window; `dumber sessions delete <id>` removes a session.
20- - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data.
21- - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling.
21+ - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. Simplified UI with single "Sessions" toggle instead of individual selection.
22+ - **Automatic session cleanup**: Sessions are automatically cleaned up on startup based on `max_exited_sessions` (default: 50) and `max_exited_session_age_days` (default: 7 days).
23+ - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_exited_session_age_days`, plus session mode shortcuts and styling.
2224
2325 ### Fixed
2426 - **Alt/Ctrl+number shortcuts on non-QWERTY keyboards**: Fixed Alt+1-9/0 tab switching and Ctrl+1-9/0 omnibox shortcuts not working on AZERTY, QWERTZ, Dvorak, and other non-QWERTY keyboard layouts. Uses hardware keycodes as fallback when keyval lookup fails, enabling shortcuts to work based on physical key position regardless of layout.
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
8 - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture.9 - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture.
9 - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true).10 - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true).
10 - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability.11 - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability.
@@ -17,8 +18,9 @@ All notable changes to this project will be documented in this file.
17 - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes.18 - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes.
18 - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes.19 - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes.
19 - **CLI session commands**: `dumber sessions` opens interactive TUI browser; `dumber sessions list` shows saved sessions; `dumber sessions restore <id>` resurrects a session in a new window; `dumber sessions delete <id>` removes a session.20 - **CLI session commands**: `dumber sessions` opens interactive TUI browser; `dumber sessions list` shows saved sessions; `dumber sessions restore <id>` resurrects a session in a new window; `dumber sessions delete <id>` removes a session.
20- - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data.21+ - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. Simplified UI with single "Sessions" toggle instead of individual selection.
21- - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling.22+ - **Automatic session cleanup**: Sessions are automatically cleaned up on startup based on `max_exited_sessions` (default: 50) and `max_exited_session_age_days` (default: 7 days).
23+ - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_exited_session_age_days`, plus session mode shortcuts and styling.
22 24
23 ### Fixed25 ### Fixed
24 - **Alt/Ctrl+number shortcuts on non-QWERTY keyboards**: Fixed Alt+1-9/0 tab switching and Ctrl+1-9/0 omnibox shortcuts not working on AZERTY, QWERTZ, Dvorak, and other non-QWERTY keyboard layouts. Uses hardware keycodes as fallback when keyval lookup fails, enabling shortcuts to work based on physical key position regardless of layout.26 - **Alt/Ctrl+number shortcuts on non-QWERTY keyboards**: Fixed Alt+1-9/0 tab switching and Ctrl+1-9/0 omnibox shortcuts not working on AZERTY, QWERTZ, Dvorak, and other non-QWERTY keyboard layouts. Uses hardware keycodes as fallback when keyval lookup fails, enabling shortcuts to work based on physical key position regardless of layout.
M docs/CONFIG.md +6 -6
@@ -285,8 +285,8 @@ cancel = ["escape"]
285285 |-----|------|---------|-------------|
286286 | `session.auto_restore` | bool | `false` | Automatically restore the last session on startup |
287287 | `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds |
288-| `session.max_exited_sessions` | int | `10` | Maximum number of exited sessions to keep |
289-| `session.max_listed_sessions` | int | `50` | Maximum number of sessions to list in CLI/TUI |
288+| `session.max_exited_sessions` | int | `50` | Maximum number of exited sessions to keep |
289+| `session.max_exited_session_age_days` | int | `7` | Maximum age in days for exited sessions (auto-deleted on startup) |
290290
291291 ### Session Mode
292292
@@ -307,10 +307,10 @@ cancel = ["escape"]
307307 **Example:**
308308 ```toml
309309 [session]
310-auto_restore = false # Don't auto-restore on startup
311-snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced)
312-max_exited_sessions = 10 # Keep last 10 exited sessions
313-max_listed_sessions = 50 # Show up to 50 sessions in CLI/TUI
310+auto_restore = false # Don't auto-restore on startup
311+snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced)
312+max_exited_sessions = 50 # Keep last 50 exited sessions
313+max_exited_session_age_days = 7 # Delete sessions older than 7 days on startup
314314 ```
315315
316316 ## Media
@@ -285,8 +285,8 @@ cancel = ["escape"]
285 |-----|------|---------|-------------|285 |-----|------|---------|-------------|
286 | `session.auto_restore` | bool | `false` | Automatically restore the last session on startup |286 | `session.auto_restore` | bool | `false` | Automatically restore the last session on startup |
287 | `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds |287 | `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds |
288-| `session.max_exited_sessions` | int | `10` | Maximum number of exited sessions to keep |288+| `session.max_exited_sessions` | int | `50` | Maximum number of exited sessions to keep |
289-| `session.max_listed_sessions` | int | `50` | Maximum number of sessions to list in CLI/TUI |289+| `session.max_exited_session_age_days` | int | `7` | Maximum age in days for exited sessions (auto-deleted on startup) |
290 290
291 ### Session Mode291 ### Session Mode
292 292
@@ -307,10 +307,10 @@ cancel = ["escape"]
307 **Example:**307 **Example:**
308 ```toml308 ```toml
309 [session]309 [session]
310-auto_restore = false # Don't auto-restore on startup310+auto_restore = false # Don't auto-restore on startup
311-snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced)311+snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced)
312-max_exited_sessions = 10 # Keep last 10 exited sessions312+max_exited_sessions = 50 # Keep last 50 exited sessions
313-max_listed_sessions = 50 # Show up to 50 sessions in CLI/TUI313+max_exited_session_age_days = 7 # Delete sessions older than 7 days on startup
314 ```314 ```
315 315
316 ## Media316 ## Media
A internal/application/usecase/cleanup_sessions.go +98 -0
@@ -0,0 +1,98 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/repository"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// CleanupSessionsUseCase handles automatic cleanup of old exited sessions.
12+type CleanupSessionsUseCase struct {
13+ sessionRepo repository.SessionRepository
14+}
15+
16+// NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase.
17+func NewCleanupSessionsUseCase(sessionRepo repository.SessionRepository) *CleanupSessionsUseCase {
18+ return &CleanupSessionsUseCase{
19+ sessionRepo: sessionRepo,
20+ }
21+}
22+
23+// CleanupSessionsInput contains the cleanup configuration.
24+type CleanupSessionsInput struct {
25+ // MaxExitedSessions is the maximum number of exited sessions to keep.
26+ // Sessions beyond this count will be deleted (oldest first).
27+ MaxExitedSessions int
28+
29+ // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
30+ // Sessions older than this will be deleted.
31+ // A value of 0 disables age-based cleanup.
32+ MaxExitedSessionAgeDays int
33+}
34+
35+// CleanupSessionsOutput contains the cleanup results.
36+type CleanupSessionsOutput struct {
37+ // DeletedByAge is the number of sessions deleted due to age.
38+ DeletedByAge int64
39+
40+ // DeletedByCount is the number of sessions deleted due to count limit.
41+ DeletedByCount int64
42+
43+ // TotalDeleted is the total number of sessions deleted.
44+ TotalDeleted int64
45+}
46+
47+// Execute cleans up old exited sessions based on the provided configuration.
48+// It first deletes sessions older than MaxExitedSessionAgeDays,
49+// then enforces the MaxExitedSessions count limit.
50+func (uc *CleanupSessionsUseCase) Execute(ctx context.Context, input CleanupSessionsInput) (CleanupSessionsOutput, error) {
51+ log := logging.FromContext(ctx)
52+ output := CleanupSessionsOutput{}
53+
54+ // 1. Delete by age first (if enabled)
55+ if input.MaxExitedSessionAgeDays > 0 {
56+ cutoff := time.Now().AddDate(0, 0, -input.MaxExitedSessionAgeDays)
57+ deleted, err := uc.sessionRepo.DeleteExitedBefore(ctx, cutoff)
58+ if err != nil {
59+ log.Warn().Err(err).Msg("failed to delete sessions by age")
60+ } else {
61+ output.DeletedByAge = deleted
62+ if deleted > 0 {
63+ log.Info().
64+ Int64("deleted", deleted).
65+ Int("max_age_days", input.MaxExitedSessionAgeDays).
66+ Msg("cleaned up old sessions by age")
67+ }
68+ }
69+ }
70+
71+ // 2. Enforce count limit on remaining sessions
72+ if input.MaxExitedSessions >= 0 {
73+ deleted, err := uc.sessionRepo.DeleteOldestExited(ctx, input.MaxExitedSessions)
74+ if err != nil {
75+ log.Warn().Err(err).Msg("failed to delete sessions by count")
76+ } else {
77+ output.DeletedByCount = deleted
78+ if deleted > 0 {
79+ log.Info().
80+ Int64("deleted", deleted).
81+ Int("max_count", input.MaxExitedSessions).
82+ Msg("cleaned up old sessions by count")
83+ }
84+ }
85+ }
86+
87+ output.TotalDeleted = output.DeletedByAge + output.DeletedByCount
88+
89+ if output.TotalDeleted > 0 {
90+ log.Info().
91+ Int64("total_deleted", output.TotalDeleted).
92+ Int64("by_age", output.DeletedByAge).
93+ Int64("by_count", output.DeletedByCount).
94+ Msg("session cleanup completed")
95+ }
96+
97+ return output, nil
98+}
@@ -0,0 +1,98 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/repository"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// CleanupSessionsUseCase handles automatic cleanup of old exited sessions.
12+type CleanupSessionsUseCase struct {
13+ sessionRepo repository.SessionRepository
14+}
15+
16+// NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase.
17+func NewCleanupSessionsUseCase(sessionRepo repository.SessionRepository) *CleanupSessionsUseCase {
18+ return &CleanupSessionsUseCase{
19+ sessionRepo: sessionRepo,
20+ }
21+}
22+
23+// CleanupSessionsInput contains the cleanup configuration.
24+type CleanupSessionsInput struct {
25+ // MaxExitedSessions is the maximum number of exited sessions to keep.
26+ // Sessions beyond this count will be deleted (oldest first).
27+ MaxExitedSessions int
28+
29+ // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
30+ // Sessions older than this will be deleted.
31+ // A value of 0 disables age-based cleanup.
32+ MaxExitedSessionAgeDays int
33+}
34+
35+// CleanupSessionsOutput contains the cleanup results.
36+type CleanupSessionsOutput struct {
37+ // DeletedByAge is the number of sessions deleted due to age.
38+ DeletedByAge int64
39+
40+ // DeletedByCount is the number of sessions deleted due to count limit.
41+ DeletedByCount int64
42+
43+ // TotalDeleted is the total number of sessions deleted.
44+ TotalDeleted int64
45+}
46+
47+// Execute cleans up old exited sessions based on the provided configuration.
48+// It first deletes sessions older than MaxExitedSessionAgeDays,
49+// then enforces the MaxExitedSessions count limit.
50+func (uc *CleanupSessionsUseCase) Execute(ctx context.Context, input CleanupSessionsInput) (CleanupSessionsOutput, error) {
51+ log := logging.FromContext(ctx)
52+ output := CleanupSessionsOutput{}
53+
54+ // 1. Delete by age first (if enabled)
55+ if input.MaxExitedSessionAgeDays > 0 {
56+ cutoff := time.Now().AddDate(0, 0, -input.MaxExitedSessionAgeDays)
57+ deleted, err := uc.sessionRepo.DeleteExitedBefore(ctx, cutoff)
58+ if err != nil {
59+ log.Warn().Err(err).Msg("failed to delete sessions by age")
60+ } else {
61+ output.DeletedByAge = deleted
62+ if deleted > 0 {
63+ log.Info().
64+ Int64("deleted", deleted).
65+ Int("max_age_days", input.MaxExitedSessionAgeDays).
66+ Msg("cleaned up old sessions by age")
67+ }
68+ }
69+ }
70+
71+ // 2. Enforce count limit on remaining sessions
72+ if input.MaxExitedSessions >= 0 {
73+ deleted, err := uc.sessionRepo.DeleteOldestExited(ctx, input.MaxExitedSessions)
74+ if err != nil {
75+ log.Warn().Err(err).Msg("failed to delete sessions by count")
76+ } else {
77+ output.DeletedByCount = deleted
78+ if deleted > 0 {
79+ log.Info().
80+ Int64("deleted", deleted).
81+ Int("max_count", input.MaxExitedSessions).
82+ Msg("cleaned up old sessions by count")
83+ }
84+ }
85+ }
86+
87+ output.TotalDeleted = output.DeletedByAge + output.DeletedByCount
88+
89+ if output.TotalDeleted > 0 {
90+ log.Info().
91+ Int64("total_deleted", output.TotalDeleted).
92+ Int64("by_age", output.DeletedByAge).
93+ Int64("by_count", output.DeletedByCount).
94+ Msg("session cleanup completed")
95+ }
96+
97+ return output, nil
98+}
A internal/application/usecase/cleanup_sessions_test.go +224 -0
@@ -0,0 +1,224 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) {
16+ ctx := testContext()
17+
18+ sessionRepo := repomocks.NewMockSessionRepository(t)
19+
20+ // Expect age-based deletion
21+ sessionRepo.EXPECT().
22+ DeleteExitedBefore(ctx, mock.MatchedBy(func(cutoff time.Time) bool {
23+ // Should be approximately 7 days ago
24+ expected := time.Now().AddDate(0, 0, -7)
25+ diff := expected.Sub(cutoff)
26+ return diff > -time.Minute && diff < time.Minute
27+ })).
28+ Return(int64(3), nil)
29+
30+ // Expect count-based deletion
31+ sessionRepo.EXPECT().
32+ DeleteOldestExited(ctx, 50).
33+ Return(int64(2), nil)
34+
35+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
36+
37+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
38+ MaxExitedSessions: 50,
39+ MaxExitedSessionAgeDays: 7,
40+ })
41+
42+ require.NoError(t, err)
43+ assert.Equal(t, int64(3), output.DeletedByAge)
44+ assert.Equal(t, int64(2), output.DeletedByCount)
45+ assert.Equal(t, int64(5), output.TotalDeleted)
46+}
47+
48+func TestCleanupSessionsUseCase_Execute_AgeOnlyCleanup(t *testing.T) {
49+ ctx := testContext()
50+
51+ sessionRepo := repomocks.NewMockSessionRepository(t)
52+
53+ // Expect age-based deletion
54+ sessionRepo.EXPECT().
55+ DeleteExitedBefore(ctx, mock.Anything).
56+ Return(int64(5), nil)
57+
58+ // Count-based with -1 should be skipped (disabled)
59+ // MaxExitedSessions >= 0 triggers count cleanup, so use -1 to disable
60+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
61+
62+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
63+ MaxExitedSessions: -1, // Disabled
64+ MaxExitedSessionAgeDays: 30,
65+ })
66+
67+ require.NoError(t, err)
68+ assert.Equal(t, int64(5), output.DeletedByAge)
69+ assert.Equal(t, int64(0), output.DeletedByCount)
70+ assert.Equal(t, int64(5), output.TotalDeleted)
71+}
72+
73+func TestCleanupSessionsUseCase_Execute_CountOnlyCleanup(t *testing.T) {
74+ ctx := testContext()
75+
76+ sessionRepo := repomocks.NewMockSessionRepository(t)
77+
78+ // Count-based deletion only (age disabled with 0)
79+ sessionRepo.EXPECT().
80+ DeleteOldestExited(ctx, 10).
81+ Return(int64(4), nil)
82+
83+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
84+
85+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
86+ MaxExitedSessions: 10,
87+ MaxExitedSessionAgeDays: 0, // Disabled
88+ })
89+
90+ require.NoError(t, err)
91+ assert.Equal(t, int64(0), output.DeletedByAge)
92+ assert.Equal(t, int64(4), output.DeletedByCount)
93+ assert.Equal(t, int64(4), output.TotalDeleted)
94+}
95+
96+func TestCleanupSessionsUseCase_Execute_NoCleanupNeeded(t *testing.T) {
97+ ctx := testContext()
98+
99+ sessionRepo := repomocks.NewMockSessionRepository(t)
100+
101+ // Nothing to delete
102+ sessionRepo.EXPECT().
103+ DeleteExitedBefore(ctx, mock.Anything).
104+ Return(int64(0), nil)
105+
106+ sessionRepo.EXPECT().
107+ DeleteOldestExited(ctx, 50).
108+ Return(int64(0), nil)
109+
110+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
111+
112+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
113+ MaxExitedSessions: 50,
114+ MaxExitedSessionAgeDays: 7,
115+ })
116+
117+ require.NoError(t, err)
118+ assert.Equal(t, int64(0), output.DeletedByAge)
119+ assert.Equal(t, int64(0), output.DeletedByCount)
120+ assert.Equal(t, int64(0), output.TotalDeleted)
121+}
122+
123+func TestCleanupSessionsUseCase_Execute_AgeDeleteError_ContinuesToCount(t *testing.T) {
124+ ctx := testContext()
125+
126+ sessionRepo := repomocks.NewMockSessionRepository(t)
127+
128+ // Age-based deletion fails
129+ sessionRepo.EXPECT().
130+ DeleteExitedBefore(ctx, mock.Anything).
131+ Return(int64(0), errors.New("db error"))
132+
133+ // Should still attempt count-based deletion
134+ sessionRepo.EXPECT().
135+ DeleteOldestExited(ctx, 50).
136+ Return(int64(3), nil)
137+
138+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
139+
140+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
141+ MaxExitedSessions: 50,
142+ MaxExitedSessionAgeDays: 7,
143+ })
144+
145+ // Should not return error, just log warning
146+ require.NoError(t, err)
147+ assert.Equal(t, int64(0), output.DeletedByAge)
148+ assert.Equal(t, int64(3), output.DeletedByCount)
149+ assert.Equal(t, int64(3), output.TotalDeleted)
150+}
151+
152+func TestCleanupSessionsUseCase_Execute_CountDeleteError(t *testing.T) {
153+ ctx := testContext()
154+
155+ sessionRepo := repomocks.NewMockSessionRepository(t)
156+
157+ // Age-based deletion succeeds
158+ sessionRepo.EXPECT().
159+ DeleteExitedBefore(ctx, mock.Anything).
160+ Return(int64(2), nil)
161+
162+ // Count-based deletion fails
163+ sessionRepo.EXPECT().
164+ DeleteOldestExited(ctx, 50).
165+ Return(int64(0), errors.New("db error"))
166+
167+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
168+
169+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
170+ MaxExitedSessions: 50,
171+ MaxExitedSessionAgeDays: 7,
172+ })
173+
174+ // Should not return error, just log warning
175+ require.NoError(t, err)
176+ assert.Equal(t, int64(2), output.DeletedByAge)
177+ assert.Equal(t, int64(0), output.DeletedByCount)
178+ assert.Equal(t, int64(2), output.TotalDeleted)
179+}
180+
181+func TestCleanupSessionsUseCase_Execute_BothDisabled(t *testing.T) {
182+ ctx := testContext()
183+
184+ sessionRepo := repomocks.NewMockSessionRepository(t)
185+
186+ // No expectations - nothing should be called
187+ // Actually, count with 0 means "keep 0" which will trigger cleanup
188+ // So we need -1 for count and 0 for age to fully disable
189+
190+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
191+
192+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
193+ MaxExitedSessions: -1, // Disabled
194+ MaxExitedSessionAgeDays: 0, // Disabled
195+ })
196+
197+ require.NoError(t, err)
198+ assert.Equal(t, int64(0), output.DeletedByAge)
199+ assert.Equal(t, int64(0), output.DeletedByCount)
200+ assert.Equal(t, int64(0), output.TotalDeleted)
201+}
202+
203+func TestCleanupSessionsUseCase_Execute_ZeroMaxSessions(t *testing.T) {
204+ ctx := testContext()
205+
206+ sessionRepo := repomocks.NewMockSessionRepository(t)
207+
208+ // MaxExitedSessions = 0 means keep 0 sessions (delete all)
209+ sessionRepo.EXPECT().
210+ DeleteOldestExited(ctx, 0).
211+ Return(int64(10), nil)
212+
213+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
214+
215+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
216+ MaxExitedSessions: 0, // Keep 0 = delete all
217+ MaxExitedSessionAgeDays: 0, // Disabled
218+ })
219+
220+ require.NoError(t, err)
221+ assert.Equal(t, int64(0), output.DeletedByAge)
222+ assert.Equal(t, int64(10), output.DeletedByCount)
223+ assert.Equal(t, int64(10), output.TotalDeleted)
224+}
@@ -0,0 +1,224 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) {
16+ ctx := testContext()
17+
18+ sessionRepo := repomocks.NewMockSessionRepository(t)
19+
20+ // Expect age-based deletion
21+ sessionRepo.EXPECT().
22+ DeleteExitedBefore(ctx, mock.MatchedBy(func(cutoff time.Time) bool {
23+ // Should be approximately 7 days ago
24+ expected := time.Now().AddDate(0, 0, -7)
25+ diff := expected.Sub(cutoff)
26+ return diff > -time.Minute && diff < time.Minute
27+ })).
28+ Return(int64(3), nil)
29+
30+ // Expect count-based deletion
31+ sessionRepo.EXPECT().
32+ DeleteOldestExited(ctx, 50).
33+ Return(int64(2), nil)
34+
35+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
36+
37+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
38+ MaxExitedSessions: 50,
39+ MaxExitedSessionAgeDays: 7,
40+ })
41+
42+ require.NoError(t, err)
43+ assert.Equal(t, int64(3), output.DeletedByAge)
44+ assert.Equal(t, int64(2), output.DeletedByCount)
45+ assert.Equal(t, int64(5), output.TotalDeleted)
46+}
47+
48+func TestCleanupSessionsUseCase_Execute_AgeOnlyCleanup(t *testing.T) {
49+ ctx := testContext()
50+
51+ sessionRepo := repomocks.NewMockSessionRepository(t)
52+
53+ // Expect age-based deletion
54+ sessionRepo.EXPECT().
55+ DeleteExitedBefore(ctx, mock.Anything).
56+ Return(int64(5), nil)
57+
58+ // Count-based with -1 should be skipped (disabled)
59+ // MaxExitedSessions >= 0 triggers count cleanup, so use -1 to disable
60+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
61+
62+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
63+ MaxExitedSessions: -1, // Disabled
64+ MaxExitedSessionAgeDays: 30,
65+ })
66+
67+ require.NoError(t, err)
68+ assert.Equal(t, int64(5), output.DeletedByAge)
69+ assert.Equal(t, int64(0), output.DeletedByCount)
70+ assert.Equal(t, int64(5), output.TotalDeleted)
71+}
72+
73+func TestCleanupSessionsUseCase_Execute_CountOnlyCleanup(t *testing.T) {
74+ ctx := testContext()
75+
76+ sessionRepo := repomocks.NewMockSessionRepository(t)
77+
78+ // Count-based deletion only (age disabled with 0)
79+ sessionRepo.EXPECT().
80+ DeleteOldestExited(ctx, 10).
81+ Return(int64(4), nil)
82+
83+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
84+
85+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
86+ MaxExitedSessions: 10,
87+ MaxExitedSessionAgeDays: 0, // Disabled
88+ })
89+
90+ require.NoError(t, err)
91+ assert.Equal(t, int64(0), output.DeletedByAge)
92+ assert.Equal(t, int64(4), output.DeletedByCount)
93+ assert.Equal(t, int64(4), output.TotalDeleted)
94+}
95+
96+func TestCleanupSessionsUseCase_Execute_NoCleanupNeeded(t *testing.T) {
97+ ctx := testContext()
98+
99+ sessionRepo := repomocks.NewMockSessionRepository(t)
100+
101+ // Nothing to delete
102+ sessionRepo.EXPECT().
103+ DeleteExitedBefore(ctx, mock.Anything).
104+ Return(int64(0), nil)
105+
106+ sessionRepo.EXPECT().
107+ DeleteOldestExited(ctx, 50).
108+ Return(int64(0), nil)
109+
110+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
111+
112+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
113+ MaxExitedSessions: 50,
114+ MaxExitedSessionAgeDays: 7,
115+ })
116+
117+ require.NoError(t, err)
118+ assert.Equal(t, int64(0), output.DeletedByAge)
119+ assert.Equal(t, int64(0), output.DeletedByCount)
120+ assert.Equal(t, int64(0), output.TotalDeleted)
121+}
122+
123+func TestCleanupSessionsUseCase_Execute_AgeDeleteError_ContinuesToCount(t *testing.T) {
124+ ctx := testContext()
125+
126+ sessionRepo := repomocks.NewMockSessionRepository(t)
127+
128+ // Age-based deletion fails
129+ sessionRepo.EXPECT().
130+ DeleteExitedBefore(ctx, mock.Anything).
131+ Return(int64(0), errors.New("db error"))
132+
133+ // Should still attempt count-based deletion
134+ sessionRepo.EXPECT().
135+ DeleteOldestExited(ctx, 50).
136+ Return(int64(3), nil)
137+
138+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
139+
140+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
141+ MaxExitedSessions: 50,
142+ MaxExitedSessionAgeDays: 7,
143+ })
144+
145+ // Should not return error, just log warning
146+ require.NoError(t, err)
147+ assert.Equal(t, int64(0), output.DeletedByAge)
148+ assert.Equal(t, int64(3), output.DeletedByCount)
149+ assert.Equal(t, int64(3), output.TotalDeleted)
150+}
151+
152+func TestCleanupSessionsUseCase_Execute_CountDeleteError(t *testing.T) {
153+ ctx := testContext()
154+
155+ sessionRepo := repomocks.NewMockSessionRepository(t)
156+
157+ // Age-based deletion succeeds
158+ sessionRepo.EXPECT().
159+ DeleteExitedBefore(ctx, mock.Anything).
160+ Return(int64(2), nil)
161+
162+ // Count-based deletion fails
163+ sessionRepo.EXPECT().
164+ DeleteOldestExited(ctx, 50).
165+ Return(int64(0), errors.New("db error"))
166+
167+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
168+
169+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
170+ MaxExitedSessions: 50,
171+ MaxExitedSessionAgeDays: 7,
172+ })
173+
174+ // Should not return error, just log warning
175+ require.NoError(t, err)
176+ assert.Equal(t, int64(2), output.DeletedByAge)
177+ assert.Equal(t, int64(0), output.DeletedByCount)
178+ assert.Equal(t, int64(2), output.TotalDeleted)
179+}
180+
181+func TestCleanupSessionsUseCase_Execute_BothDisabled(t *testing.T) {
182+ ctx := testContext()
183+
184+ sessionRepo := repomocks.NewMockSessionRepository(t)
185+
186+ // No expectations - nothing should be called
187+ // Actually, count with 0 means "keep 0" which will trigger cleanup
188+ // So we need -1 for count and 0 for age to fully disable
189+
190+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
191+
192+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
193+ MaxExitedSessions: -1, // Disabled
194+ MaxExitedSessionAgeDays: 0, // Disabled
195+ })
196+
197+ require.NoError(t, err)
198+ assert.Equal(t, int64(0), output.DeletedByAge)
199+ assert.Equal(t, int64(0), output.DeletedByCount)
200+ assert.Equal(t, int64(0), output.TotalDeleted)
201+}
202+
203+func TestCleanupSessionsUseCase_Execute_ZeroMaxSessions(t *testing.T) {
204+ ctx := testContext()
205+
206+ sessionRepo := repomocks.NewMockSessionRepository(t)
207+
208+ // MaxExitedSessions = 0 means keep 0 sessions (delete all)
209+ sessionRepo.EXPECT().
210+ DeleteOldestExited(ctx, 0).
211+ Return(int64(10), nil)
212+
213+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
214+
215+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
216+ MaxExitedSessions: 0, // Keep 0 = delete all
217+ MaxExitedSessionAgeDays: 0, // Disabled
218+ })
219+
220+ require.NoError(t, err)
221+ assert.Equal(t, int64(0), output.DeletedByAge)
222+ assert.Equal(t, int64(10), output.DeletedByCount)
223+ assert.Equal(t, int64(10), output.TotalDeleted)
224+}
M internal/bootstrap/session.go +14 -0
@@ -56,6 +56,20 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
5656 }
5757 }
5858
59+ // Clean up old exited sessions based on config limits
60+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
61+ cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
62+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
63+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
64+ })
65+ if err != nil {
66+ log.Warn().Err(err).Msg("failed to cleanup old sessions")
67+ } else if cleanupOutput.TotalDeleted > 0 {
68+ log.Info().
69+ Int64("deleted", cleanupOutput.TotalDeleted).
70+ Msg("cleaned up old sessions on startup")
71+ }
72+
5973 now := time.Now()
6074 out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
6175 Type: entity.SessionTypeBrowser,
@@ -56,6 +56,20 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
56 }56 }
57 }57 }
58 58
59+ // Clean up old exited sessions based on config limits
60+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
61+ cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
62+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
63+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
64+ })
65+ if err != nil {
66+ log.Warn().Err(err).Msg("failed to cleanup old sessions")
67+ } else if cleanupOutput.TotalDeleted > 0 {
68+ log.Info().
69+ Int64("deleted", cleanupOutput.TotalDeleted).
70+ Msg("cleaned up old sessions on startup")
71+ }
72+
59 now := time.Now()73 now := time.Now()
60 out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{74 out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
61 Type: entity.SessionTypeBrowser,75 Type: entity.SessionTypeBrowser,
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", IconSession)
387+ var detail string
388+ if enabled {
389+ detail = fmt.Sprintf("(%d inactive, %s)", len(m.Sessions), formatSize(m.SessionsSize))
390+ } else {
391+ detail = fmt.Sprintf("(%d inactive, included with Data)", len(m.Sessions))
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", IconSession)
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
M internal/domain/repository/mocks/mocks.go +132 -0
@@ -2803,6 +2803,138 @@ func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.C
28032803 return _c
28042804 }
28052805
2806+// DeleteExitedBefore provides a mock function for the type MockSessionRepository
2807+func (_mock *MockSessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
2808+ ret := _mock.Called(ctx, cutoff)
2809+
2810+ if len(ret) == 0 {
2811+ panic("no return value specified for DeleteExitedBefore")
2812+ }
2813+
2814+ var r0 int64
2815+ var r1 error
2816+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (int64, error)); ok {
2817+ return returnFunc(ctx, cutoff)
2818+ }
2819+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) int64); ok {
2820+ r0 = returnFunc(ctx, cutoff)
2821+ } else {
2822+ r0 = ret.Get(0).(int64)
2823+ }
2824+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
2825+ r1 = returnFunc(ctx, cutoff)
2826+ } else {
2827+ r1 = ret.Error(1)
2828+ }
2829+ return r0, r1
2830+}
2831+
2832+// MockSessionRepository_DeleteExitedBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExitedBefore'
2833+type MockSessionRepository_DeleteExitedBefore_Call struct {
2834+ *mock.Call
2835+}
2836+
2837+// DeleteExitedBefore is a helper method to define mock.On call
2838+// - ctx context.Context
2839+// - cutoff time.Time
2840+func (_e *MockSessionRepository_Expecter) DeleteExitedBefore(ctx interface{}, cutoff interface{}) *MockSessionRepository_DeleteExitedBefore_Call {
2841+ return &MockSessionRepository_DeleteExitedBefore_Call{Call: _e.mock.On("DeleteExitedBefore", ctx, cutoff)}
2842+}
2843+
2844+func (_c *MockSessionRepository_DeleteExitedBefore_Call) Run(run func(ctx context.Context, cutoff time.Time)) *MockSessionRepository_DeleteExitedBefore_Call {
2845+ _c.Call.Run(func(args mock.Arguments) {
2846+ var arg0 context.Context
2847+ if args[0] != nil {
2848+ arg0 = args[0].(context.Context)
2849+ }
2850+ var arg1 time.Time
2851+ if args[1] != nil {
2852+ arg1 = args[1].(time.Time)
2853+ }
2854+ run(
2855+ arg0,
2856+ arg1,
2857+ )
2858+ })
2859+ return _c
2860+}
2861+
2862+func (_c *MockSessionRepository_DeleteExitedBefore_Call) Return(n int64, err error) *MockSessionRepository_DeleteExitedBefore_Call {
2863+ _c.Call.Return(n, err)
2864+ return _c
2865+}
2866+
2867+func (_c *MockSessionRepository_DeleteExitedBefore_Call) RunAndReturn(run func(ctx context.Context, cutoff time.Time) (int64, error)) *MockSessionRepository_DeleteExitedBefore_Call {
2868+ _c.Call.Return(run)
2869+ return _c
2870+}
2871+
2872+// DeleteOldestExited provides a mock function for the type MockSessionRepository
2873+func (_mock *MockSessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
2874+ ret := _mock.Called(ctx, keepCount)
2875+
2876+ if len(ret) == 0 {
2877+ panic("no return value specified for DeleteOldestExited")
2878+ }
2879+
2880+ var r0 int64
2881+ var r1 error
2882+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int64, error)); ok {
2883+ return returnFunc(ctx, keepCount)
2884+ }
2885+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) int64); ok {
2886+ r0 = returnFunc(ctx, keepCount)
2887+ } else {
2888+ r0 = ret.Get(0).(int64)
2889+ }
2890+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2891+ r1 = returnFunc(ctx, keepCount)
2892+ } else {
2893+ r1 = ret.Error(1)
2894+ }
2895+ return r0, r1
2896+}
2897+
2898+// MockSessionRepository_DeleteOldestExited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteOldestExited'
2899+type MockSessionRepository_DeleteOldestExited_Call struct {
2900+ *mock.Call
2901+}
2902+
2903+// DeleteOldestExited is a helper method to define mock.On call
2904+// - ctx context.Context
2905+// - keepCount int
2906+func (_e *MockSessionRepository_Expecter) DeleteOldestExited(ctx interface{}, keepCount interface{}) *MockSessionRepository_DeleteOldestExited_Call {
2907+ return &MockSessionRepository_DeleteOldestExited_Call{Call: _e.mock.On("DeleteOldestExited", ctx, keepCount)}
2908+}
2909+
2910+func (_c *MockSessionRepository_DeleteOldestExited_Call) Run(run func(ctx context.Context, keepCount int)) *MockSessionRepository_DeleteOldestExited_Call {
2911+ _c.Call.Run(func(args mock.Arguments) {
2912+ var arg0 context.Context
2913+ if args[0] != nil {
2914+ arg0 = args[0].(context.Context)
2915+ }
2916+ var arg1 int
2917+ if args[1] != nil {
2918+ arg1 = args[1].(int)
2919+ }
2920+ run(
2921+ arg0,
2922+ arg1,
2923+ )
2924+ })
2925+ return _c
2926+}
2927+
2928+func (_c *MockSessionRepository_DeleteOldestExited_Call) Return(n int64, err error) *MockSessionRepository_DeleteOldestExited_Call {
2929+ _c.Call.Return(n, err)
2930+ return _c
2931+}
2932+
2933+func (_c *MockSessionRepository_DeleteOldestExited_Call) RunAndReturn(run func(ctx context.Context, keepCount int) (int64, error)) *MockSessionRepository_DeleteOldestExited_Call {
2934+ _c.Call.Return(run)
2935+ return _c
2936+}
2937+
28062938 // FindByID provides a mock function for the type MockSessionRepository
28072939 func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
28082940 ret := _mock.Called(ctx, id)
@@ -2803,6 +2803,138 @@ func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.C
2803 return _c2803 return _c
2804 }2804 }
2805 2805
2806+// DeleteExitedBefore provides a mock function for the type MockSessionRepository
2807+func (_mock *MockSessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
2808+ ret := _mock.Called(ctx, cutoff)
2809+
2810+ if len(ret) == 0 {
2811+ panic("no return value specified for DeleteExitedBefore")
2812+ }
2813+
2814+ var r0 int64
2815+ var r1 error
2816+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (int64, error)); ok {
2817+ return returnFunc(ctx, cutoff)
2818+ }
2819+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) int64); ok {
2820+ r0 = returnFunc(ctx, cutoff)
2821+ } else {
2822+ r0 = ret.Get(0).(int64)
2823+ }
2824+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
2825+ r1 = returnFunc(ctx, cutoff)
2826+ } else {
2827+ r1 = ret.Error(1)
2828+ }
2829+ return r0, r1
2830+}
2831+
2832+// MockSessionRepository_DeleteExitedBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExitedBefore'
2833+type MockSessionRepository_DeleteExitedBefore_Call struct {
2834+ *mock.Call
2835+}
2836+
2837+// DeleteExitedBefore is a helper method to define mock.On call
2838+// - ctx context.Context
2839+// - cutoff time.Time
2840+func (_e *MockSessionRepository_Expecter) DeleteExitedBefore(ctx interface{}, cutoff interface{}) *MockSessionRepository_DeleteExitedBefore_Call {
2841+ return &MockSessionRepository_DeleteExitedBefore_Call{Call: _e.mock.On("DeleteExitedBefore", ctx, cutoff)}
2842+}
2843+
2844+func (_c *MockSessionRepository_DeleteExitedBefore_Call) Run(run func(ctx context.Context, cutoff time.Time)) *MockSessionRepository_DeleteExitedBefore_Call {
2845+ _c.Call.Run(func(args mock.Arguments) {
2846+ var arg0 context.Context
2847+ if args[0] != nil {
2848+ arg0 = args[0].(context.Context)
2849+ }
2850+ var arg1 time.Time
2851+ if args[1] != nil {
2852+ arg1 = args[1].(time.Time)
2853+ }
2854+ run(
2855+ arg0,
2856+ arg1,
2857+ )
2858+ })
2859+ return _c
2860+}
2861+
2862+func (_c *MockSessionRepository_DeleteExitedBefore_Call) Return(n int64, err error) *MockSessionRepository_DeleteExitedBefore_Call {
2863+ _c.Call.Return(n, err)
2864+ return _c
2865+}
2866+
2867+func (_c *MockSessionRepository_DeleteExitedBefore_Call) RunAndReturn(run func(ctx context.Context, cutoff time.Time) (int64, error)) *MockSessionRepository_DeleteExitedBefore_Call {
2868+ _c.Call.Return(run)
2869+ return _c
2870+}
2871+
2872+// DeleteOldestExited provides a mock function for the type MockSessionRepository
2873+func (_mock *MockSessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
2874+ ret := _mock.Called(ctx, keepCount)
2875+
2876+ if len(ret) == 0 {
2877+ panic("no return value specified for DeleteOldestExited")
2878+ }
2879+
2880+ var r0 int64
2881+ var r1 error
2882+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int64, error)); ok {
2883+ return returnFunc(ctx, keepCount)
2884+ }
2885+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) int64); ok {
2886+ r0 = returnFunc(ctx, keepCount)
2887+ } else {
2888+ r0 = ret.Get(0).(int64)
2889+ }
2890+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2891+ r1 = returnFunc(ctx, keepCount)
2892+ } else {
2893+ r1 = ret.Error(1)
2894+ }
2895+ return r0, r1
2896+}
2897+
2898+// MockSessionRepository_DeleteOldestExited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteOldestExited'
2899+type MockSessionRepository_DeleteOldestExited_Call struct {
2900+ *mock.Call
2901+}
2902+
2903+// DeleteOldestExited is a helper method to define mock.On call
2904+// - ctx context.Context
2905+// - keepCount int
2906+func (_e *MockSessionRepository_Expecter) DeleteOldestExited(ctx interface{}, keepCount interface{}) *MockSessionRepository_DeleteOldestExited_Call {
2907+ return &MockSessionRepository_DeleteOldestExited_Call{Call: _e.mock.On("DeleteOldestExited", ctx, keepCount)}
2908+}
2909+
2910+func (_c *MockSessionRepository_DeleteOldestExited_Call) Run(run func(ctx context.Context, keepCount int)) *MockSessionRepository_DeleteOldestExited_Call {
2911+ _c.Call.Run(func(args mock.Arguments) {
2912+ var arg0 context.Context
2913+ if args[0] != nil {
2914+ arg0 = args[0].(context.Context)
2915+ }
2916+ var arg1 int
2917+ if args[1] != nil {
2918+ arg1 = args[1].(int)
2919+ }
2920+ run(
2921+ arg0,
2922+ arg1,
2923+ )
2924+ })
2925+ return _c
2926+}
2927+
2928+func (_c *MockSessionRepository_DeleteOldestExited_Call) Return(n int64, err error) *MockSessionRepository_DeleteOldestExited_Call {
2929+ _c.Call.Return(n, err)
2930+ return _c
2931+}
2932+
2933+func (_c *MockSessionRepository_DeleteOldestExited_Call) RunAndReturn(run func(ctx context.Context, keepCount int) (int64, error)) *MockSessionRepository_DeleteOldestExited_Call {
2934+ _c.Call.Return(run)
2935+ return _c
2936+}
2937+
2806 // FindByID provides a mock function for the type MockSessionRepository2938 // FindByID provides a mock function for the type MockSessionRepository
2807 func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {2939 func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
2808 ret := _mock.Called(ctx, id)2940 ret := _mock.Called(ctx, id)
M internal/domain/repository/session.go +8 -0
@@ -19,4 +19,12 @@ type SessionRepository interface {
1919
2020 // Delete removes a session record.
2121 Delete(ctx context.Context, id entity.SessionID) error
22+
23+ // DeleteOldestExited deletes exited sessions beyond the keep limit.
24+ // Returns number of deleted sessions.
25+ DeleteOldestExited(ctx context.Context, keepCount int) (int64, error)
26+
27+ // DeleteExitedBefore deletes exited sessions older than the cutoff time.
28+ // Returns number of deleted sessions.
29+ DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error)
2230 }
@@ -19,4 +19,12 @@ type SessionRepository interface {
19 19
20 // Delete removes a session record.20 // Delete removes a session record.
21 Delete(ctx context.Context, id entity.SessionID) error21 Delete(ctx context.Context, id entity.SessionID) error
22+
23+ // DeleteOldestExited deletes exited sessions beyond the keep limit.
24+ // Returns number of deleted sessions.
25+ DeleteOldestExited(ctx context.Context, keepCount int) (int64, error)
26+
27+ // DeleteExitedBefore deletes exited sessions older than the cutoff time.
28+ // Returns number of deleted sessions.
29+ DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error)
22 }30 }
M internal/infrastructure/config/defaults.go +6 -6
@@ -30,8 +30,8 @@ const (
3030 defaultSessionActivationShortcut = "ctrl+o"
3131 defaultSessionTimeoutMilliseconds = 3000
3232 defaultSnapshotIntervalMs = 5000
33- defaultMaxExitedSessions = 10
34- defaultMaxListedSessions = 50
33+ defaultMaxExitedSessions = 50
34+ defaultMaxExitedSessionAgeDays = 7
3535
3636 // Workspace styling defaults
3737 // Active pane border (overlay)
@@ -210,10 +210,10 @@ func DefaultConfig() *Config {
210210 InitialBehavior: defaultOmniboxInitialBehavior,
211211 },
212212 Session: SessionConfig{
213- AutoRestore: false,
214- SnapshotIntervalMs: defaultSnapshotIntervalMs,
215- MaxExitedSessions: defaultMaxExitedSessions,
216- MaxListedSessions: defaultMaxListedSessions,
213+ AutoRestore: false,
214+ SnapshotIntervalMs: defaultSnapshotIntervalMs,
215+ MaxExitedSessions: defaultMaxExitedSessions,
216+ MaxExitedSessionAgeDays: defaultMaxExitedSessionAgeDays,
217217 SessionMode: SessionModeConfig{
218218 ActivationShortcut: defaultSessionActivationShortcut,
219219 TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,
@@ -30,8 +30,8 @@ const (
30 defaultSessionActivationShortcut = "ctrl+o"30 defaultSessionActivationShortcut = "ctrl+o"
31 defaultSessionTimeoutMilliseconds = 300031 defaultSessionTimeoutMilliseconds = 3000
32 defaultSnapshotIntervalMs = 500032 defaultSnapshotIntervalMs = 5000
33- defaultMaxExitedSessions = 1033+ defaultMaxExitedSessions = 50
34- defaultMaxListedSessions = 5034+ defaultMaxExitedSessionAgeDays = 7
35 35
36 // Workspace styling defaults36 // Workspace styling defaults
37 // Active pane border (overlay)37 // Active pane border (overlay)
@@ -210,10 +210,10 @@ func DefaultConfig() *Config {
210 InitialBehavior: defaultOmniboxInitialBehavior,210 InitialBehavior: defaultOmniboxInitialBehavior,
211 },211 },
212 Session: SessionConfig{212 Session: SessionConfig{
213- AutoRestore: false,213+ AutoRestore: false,
214- SnapshotIntervalMs: defaultSnapshotIntervalMs,214+ SnapshotIntervalMs: defaultSnapshotIntervalMs,
215- MaxExitedSessions: defaultMaxExitedSessions,215+ MaxExitedSessions: defaultMaxExitedSessions,
216- MaxListedSessions: defaultMaxListedSessions,216+ MaxExitedSessionAgeDays: defaultMaxExitedSessionAgeDays,
217 SessionMode: SessionModeConfig{217 SessionMode: SessionModeConfig{
218 ActivationShortcut: defaultSessionActivationShortcut,218 ActivationShortcut: defaultSessionActivationShortcut,
219 TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,219 TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,
M internal/infrastructure/config/loader.go +1 -1
@@ -437,7 +437,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) {
437437 m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
438438 m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
439439 m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
440- m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions)
440+ m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays)
441441 m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
442442 m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
443443 m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
@@ -437,7 +437,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) {
437 m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)437 m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
438 m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)438 m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
439 m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)439 m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
440- m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions)440+ m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays)
441 m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)441 m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
442 m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)442 m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
443 m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)443 m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
M internal/infrastructure/config/schema.go +5 -4
@@ -437,12 +437,13 @@ type SessionConfig struct {
437437 SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
438438
439439 // MaxExitedSessions is the maximum number of exited sessions to keep.
440- // Default: 10
440+ // Default: 50
441441 MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
442442
443- // MaxListedSessions is the maximum number of sessions to list in CLI/TUI.
444- // Default: 50
445- MaxListedSessions int `mapstructure:"max_listed_sessions" yaml:"max_listed_sessions" toml:"max_listed_sessions" json:"max_listed_sessions"` //nolint:lll // struct tags must stay on one line
443+ // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
444+ // Sessions older than this will be automatically deleted on startup.
445+ // Default: 7
446+ MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
446447
447448 // SessionMode defines modal behavior for session management (Ctrl+O).
448449 SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
@@ -437,12 +437,13 @@ type SessionConfig struct {
437 SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line437 SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
438 438
439 // MaxExitedSessions is the maximum number of exited sessions to keep.439 // MaxExitedSessions is the maximum number of exited sessions to keep.
440- // Default: 10440+ // Default: 50
441 MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line441 MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
442 442
443- // MaxListedSessions is the maximum number of sessions to list in CLI/TUI.443+ // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
444- // Default: 50444+ // Sessions older than this will be automatically deleted on startup.
445- MaxListedSessions int `mapstructure:"max_listed_sessions" yaml:"max_listed_sessions" toml:"max_listed_sessions" json:"max_listed_sessions"` //nolint:lll // struct tags must stay on one line445+ // Default: 7
446+ MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
446 447
447 // SessionMode defines modal behavior for session management (Ctrl+O).448 // SessionMode defines modal behavior for session management (Ctrl+O).
448 SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`449 SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
M internal/infrastructure/config/validation.go +15 -0
@@ -24,6 +24,7 @@ func validateConfig(config *Config) error {
2424 validationErrors = append(validationErrors, validateOmnibox(config)...)
2525 validationErrors = append(validationErrors, validateRendering(config)...)
2626 validationErrors = append(validationErrors, validateColorScheme(config)...)
27+ validationErrors = append(validationErrors, validateSession(config)...)
2728
2829 // If there are validation errors, return them
2930 if len(validationErrors) > 0 {
@@ -301,3 +302,17 @@ func validateColorScheme(config *Config) []string {
301302 )}
302303 }
303304 }
305+
306+func validateSession(config *Config) []string {
307+ var validationErrors []string
308+ if config.Session.MaxExitedSessions < 0 {
309+ validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
310+ }
311+ if config.Session.MaxExitedSessionAgeDays < 0 {
312+ validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
313+ }
314+ if config.Session.SnapshotIntervalMs < 0 {
315+ validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
316+ }
317+ return validationErrors
318+}
@@ -24,6 +24,7 @@ func validateConfig(config *Config) error {
24 validationErrors = append(validationErrors, validateOmnibox(config)...)24 validationErrors = append(validationErrors, validateOmnibox(config)...)
25 validationErrors = append(validationErrors, validateRendering(config)...)25 validationErrors = append(validationErrors, validateRendering(config)...)
26 validationErrors = append(validationErrors, validateColorScheme(config)...)26 validationErrors = append(validationErrors, validateColorScheme(config)...)
27+ validationErrors = append(validationErrors, validateSession(config)...)
27 28
28 // If there are validation errors, return them29 // If there are validation errors, return them
29 if len(validationErrors) > 0 {30 if len(validationErrors) > 0 {
@@ -301,3 +302,17 @@ func validateColorScheme(config *Config) []string {
301 )}302 )}
302 }303 }
303 }304 }
305+
306+func validateSession(config *Config) []string {
307+ var validationErrors []string
308+ if config.Session.MaxExitedSessions < 0 {
309+ validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
310+ }
311+ if config.Session.MaxExitedSessionAgeDays < 0 {
312+ validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
313+ }
314+ if config.Session.SnapshotIntervalMs < 0 {
315+ validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
316+ }
317+ return validationErrors
318+}
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +17 -0
@@ -23,3 +23,20 @@ WHERE id = ?;
2323
2424 -- name: DeleteSession :exec
2525 DELETE FROM sessions WHERE id = ?;
26+
27+-- name: DeleteOldestExitedSessions :execrows
28+-- Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
29+DELETE FROM sessions
30+WHERE id IN (
31+ SELECT id FROM sessions
32+ WHERE type = 'browser' AND ended_at IS NOT NULL
33+ ORDER BY ended_at DESC
34+ LIMIT -1 OFFSET ?
35+);
36+
37+-- name: DeleteExitedSessionsBefore :execrows
38+-- Deletes exited browser sessions older than the given cutoff time.
39+DELETE FROM sessions
40+WHERE type = 'browser'
41+ AND ended_at IS NOT NULL
42+ AND ended_at < ?;
@@ -23,3 +23,20 @@ WHERE id = ?;
23 23
24 -- name: DeleteSession :exec24 -- name: DeleteSession :exec
25 DELETE FROM sessions WHERE id = ?;25 DELETE FROM sessions WHERE id = ?;
26+
27+-- name: DeleteOldestExitedSessions :execrows
28+-- Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
29+DELETE FROM sessions
30+WHERE id IN (
31+ SELECT id FROM sessions
32+ WHERE type = 'browser' AND ended_at IS NOT NULL
33+ ORDER BY ended_at DESC
34+ LIMIT -1 OFFSET ?
35+);
36+
37+-- name: DeleteExitedSessionsBefore :execrows
38+-- Deletes exited browser sessions older than the given cutoff time.
39+DELETE FROM sessions
40+WHERE type = 'browser'
41+ AND ended_at IS NOT NULL
42+ AND ended_at < ?;
M internal/infrastructure/persistence/sqlite/session_repo.go +27 -0
@@ -93,6 +93,33 @@ func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error {
9393 return r.queries.DeleteSession(ctx, string(id))
9494 }
9595
96+func (r *sessionRepo) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
97+ log := logging.FromContext(ctx)
98+ if keepCount < 0 {
99+ keepCount = 0
100+ }
101+ deleted, err := r.queries.DeleteOldestExitedSessions(ctx, int64(keepCount))
102+ if err != nil {
103+ return 0, err
104+ }
105+ if deleted > 0 {
106+ log.Debug().Int64("deleted", deleted).Int("keep_count", keepCount).Msg("deleted oldest exited sessions")
107+ }
108+ return deleted, nil
109+}
110+
111+func (r *sessionRepo) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
112+ log := logging.FromContext(ctx)
113+ deleted, err := r.queries.DeleteExitedSessionsBefore(ctx, sql.NullTime{Time: cutoff.UTC(), Valid: true})
114+ if err != nil {
115+ return 0, err
116+ }
117+ if deleted > 0 {
118+ log.Debug().Int64("deleted", deleted).Time("cutoff", cutoff).Msg("deleted exited sessions before cutoff")
119+ }
120+ return deleted, nil
121+}
122+
96123 func sessionFromRow(row sqlc.Session) *entity.Session {
97124 var endedAt *time.Time
98125 if row.EndedAt.Valid {
@@ -93,6 +93,33 @@ func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error {
93 return r.queries.DeleteSession(ctx, string(id))93 return r.queries.DeleteSession(ctx, string(id))
94 }94 }
95 95
96+func (r *sessionRepo) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
97+ log := logging.FromContext(ctx)
98+ if keepCount < 0 {
99+ keepCount = 0
100+ }
101+ deleted, err := r.queries.DeleteOldestExitedSessions(ctx, int64(keepCount))
102+ if err != nil {
103+ return 0, err
104+ }
105+ if deleted > 0 {
106+ log.Debug().Int64("deleted", deleted).Int("keep_count", keepCount).Msg("deleted oldest exited sessions")
107+ }
108+ return deleted, nil
109+}
110+
111+func (r *sessionRepo) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
112+ log := logging.FromContext(ctx)
113+ deleted, err := r.queries.DeleteExitedSessionsBefore(ctx, sql.NullTime{Time: cutoff.UTC(), Valid: true})
114+ if err != nil {
115+ return 0, err
116+ }
117+ if deleted > 0 {
118+ log.Debug().Int64("deleted", deleted).Time("cutoff", cutoff).Msg("deleted exited sessions before cutoff")
119+ }
120+ return deleted, nil
121+}
122+
96 func sessionFromRow(row sqlc.Session) *entity.Session {123 func sessionFromRow(row sqlc.Session) *entity.Session {
97 var endedAt *time.Time124 var endedAt *time.Time
98 if row.EndedAt.Valid {125 if row.EndedAt.Valid {
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +4 -0
@@ -16,11 +16,15 @@ type Querier interface {
1616 CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)
1717 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)
1818 DeleteAllHistory(ctx context.Context) error
19+ // Deletes exited browser sessions older than the given cutoff time.
20+ DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
1921 DeleteFavorite(ctx context.Context, id int64) error
2022 DeleteFolder(ctx context.Context, id int64) error
2123 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
2224 DeleteHistoryByID(ctx context.Context, id int64) error
2325 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
26+ // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
27+ DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)
2428 DeleteSession(ctx context.Context, id string) error
2529 DeleteSessionState(ctx context.Context, sessionID string) error
2630 DeleteTag(ctx context.Context, id int64) error
@@ -16,11 +16,15 @@ type Querier interface {
16 CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)16 CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error)
17 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)17 CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error)
18 DeleteAllHistory(ctx context.Context) error18 DeleteAllHistory(ctx context.Context) error
19+ // Deletes exited browser sessions older than the given cutoff time.
20+ DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
19 DeleteFavorite(ctx context.Context, id int64) error21 DeleteFavorite(ctx context.Context, id int64) error
20 DeleteFolder(ctx context.Context, id int64) error22 DeleteFolder(ctx context.Context, id int64) error
21 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error23 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
22 DeleteHistoryByID(ctx context.Context, id int64) error24 DeleteHistoryByID(ctx context.Context, id int64) error
23 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error25 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
26+ // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
27+ DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)
24 DeleteSession(ctx context.Context, id string) error28 DeleteSession(ctx context.Context, id string) error
25 DeleteSessionState(ctx context.Context, sessionID string) error29 DeleteSessionState(ctx context.Context, sessionID string) error
26 DeleteTag(ctx context.Context, id int64) error30 DeleteTag(ctx context.Context, id int64) error
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +35 -0
@@ -11,6 +11,41 @@ import (
1111 "time"
1212 )
1313
14+const DeleteExitedSessionsBefore = `-- name: DeleteExitedSessionsBefore :execrows
15+DELETE FROM sessions
16+WHERE type = 'browser'
17+ AND ended_at IS NOT NULL
18+ AND ended_at < ?
19+`
20+
21+// Deletes exited browser sessions older than the given cutoff time.
22+func (q *Queries) DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) {
23+ result, err := q.db.ExecContext(ctx, DeleteExitedSessionsBefore, endedAt)
24+ if err != nil {
25+ return 0, err
26+ }
27+ return result.RowsAffected()
28+}
29+
30+const DeleteOldestExitedSessions = `-- name: DeleteOldestExitedSessions :execrows
31+DELETE FROM sessions
32+WHERE id IN (
33+ SELECT id FROM sessions
34+ WHERE type = 'browser' AND ended_at IS NOT NULL
35+ ORDER BY ended_at DESC
36+ LIMIT -1 OFFSET ?
37+)
38+`
39+
40+// Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
41+func (q *Queries) DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) {
42+ result, err := q.db.ExecContext(ctx, DeleteOldestExitedSessions, offset)
43+ if err != nil {
44+ return 0, err
45+ }
46+ return result.RowsAffected()
47+}
48+
1449 const DeleteSession = `-- name: DeleteSession :exec
1550 DELETE FROM sessions WHERE id = ?
1651 `
@@ -11,6 +11,41 @@ import (
11 "time"11 "time"
12 )12 )
13 13
14+const DeleteExitedSessionsBefore = `-- name: DeleteExitedSessionsBefore :execrows
15+DELETE FROM sessions
16+WHERE type = 'browser'
17+ AND ended_at IS NOT NULL
18+ AND ended_at < ?
19+`
20+
21+// Deletes exited browser sessions older than the given cutoff time.
22+func (q *Queries) DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) {
23+ result, err := q.db.ExecContext(ctx, DeleteExitedSessionsBefore, endedAt)
24+ if err != nil {
25+ return 0, err
26+ }
27+ return result.RowsAffected()
28+}
29+
30+const DeleteOldestExitedSessions = `-- name: DeleteOldestExitedSessions :execrows
31+DELETE FROM sessions
32+WHERE id IN (
33+ SELECT id FROM sessions
34+ WHERE type = 'browser' AND ended_at IS NOT NULL
35+ ORDER BY ended_at DESC
36+ LIMIT -1 OFFSET ?
37+)
38+`
39+
40+// Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
41+func (q *Queries) DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) {
42+ result, err := q.db.ExecContext(ctx, DeleteOldestExitedSessions, offset)
43+ if err != nil {
44+ return 0, err
45+ }
46+ return result.RowsAffected()
47+}
48+
14 const DeleteSession = `-- name: DeleteSession :exec49 const DeleteSession = `-- name: DeleteSession :exec
15 DELETE FROM sessions WHERE id = ?50 DELETE FROM sessions WHERE id = ?
16 `51 `
M internal/ui/app.go +33 -0
@@ -796,6 +796,11 @@ func (a *App) initCoordinators(ctx context.Context) {
796796 a.navCoord.RecordHistory(ctx, paneID, url)
797797 })
798798
799+ // Wire window title updates when active pane's title changes
800+ a.contentCoord.SetOnWindowTitleChanged(func(title string) {
801+ a.updateWindowTitle(title)
802+ })
803+
799804 // Wire pane URI updates for session snapshots (searches all tabs)
800805 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
801806 a.updatePaneURIInAllTabs(paneID, url)
@@ -875,6 +880,31 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int {
875880 return app.Run(ctx, os.Args)
876881 }
877882
883+// updateWindowTitle updates the window title with the given page title.
884+// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
885+func (a *App) updateWindowTitle(pageTitle string) {
886+ if a.mainWindow == nil {
887+ return
888+ }
889+
890+ title := "Dumber"
891+ if pageTitle != "" {
892+ title = pageTitle + " - Dumber"
893+ }
894+ a.mainWindow.SetTitle(title)
895+}
896+
897+// updateWindowTitleFromActivePane updates the window title based on the current active pane.
898+func (a *App) updateWindowTitleFromActivePane() {
899+ ws := a.activeWorkspace()
900+ if ws == nil || a.contentCoord == nil {
901+ a.updateWindowTitle("")
902+ return
903+ }
904+ title := a.contentCoord.GetTitle(ws.ActivePaneID)
905+ a.updateWindowTitle(title)
906+}
907+
878908 // handleModeChange is called when the input mode changes.
879909 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
880910 log := logging.FromContext(ctx)
@@ -1043,6 +1073,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
10431073 a.mainWindow.SetContent(gtkWidget)
10441074 }
10451075
1076+ // Update window title with the new active pane's title
1077+ a.updateWindowTitleFromActivePane()
1078+
10461079 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
10471080 }
10481081
@@ -796,6 +796,11 @@ func (a *App) initCoordinators(ctx context.Context) {
796 a.navCoord.RecordHistory(ctx, paneID, url)796 a.navCoord.RecordHistory(ctx, paneID, url)
797 })797 })
798 798
799+ // Wire window title updates when active pane's title changes
800+ a.contentCoord.SetOnWindowTitleChanged(func(title string) {
801+ a.updateWindowTitle(title)
802+ })
803+
799 // Wire pane URI updates for session snapshots (searches all tabs)804 // Wire pane URI updates for session snapshots (searches all tabs)
800 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {805 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
801 a.updatePaneURIInAllTabs(paneID, url)806 a.updatePaneURIInAllTabs(paneID, url)
@@ -875,6 +880,31 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int {
875 return app.Run(ctx, os.Args)880 return app.Run(ctx, os.Args)
876 }881 }
877 882
883+// updateWindowTitle updates the window title with the given page title.
884+// Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
885+func (a *App) updateWindowTitle(pageTitle string) {
886+ if a.mainWindow == nil {
887+ return
888+ }
889+
890+ title := "Dumber"
891+ if pageTitle != "" {
892+ title = pageTitle + " - Dumber"
893+ }
894+ a.mainWindow.SetTitle(title)
895+}
896+
897+// updateWindowTitleFromActivePane updates the window title based on the current active pane.
898+func (a *App) updateWindowTitleFromActivePane() {
899+ ws := a.activeWorkspace()
900+ if ws == nil || a.contentCoord == nil {
901+ a.updateWindowTitle("")
902+ return
903+ }
904+ title := a.contentCoord.GetTitle(ws.ActivePaneID)
905+ a.updateWindowTitle(title)
906+}
907+
878 // handleModeChange is called when the input mode changes.908 // handleModeChange is called when the input mode changes.
879 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {909 func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
880 log := logging.FromContext(ctx)910 log := logging.FromContext(ctx)
@@ -1043,6 +1073,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
1043 a.mainWindow.SetContent(gtkWidget)1073 a.mainWindow.SetContent(gtkWidget)
1044 }1074 }
1045 1075
1076+ // Update window title with the new active pane's title
1077+ a.updateWindowTitleFromActivePane()
1078+
1046 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")1079 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
1047 }1080 }
1048 1081
M internal/ui/coordinator/content.go +19 -1
@@ -49,6 +49,9 @@ type ContentCoordinator struct {
4949 // Callback when pane URI changes (for session snapshots)
5050 onPaneURIUpdated func(paneID entity.PaneID, url string)
5151
52+ // Callback when active pane title changes (for window title updates)
53+ onWindowTitleChanged func(title string)
54+
5255 // Gesture action handler for mouse button navigation
5356 gestureActionHandler input.ActionHandler
5457
@@ -111,6 +114,11 @@ func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, u
111114 c.onPaneURIUpdated = fn
112115 }
113116
117+// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
118+func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) {
119+ c.onWindowTitleChanged = fn
120+}
121+
114122 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
115123 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
116124 c.gestureActionHandler = handler
@@ -483,13 +491,18 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
483491 c.paneTitles[paneID] = title
484492 c.titleMu.Unlock()
485493
486- // Update domain model
494+ // Update domain model and check if this is the active pane
495+ isActivePaneTitle := false
487496 ws, wsView := c.getActiveWS()
488497 if ws != nil {
489498 paneNode := ws.FindPane(paneID)
490499 if paneNode != nil && paneNode.Pane != nil {
491500 paneNode.Pane.Title = title
492501 }
502+ // Check if this pane is the active one
503+ if ws.ActivePaneID == paneID {
504+ isActivePaneTitle = true
505+ }
493506 }
494507
495508 // Update StackedView title bar if this pane is in a stack
@@ -513,6 +526,11 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
513526 }
514527 }
515528
529+ // Notify window title update if this is the active pane
530+ if isActivePaneTitle && c.onWindowTitleChanged != nil {
531+ c.onWindowTitleChanged(title)
532+ }
533+
516534 log.Debug().
517535 Str("pane_id", string(paneID)).
518536 Str("title", title).
@@ -49,6 +49,9 @@ type ContentCoordinator struct {
49 // Callback when pane URI changes (for session snapshots)49 // Callback when pane URI changes (for session snapshots)
50 onPaneURIUpdated func(paneID entity.PaneID, url string)50 onPaneURIUpdated func(paneID entity.PaneID, url string)
51 51
52+ // Callback when active pane title changes (for window title updates)
53+ onWindowTitleChanged func(title string)
54+
52 // Gesture action handler for mouse button navigation55 // Gesture action handler for mouse button navigation
53 gestureActionHandler input.ActionHandler56 gestureActionHandler input.ActionHandler
54 57
@@ -111,6 +114,11 @@ func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, u
111 c.onPaneURIUpdated = fn114 c.onPaneURIUpdated = fn
112 }115 }
113 116
117+// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
118+func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) {
119+ c.onWindowTitleChanged = fn
120+}
121+
114 // SetGestureActionHandler sets the callback for mouse button navigation gestures.122 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
115 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {123 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
116 c.gestureActionHandler = handler124 c.gestureActionHandler = handler
@@ -483,13 +491,18 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
483 c.paneTitles[paneID] = title491 c.paneTitles[paneID] = title
484 c.titleMu.Unlock()492 c.titleMu.Unlock()
485 493
486- // Update domain model494+ // Update domain model and check if this is the active pane
495+ isActivePaneTitle := false
487 ws, wsView := c.getActiveWS()496 ws, wsView := c.getActiveWS()
488 if ws != nil {497 if ws != nil {
489 paneNode := ws.FindPane(paneID)498 paneNode := ws.FindPane(paneID)
490 if paneNode != nil && paneNode.Pane != nil {499 if paneNode != nil && paneNode.Pane != nil {
491 paneNode.Pane.Title = title500 paneNode.Pane.Title = title
492 }501 }
502+ // Check if this pane is the active one
503+ if ws.ActivePaneID == paneID {
504+ isActivePaneTitle = true
505+ }
493 }506 }
494 507
495 // Update StackedView title bar if this pane is in a stack508 // Update StackedView title bar if this pane is in a stack
@@ -513,6 +526,11 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
513 }526 }
514 }527 }
515 528
529+ // Notify window title update if this is the active pane
530+ if isActivePaneTitle && c.onWindowTitleChanged != nil {
531+ c.onWindowTitleChanged(title)
532+ }
533+
516 log.Debug().534 log.Debug().
517 Str("pane_id", string(paneID)).535 Str("pane_id", string(paneID)).
518 Str("title", title).536 Str("title", title).
M internal/ui/window/main_window.go +15 -0
@@ -170,6 +170,21 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow {
170170 return mw.window
171171 }
172172
173+// SetTitle updates the window title with proper formatting.
174+// The title is capped at 255 characters for display.
175+func (mw *MainWindow) SetTitle(title string) {
176+ if mw.window == nil {
177+ return
178+ }
179+ // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8)
180+ const maxTitleLen = 255
181+ runes := []rune(title)
182+ if len(runes) > maxTitleLen {
183+ title = string(runes[:maxTitleLen-3]) + "..."
184+ }
185+ mw.window.SetTitle(&title)
186+}
187+
173188 // AddOverlay adds a widget to the content overlay.
174189 // The widget will be displayed on top of the workspace content.
175190 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
@@ -170,6 +170,21 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow {
170 return mw.window170 return mw.window
171 }171 }
172 172
173+// SetTitle updates the window title with proper formatting.
174+// The title is capped at 255 characters for display.
175+func (mw *MainWindow) SetTitle(title string) {
176+ if mw.window == nil {
177+ return
178+ }
179+ // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8)
180+ const maxTitleLen = 255
181+ runes := []rune(title)
182+ if len(runes) > maxTitleLen {
183+ title = string(runes[:maxTitleLen-3]) + "..."
184+ }
185+ mw.window.SetTitle(&title)
186+}
187+
173 // AddOverlay adds a widget to the content overlay.188 // AddOverlay adds a widget to the content overlay.
174 // The widget will be displayed on top of the workspace content.189 // The widget will be displayed on top of the workspace content.
175 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {190 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {

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