detached 795138f

feat(usecase): implement purgeable sessions retrieval

commit
795138fe5411acbb040a1d7202cf3ee35d9e6465
author
brice · · 2025-12-25 10:17 UTC
parent
de0b075
signature
unsigned
2 files · +221 -0
M internal/application/usecase/list_sessions.go +43 -0
@@ -211,3 +211,46 @@ func GetRelativeTime(t time.Time) string {
211211 func formatDuration(n int, unit string) string {
212212 return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"
213213 }
214+
215+// PurgeableSessionsOutput contains inactive sessions that can be purged.
216+type PurgeableSessionsOutput struct {
217+ Sessions []entity.SessionPurgeItem
218+ TotalSize int64
219+}
220+
221+// GetPurgeableSessions returns all inactive sessions that can be safely deleted.
222+// These are sessions that have ended and have no active lock file.
223+func (uc *ListSessionsUseCase) GetPurgeableSessions(ctx context.Context) (*PurgeableSessionsOutput, error) {
224+ log := logging.FromContext(ctx)
225+
226+ // Get all sessions (no current session in CLI context)
227+ output, err := uc.Execute(ctx, "", maxPurgeableSessions)
228+ if err != nil {
229+ return nil, err
230+ }
231+
232+ // Filter to only inactive sessions
233+ var purgeable []entity.SessionPurgeItem
234+ for _, info := range output.Sessions {
235+ if !info.IsActive {
236+ purgeable = append(purgeable, entity.SessionPurgeItem{
237+ Info: info,
238+ Selected: true, // Default to selected
239+ })
240+ }
241+ }
242+
243+ // Get total size of all snapshots
244+ totalSize, err := uc.stateRepo.GetTotalSnapshotsSize(ctx)
245+ if err != nil {
246+ log.Warn().Err(err).Msg("failed to get total snapshots size")
247+ totalSize = 0
248+ }
249+
250+ return &PurgeableSessionsOutput{
251+ Sessions: purgeable,
252+ TotalSize: totalSize,
253+ }, nil
254+}
255+
256+const maxPurgeableSessions = 1000
@@ -211,3 +211,46 @@ func GetRelativeTime(t time.Time) string {
211 func formatDuration(n int, unit string) string {211 func formatDuration(n int, unit string) string {
212 return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"212 return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"
213 }213 }
214+
215+// PurgeableSessionsOutput contains inactive sessions that can be purged.
216+type PurgeableSessionsOutput struct {
217+ Sessions []entity.SessionPurgeItem
218+ TotalSize int64
219+}
220+
221+// GetPurgeableSessions returns all inactive sessions that can be safely deleted.
222+// These are sessions that have ended and have no active lock file.
223+func (uc *ListSessionsUseCase) GetPurgeableSessions(ctx context.Context) (*PurgeableSessionsOutput, error) {
224+ log := logging.FromContext(ctx)
225+
226+ // Get all sessions (no current session in CLI context)
227+ output, err := uc.Execute(ctx, "", maxPurgeableSessions)
228+ if err != nil {
229+ return nil, err
230+ }
231+
232+ // Filter to only inactive sessions
233+ var purgeable []entity.SessionPurgeItem
234+ for _, info := range output.Sessions {
235+ if !info.IsActive {
236+ purgeable = append(purgeable, entity.SessionPurgeItem{
237+ Info: info,
238+ Selected: true, // Default to selected
239+ })
240+ }
241+ }
242+
243+ // Get total size of all snapshots
244+ totalSize, err := uc.stateRepo.GetTotalSnapshotsSize(ctx)
245+ if err != nil {
246+ log.Warn().Err(err).Msg("failed to get total snapshots size")
247+ totalSize = 0
248+ }
249+
250+ return &PurgeableSessionsOutput{
251+ Sessions: purgeable,
252+ TotalSize: totalSize,
253+ }, nil
254+}
255+
256+const maxPurgeableSessions = 1000
A internal/application/usecase/list_sessions_test.go +178 -0
@@ -0,0 +1,178 @@
1+package usecase_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) {
15+ ctx := testContext()
16+
17+ sessionRepo := repomocks.NewMockSessionRepository(t)
18+ stateRepo := repomocks.NewMockSessionStateRepository(t)
19+
20+ endedAt := time.Now().Add(-time.Hour)
21+ sessions := []*entity.Session{
22+ {
23+ ID: "20251224_120000_abc1",
24+ Type: entity.SessionTypeBrowser,
25+ StartedAt: time.Now().Add(-2 * time.Hour),
26+ EndedAt: &endedAt,
27+ },
28+ {
29+ ID: "20251224_130000_def2",
30+ Type: entity.SessionTypeBrowser,
31+ StartedAt: time.Now().Add(-time.Hour),
32+ EndedAt: nil, // Active
33+ },
34+ }
35+
36+ states := []*entity.SessionState{
37+ {
38+ SessionID: "20251224_120000_abc1",
39+ SavedAt: time.Now().Add(-time.Hour),
40+ Tabs: []entity.TabSnapshot{
41+ {ID: "tab1"},
42+ {ID: "tab2"},
43+ },
44+ },
45+ }
46+
47+ sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)
48+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)
49+
50+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
51+
52+ output, err := uc.Execute(ctx, "", 50)
53+ require.NoError(t, err)
54+ require.NotNil(t, output)
55+ require.Len(t, output.Sessions, 2)
56+
57+ // Active session should be first (or current, but we have no current)
58+ // The sorting puts active sessions before inactive
59+ activeSession := output.Sessions[0]
60+ assert.True(t, activeSession.IsActive)
61+
62+ // Check session with state has tab count
63+ var sessionWithState *entity.SessionInfo
64+ for i := range output.Sessions {
65+ if output.Sessions[i].Session.ID == "20251224_120000_abc1" {
66+ sessionWithState = &output.Sessions[i]
67+ break
68+ }
69+ }
70+ require.NotNil(t, sessionWithState)
71+ assert.Equal(t, 2, sessionWithState.TabCount)
72+}
73+
74+func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing.T) {
75+ ctx := testContext()
76+
77+ sessionRepo := repomocks.NewMockSessionRepository(t)
78+ stateRepo := repomocks.NewMockSessionStateRepository(t)
79+
80+ endedAt := time.Now().Add(-time.Hour)
81+ sessions := []*entity.Session{
82+ {
83+ ID: "20251224_120000_inactive1",
84+ Type: entity.SessionTypeBrowser,
85+ StartedAt: time.Now().Add(-3 * time.Hour),
86+ EndedAt: &endedAt, // Ended - purgeable
87+ },
88+ {
89+ ID: "20251224_130000_active1",
90+ Type: entity.SessionTypeBrowser,
91+ StartedAt: time.Now().Add(-time.Hour),
92+ EndedAt: nil, // Active - NOT purgeable
93+ },
94+ {
95+ ID: "20251224_140000_inactive2",
96+ Type: entity.SessionTypeBrowser,
97+ StartedAt: time.Now().Add(-2 * time.Hour),
98+ EndedAt: &endedAt, // Ended - purgeable
99+ },
100+ }
101+
102+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
103+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
104+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)
105+
106+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
107+
108+ output, err := uc.GetPurgeableSessions(ctx)
109+ require.NoError(t, err)
110+ require.NotNil(t, output)
111+
112+ // Should only have 2 inactive sessions, not the active one
113+ require.Len(t, output.Sessions, 2)
114+ assert.Equal(t, int64(12345), output.TotalSize)
115+
116+ // Verify all returned sessions are inactive
117+ for _, s := range output.Sessions {
118+ assert.False(t, s.Info.IsActive, "session %s should not be active", s.Info.Session.ID)
119+ assert.True(t, s.Selected, "sessions should be selected by default")
120+ }
121+}
122+
123+func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.T) {
124+ ctx := testContext()
125+
126+ sessionRepo := repomocks.NewMockSessionRepository(t)
127+ stateRepo := repomocks.NewMockSessionStateRepository(t)
128+
129+ sessions := []*entity.Session{
130+ {
131+ ID: "20251224_130000_active1",
132+ Type: entity.SessionTypeBrowser,
133+ StartedAt: time.Now().Add(-time.Hour),
134+ EndedAt: nil, // Active
135+ },
136+ }
137+
138+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
139+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
140+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)
141+
142+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
143+
144+ output, err := uc.GetPurgeableSessions(ctx)
145+ require.NoError(t, err)
146+ require.NotNil(t, output)
147+ require.Empty(t, output.Sessions)
148+}
149+
150+func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing.T) {
151+ ctx := testContext()
152+
153+ sessionRepo := repomocks.NewMockSessionRepository(t)
154+ stateRepo := repomocks.NewMockSessionStateRepository(t)
155+
156+ endedAt := time.Now().Add(-time.Hour)
157+ sessions := []*entity.Session{
158+ {
159+ ID: "20251224_120000_inactive1",
160+ Type: entity.SessionTypeBrowser,
161+ StartedAt: time.Now().Add(-2 * time.Hour),
162+ EndedAt: &endedAt,
163+ },
164+ }
165+
166+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
167+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
168+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)
169+
170+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
171+
172+ output, err := uc.GetPurgeableSessions(ctx)
173+ // Should succeed even if size query fails
174+ require.NoError(t, err)
175+ require.NotNil(t, output)
176+ require.Len(t, output.Sessions, 1)
177+ assert.Equal(t, int64(0), output.TotalSize) // Size defaults to 0 on error
178+}
@@ -0,0 +1,178 @@
1+package usecase_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) {
15+ ctx := testContext()
16+
17+ sessionRepo := repomocks.NewMockSessionRepository(t)
18+ stateRepo := repomocks.NewMockSessionStateRepository(t)
19+
20+ endedAt := time.Now().Add(-time.Hour)
21+ sessions := []*entity.Session{
22+ {
23+ ID: "20251224_120000_abc1",
24+ Type: entity.SessionTypeBrowser,
25+ StartedAt: time.Now().Add(-2 * time.Hour),
26+ EndedAt: &endedAt,
27+ },
28+ {
29+ ID: "20251224_130000_def2",
30+ Type: entity.SessionTypeBrowser,
31+ StartedAt: time.Now().Add(-time.Hour),
32+ EndedAt: nil, // Active
33+ },
34+ }
35+
36+ states := []*entity.SessionState{
37+ {
38+ SessionID: "20251224_120000_abc1",
39+ SavedAt: time.Now().Add(-time.Hour),
40+ Tabs: []entity.TabSnapshot{
41+ {ID: "tab1"},
42+ {ID: "tab2"},
43+ },
44+ },
45+ }
46+
47+ sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)
48+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)
49+
50+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
51+
52+ output, err := uc.Execute(ctx, "", 50)
53+ require.NoError(t, err)
54+ require.NotNil(t, output)
55+ require.Len(t, output.Sessions, 2)
56+
57+ // Active session should be first (or current, but we have no current)
58+ // The sorting puts active sessions before inactive
59+ activeSession := output.Sessions[0]
60+ assert.True(t, activeSession.IsActive)
61+
62+ // Check session with state has tab count
63+ var sessionWithState *entity.SessionInfo
64+ for i := range output.Sessions {
65+ if output.Sessions[i].Session.ID == "20251224_120000_abc1" {
66+ sessionWithState = &output.Sessions[i]
67+ break
68+ }
69+ }
70+ require.NotNil(t, sessionWithState)
71+ assert.Equal(t, 2, sessionWithState.TabCount)
72+}
73+
74+func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing.T) {
75+ ctx := testContext()
76+
77+ sessionRepo := repomocks.NewMockSessionRepository(t)
78+ stateRepo := repomocks.NewMockSessionStateRepository(t)
79+
80+ endedAt := time.Now().Add(-time.Hour)
81+ sessions := []*entity.Session{
82+ {
83+ ID: "20251224_120000_inactive1",
84+ Type: entity.SessionTypeBrowser,
85+ StartedAt: time.Now().Add(-3 * time.Hour),
86+ EndedAt: &endedAt, // Ended - purgeable
87+ },
88+ {
89+ ID: "20251224_130000_active1",
90+ Type: entity.SessionTypeBrowser,
91+ StartedAt: time.Now().Add(-time.Hour),
92+ EndedAt: nil, // Active - NOT purgeable
93+ },
94+ {
95+ ID: "20251224_140000_inactive2",
96+ Type: entity.SessionTypeBrowser,
97+ StartedAt: time.Now().Add(-2 * time.Hour),
98+ EndedAt: &endedAt, // Ended - purgeable
99+ },
100+ }
101+
102+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
103+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
104+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)
105+
106+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
107+
108+ output, err := uc.GetPurgeableSessions(ctx)
109+ require.NoError(t, err)
110+ require.NotNil(t, output)
111+
112+ // Should only have 2 inactive sessions, not the active one
113+ require.Len(t, output.Sessions, 2)
114+ assert.Equal(t, int64(12345), output.TotalSize)
115+
116+ // Verify all returned sessions are inactive
117+ for _, s := range output.Sessions {
118+ assert.False(t, s.Info.IsActive, "session %s should not be active", s.Info.Session.ID)
119+ assert.True(t, s.Selected, "sessions should be selected by default")
120+ }
121+}
122+
123+func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.T) {
124+ ctx := testContext()
125+
126+ sessionRepo := repomocks.NewMockSessionRepository(t)
127+ stateRepo := repomocks.NewMockSessionStateRepository(t)
128+
129+ sessions := []*entity.Session{
130+ {
131+ ID: "20251224_130000_active1",
132+ Type: entity.SessionTypeBrowser,
133+ StartedAt: time.Now().Add(-time.Hour),
134+ EndedAt: nil, // Active
135+ },
136+ }
137+
138+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
139+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
140+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)
141+
142+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
143+
144+ output, err := uc.GetPurgeableSessions(ctx)
145+ require.NoError(t, err)
146+ require.NotNil(t, output)
147+ require.Empty(t, output.Sessions)
148+}
149+
150+func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing.T) {
151+ ctx := testContext()
152+
153+ sessionRepo := repomocks.NewMockSessionRepository(t)
154+ stateRepo := repomocks.NewMockSessionStateRepository(t)
155+
156+ endedAt := time.Now().Add(-time.Hour)
157+ sessions := []*entity.Session{
158+ {
159+ ID: "20251224_120000_inactive1",
160+ Type: entity.SessionTypeBrowser,
161+ StartedAt: time.Now().Add(-2 * time.Hour),
162+ EndedAt: &endedAt,
163+ },
164+ }
165+
166+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
167+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
168+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)
169+
170+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
171+
172+ output, err := uc.GetPurgeableSessions(ctx)
173+ // Should succeed even if size query fails
174+ require.NoError(t, err)
175+ require.NotNil(t, output)
176+ require.Len(t, output.Sessions, 1)
177+ assert.Equal(t, int64(0), output.TotalSize) // Size defaults to 0 on error
178+}

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