detached cd6e600

feat(session): add automatic session cleanup on startup

- Add max_exited_session_age_days config (default: 7 days)
- Increase max_exited_sessions default from 10 to 50
- Create CleanupSessionsUseCase for age and count-based cleanup
- Add DeleteOldestExited and DeleteExitedBefore repository methods
- Run cleanup on startup after marking stale sessions ended

Closes #28, closes #29
commit
cd6e6000eb545fee78011f85f47b2ad53f77ffb3
author
bnema · · 2025-12-25 18:17 UTC
parent
000ca5b
signature
GPG Unverified2B82207D370127A0 The key is unknown
12 files · +362 -11
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+}
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/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 `

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