detached 0ded357

feat(usecase): add session snapshot, restore, and list use cases

commit
0ded3572def7ec32e686962cf3697caef9df4738
author
bnema · · 2025-12-24 21:27 UTC
committer
brice · · 2025-12-25 05:18 UTC
parent
f1ba35b
signature
unsigned
5 files · +670 -0
A internal/application/usecase/list_sessions.go +221 -0
@@ -0,0 +1,221 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// ListSessionsUseCase handles listing sessions with their state information.
15+type ListSessionsUseCase struct {
16+ sessionRepo repository.SessionRepository
17+ stateRepo repository.SessionStateRepository
18+ lockDir string // Directory where lock files are stored
19+}
20+
21+// NewListSessionsUseCase creates a new ListSessionsUseCase.
22+func NewListSessionsUseCase(
23+ sessionRepo repository.SessionRepository,
24+ stateRepo repository.SessionStateRepository,
25+ lockDir string,
26+) *ListSessionsUseCase {
27+ return &ListSessionsUseCase{
28+ sessionRepo: sessionRepo,
29+ stateRepo: stateRepo,
30+ lockDir: lockDir,
31+ }
32+}
33+
34+// ListSessionsOutput contains the list of sessions with their info.
35+type ListSessionsOutput struct {
36+ Sessions []entity.SessionInfo
37+}
38+
39+// Execute returns a list of sessions with their state information.
40+// Sessions are sorted with active first, then by date (most recent first).
41+func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
42+ log := logging.FromContext(ctx)
43+
44+ if limit <= 0 {
45+ limit = 50
46+ }
47+
48+ sessions, err := uc.sessionRepo.GetRecent(ctx, limit)
49+ if err != nil {
50+ return nil, err
51+ }
52+
53+ // Get all snapshots
54+ snapshots, err := uc.stateRepo.GetAllSnapshots(ctx)
55+ if err != nil {
56+ log.Warn().Err(err).Msg("failed to get session snapshots")
57+ // Continue without snapshots
58+ snapshots = nil
59+ }
60+
61+ // Build snapshot lookup map
62+ snapshotMap := make(map[entity.SessionID]*entity.SessionState)
63+ for _, s := range snapshots {
64+ snapshotMap[s.SessionID] = s
65+ }
66+
67+ // Build session info list
68+ result := make([]entity.SessionInfo, 0, len(sessions))
69+ for _, session := range sessions {
70+ // Only include browser sessions
71+ if session.Type != entity.SessionTypeBrowser {
72+ continue
73+ }
74+
75+ info := entity.SessionInfo{
76+ Session: session,
77+ IsCurrent: session.ID == currentSessionID,
78+ IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
79+ }
80+
81+ if state, ok := snapshotMap[session.ID]; ok {
82+ info.State = state
83+ info.TabCount = len(state.Tabs)
84+ info.PaneCount = state.CountPanes()
85+ info.UpdatedAt = state.SavedAt
86+ } else {
87+ info.UpdatedAt = session.StartedAt
88+ }
89+
90+ result = append(result, info)
91+ }
92+
93+ // Sort: current first, then active, then by date
94+ sortSessionInfos(result, currentSessionID)
95+
96+ return &ListSessionsOutput{Sessions: result}, nil
97+}
98+
99+// hasActiveLock checks if a session has an active lock file.
100+func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
101+ if uc.lockDir == "" {
102+ return false
103+ }
104+
105+ lockPath := filepath.Join(uc.lockDir, string(sessionID)+".lock")
106+ _, err := os.Stat(lockPath)
107+ return err == nil
108+}
109+
110+// sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
111+func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
112+ // Simple bubble sort for small lists
113+ n := len(infos)
114+ for i := 0; i < n-1; i++ {
115+ for j := 0; j < n-i-1; j++ {
116+ if shouldSwap(infos[j], infos[j+1], currentID) {
117+ infos[j], infos[j+1] = infos[j+1], infos[j]
118+ }
119+ }
120+ }
121+}
122+
123+func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool {
124+ // Current session always first
125+ if a.Session.ID == currentID {
126+ return false
127+ }
128+ if b.Session.ID == currentID {
129+ return true
130+ }
131+
132+ // Active sessions before inactive
133+ if a.IsActive && !b.IsActive {
134+ return false
135+ }
136+ if !a.IsActive && b.IsActive {
137+ return true
138+ }
139+
140+ // Most recent first
141+ return a.UpdatedAt.Before(b.UpdatedAt)
142+}
143+
144+// GetSessionInfo returns info for a specific session.
145+func (uc *ListSessionsUseCase) GetSessionInfo(
146+ ctx context.Context,
147+ sessionID, currentSessionID entity.SessionID,
148+) (*entity.SessionInfo, error) {
149+ session, err := uc.sessionRepo.FindByID(ctx, sessionID)
150+ if err != nil {
151+ return nil, err
152+ }
153+ if session == nil {
154+ return nil, nil
155+ }
156+
157+ info := &entity.SessionInfo{
158+ Session: session,
159+ IsCurrent: session.ID == currentSessionID,
160+ IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
161+ UpdatedAt: session.StartedAt,
162+ }
163+
164+ state, err := uc.stateRepo.GetSnapshot(ctx, sessionID)
165+ if err != nil {
166+ logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state")
167+ }
168+ if state != nil {
169+ info.State = state
170+ info.TabCount = len(state.Tabs)
171+ info.PaneCount = state.CountPanes()
172+ info.UpdatedAt = state.SavedAt
173+ }
174+
175+ return info, nil
176+}
177+
178+const (
179+ hoursPerDay = 24
180+ daysPerWeek = 7
181+ maxIDDisplay = 20
182+)
183+
184+// GetRelativeTime returns a human-readable relative time string.
185+func GetRelativeTime(t time.Time) string {
186+ now := time.Now()
187+ diff := now.Sub(t)
188+
189+ switch {
190+ case diff < time.Minute:
191+ return "just now"
192+ case diff < time.Hour:
193+ mins := int(diff.Minutes())
194+ if mins == 1 {
195+ return "1m ago"
196+ }
197+ return formatDuration(mins, "m")
198+ case diff < hoursPerDay*time.Hour:
199+ hours := int(diff.Hours())
200+ if hours == 1 {
201+ return "1h ago"
202+ }
203+ return formatDuration(hours, "h")
204+ case diff < daysPerWeek*hoursPerDay*time.Hour:
205+ days := int(diff.Hours() / hoursPerDay)
206+ if days == 1 {
207+ return "1d ago"
208+ }
209+ return formatDuration(days, "d")
210+ default:
211+ weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
212+ if weeks == 1 {
213+ return "1w ago"
214+ }
215+ return formatDuration(weeks, "w")
216+ }
217+}
218+
219+func formatDuration(n int, unit string) string {
220+ return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"
221+}
@@ -0,0 +1,221 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// ListSessionsUseCase handles listing sessions with their state information.
15+type ListSessionsUseCase struct {
16+ sessionRepo repository.SessionRepository
17+ stateRepo repository.SessionStateRepository
18+ lockDir string // Directory where lock files are stored
19+}
20+
21+// NewListSessionsUseCase creates a new ListSessionsUseCase.
22+func NewListSessionsUseCase(
23+ sessionRepo repository.SessionRepository,
24+ stateRepo repository.SessionStateRepository,
25+ lockDir string,
26+) *ListSessionsUseCase {
27+ return &ListSessionsUseCase{
28+ sessionRepo: sessionRepo,
29+ stateRepo: stateRepo,
30+ lockDir: lockDir,
31+ }
32+}
33+
34+// ListSessionsOutput contains the list of sessions with their info.
35+type ListSessionsOutput struct {
36+ Sessions []entity.SessionInfo
37+}
38+
39+// Execute returns a list of sessions with their state information.
40+// Sessions are sorted with active first, then by date (most recent first).
41+func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
42+ log := logging.FromContext(ctx)
43+
44+ if limit <= 0 {
45+ limit = 50
46+ }
47+
48+ sessions, err := uc.sessionRepo.GetRecent(ctx, limit)
49+ if err != nil {
50+ return nil, err
51+ }
52+
53+ // Get all snapshots
54+ snapshots, err := uc.stateRepo.GetAllSnapshots(ctx)
55+ if err != nil {
56+ log.Warn().Err(err).Msg("failed to get session snapshots")
57+ // Continue without snapshots
58+ snapshots = nil
59+ }
60+
61+ // Build snapshot lookup map
62+ snapshotMap := make(map[entity.SessionID]*entity.SessionState)
63+ for _, s := range snapshots {
64+ snapshotMap[s.SessionID] = s
65+ }
66+
67+ // Build session info list
68+ result := make([]entity.SessionInfo, 0, len(sessions))
69+ for _, session := range sessions {
70+ // Only include browser sessions
71+ if session.Type != entity.SessionTypeBrowser {
72+ continue
73+ }
74+
75+ info := entity.SessionInfo{
76+ Session: session,
77+ IsCurrent: session.ID == currentSessionID,
78+ IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
79+ }
80+
81+ if state, ok := snapshotMap[session.ID]; ok {
82+ info.State = state
83+ info.TabCount = len(state.Tabs)
84+ info.PaneCount = state.CountPanes()
85+ info.UpdatedAt = state.SavedAt
86+ } else {
87+ info.UpdatedAt = session.StartedAt
88+ }
89+
90+ result = append(result, info)
91+ }
92+
93+ // Sort: current first, then active, then by date
94+ sortSessionInfos(result, currentSessionID)
95+
96+ return &ListSessionsOutput{Sessions: result}, nil
97+}
98+
99+// hasActiveLock checks if a session has an active lock file.
100+func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
101+ if uc.lockDir == "" {
102+ return false
103+ }
104+
105+ lockPath := filepath.Join(uc.lockDir, string(sessionID)+".lock")
106+ _, err := os.Stat(lockPath)
107+ return err == nil
108+}
109+
110+// sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
111+func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
112+ // Simple bubble sort for small lists
113+ n := len(infos)
114+ for i := 0; i < n-1; i++ {
115+ for j := 0; j < n-i-1; j++ {
116+ if shouldSwap(infos[j], infos[j+1], currentID) {
117+ infos[j], infos[j+1] = infos[j+1], infos[j]
118+ }
119+ }
120+ }
121+}
122+
123+func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool {
124+ // Current session always first
125+ if a.Session.ID == currentID {
126+ return false
127+ }
128+ if b.Session.ID == currentID {
129+ return true
130+ }
131+
132+ // Active sessions before inactive
133+ if a.IsActive && !b.IsActive {
134+ return false
135+ }
136+ if !a.IsActive && b.IsActive {
137+ return true
138+ }
139+
140+ // Most recent first
141+ return a.UpdatedAt.Before(b.UpdatedAt)
142+}
143+
144+// GetSessionInfo returns info for a specific session.
145+func (uc *ListSessionsUseCase) GetSessionInfo(
146+ ctx context.Context,
147+ sessionID, currentSessionID entity.SessionID,
148+) (*entity.SessionInfo, error) {
149+ session, err := uc.sessionRepo.FindByID(ctx, sessionID)
150+ if err != nil {
151+ return nil, err
152+ }
153+ if session == nil {
154+ return nil, nil
155+ }
156+
157+ info := &entity.SessionInfo{
158+ Session: session,
159+ IsCurrent: session.ID == currentSessionID,
160+ IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
161+ UpdatedAt: session.StartedAt,
162+ }
163+
164+ state, err := uc.stateRepo.GetSnapshot(ctx, sessionID)
165+ if err != nil {
166+ logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state")
167+ }
168+ if state != nil {
169+ info.State = state
170+ info.TabCount = len(state.Tabs)
171+ info.PaneCount = state.CountPanes()
172+ info.UpdatedAt = state.SavedAt
173+ }
174+
175+ return info, nil
176+}
177+
178+const (
179+ hoursPerDay = 24
180+ daysPerWeek = 7
181+ maxIDDisplay = 20
182+)
183+
184+// GetRelativeTime returns a human-readable relative time string.
185+func GetRelativeTime(t time.Time) string {
186+ now := time.Now()
187+ diff := now.Sub(t)
188+
189+ switch {
190+ case diff < time.Minute:
191+ return "just now"
192+ case diff < time.Hour:
193+ mins := int(diff.Minutes())
194+ if mins == 1 {
195+ return "1m ago"
196+ }
197+ return formatDuration(mins, "m")
198+ case diff < hoursPerDay*time.Hour:
199+ hours := int(diff.Hours())
200+ if hours == 1 {
201+ return "1h ago"
202+ }
203+ return formatDuration(hours, "h")
204+ case diff < daysPerWeek*hoursPerDay*time.Hour:
205+ days := int(diff.Hours() / hoursPerDay)
206+ if days == 1 {
207+ return "1d ago"
208+ }
209+ return formatDuration(days, "d")
210+ default:
211+ weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
212+ if weeks == 1 {
213+ return "1w ago"
214+ }
215+ return formatDuration(weeks, "w")
216+ }
217+}
218+
219+func formatDuration(n int, unit string) string {
220+ return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"
221+}
A internal/application/usecase/restore_session.go +87 -0
@@ -0,0 +1,87 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ErrSessionNotFound is returned when a session state cannot be found.
14+var ErrSessionNotFound = errors.New("session not found")
15+
16+// ErrVersionMismatch is returned when the session state version is incompatible.
17+var ErrVersionMismatch = errors.New("session state version mismatch")
18+
19+// RestoreSessionUseCase handles restoring session state from a snapshot.
20+type RestoreSessionUseCase struct {
21+ stateRepo repository.SessionStateRepository
22+ sessionRepo repository.SessionRepository
23+}
24+
25+// NewRestoreSessionUseCase creates a new RestoreSessionUseCase.
26+func NewRestoreSessionUseCase(
27+ stateRepo repository.SessionStateRepository,
28+ sessionRepo repository.SessionRepository,
29+) *RestoreSessionUseCase {
30+ return &RestoreSessionUseCase{
31+ stateRepo: stateRepo,
32+ sessionRepo: sessionRepo,
33+ }
34+}
35+
36+// RestoreInput contains the parameters for restoring a session.
37+type RestoreInput struct {
38+ SessionID entity.SessionID
39+}
40+
41+// RestoreOutput contains the restored session state.
42+type RestoreOutput struct {
43+ State *entity.SessionState
44+}
45+
46+// Execute loads and validates a session state for restoration.
47+func (uc *RestoreSessionUseCase) Execute(ctx context.Context, input RestoreInput) (*RestoreOutput, error) {
48+ log := logging.FromContext(ctx)
49+
50+ if input.SessionID == "" {
51+ return nil, fmt.Errorf("session id required")
52+ }
53+
54+ log.Info().
55+ Str("session_id", string(input.SessionID)).
56+ Msg("restoring session state")
57+
58+ state, err := uc.stateRepo.GetSnapshot(ctx, input.SessionID)
59+ if err != nil {
60+ return nil, fmt.Errorf("get session snapshot: %w", err)
61+ }
62+ if state == nil {
63+ return nil, ErrSessionNotFound
64+ }
65+
66+ // Validate version compatibility
67+ if state.Version > entity.SessionStateVersion {
68+ log.Warn().
69+ Int("state_version", state.Version).
70+ Int("current_version", entity.SessionStateVersion).
71+ Msg("session state version is newer than current version")
72+ return nil, ErrVersionMismatch
73+ }
74+
75+ log.Info().
76+ Str("session_id", string(input.SessionID)).
77+ Int("tab_count", len(state.Tabs)).
78+ Int("pane_count", state.CountPanes()).
79+ Msg("session state loaded for restoration")
80+
81+ return &RestoreOutput{State: state}, nil
82+}
83+
84+// DeleteSnapshot removes a session's snapshot (for cleanup after failed restore or user deletion).
85+func (uc *RestoreSessionUseCase) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
86+ return uc.stateRepo.DeleteSnapshot(ctx, sessionID)
87+}
@@ -0,0 +1,87 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ErrSessionNotFound is returned when a session state cannot be found.
14+var ErrSessionNotFound = errors.New("session not found")
15+
16+// ErrVersionMismatch is returned when the session state version is incompatible.
17+var ErrVersionMismatch = errors.New("session state version mismatch")
18+
19+// RestoreSessionUseCase handles restoring session state from a snapshot.
20+type RestoreSessionUseCase struct {
21+ stateRepo repository.SessionStateRepository
22+ sessionRepo repository.SessionRepository
23+}
24+
25+// NewRestoreSessionUseCase creates a new RestoreSessionUseCase.
26+func NewRestoreSessionUseCase(
27+ stateRepo repository.SessionStateRepository,
28+ sessionRepo repository.SessionRepository,
29+) *RestoreSessionUseCase {
30+ return &RestoreSessionUseCase{
31+ stateRepo: stateRepo,
32+ sessionRepo: sessionRepo,
33+ }
34+}
35+
36+// RestoreInput contains the parameters for restoring a session.
37+type RestoreInput struct {
38+ SessionID entity.SessionID
39+}
40+
41+// RestoreOutput contains the restored session state.
42+type RestoreOutput struct {
43+ State *entity.SessionState
44+}
45+
46+// Execute loads and validates a session state for restoration.
47+func (uc *RestoreSessionUseCase) Execute(ctx context.Context, input RestoreInput) (*RestoreOutput, error) {
48+ log := logging.FromContext(ctx)
49+
50+ if input.SessionID == "" {
51+ return nil, fmt.Errorf("session id required")
52+ }
53+
54+ log.Info().
55+ Str("session_id", string(input.SessionID)).
56+ Msg("restoring session state")
57+
58+ state, err := uc.stateRepo.GetSnapshot(ctx, input.SessionID)
59+ if err != nil {
60+ return nil, fmt.Errorf("get session snapshot: %w", err)
61+ }
62+ if state == nil {
63+ return nil, ErrSessionNotFound
64+ }
65+
66+ // Validate version compatibility
67+ if state.Version > entity.SessionStateVersion {
68+ log.Warn().
69+ Int("state_version", state.Version).
70+ Int("current_version", entity.SessionStateVersion).
71+ Msg("session state version is newer than current version")
72+ return nil, ErrVersionMismatch
73+ }
74+
75+ log.Info().
76+ Str("session_id", string(input.SessionID)).
77+ Int("tab_count", len(state.Tabs)).
78+ Int("pane_count", state.CountPanes()).
79+ Msg("session state loaded for restoration")
80+
81+ return &RestoreOutput{State: state}, nil
82+}
83+
84+// DeleteSnapshot removes a session's snapshot (for cleanup after failed restore or user deletion).
85+func (uc *RestoreSessionUseCase) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
86+ return uc.stateRepo.DeleteSnapshot(ctx, sessionID)
87+}
A internal/application/usecase/restore_session_test.go +140 -0
@@ -0,0 +1,140 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestRestoreSessionUseCase_Execute_ReturnsState(t *testing.T) {
16+ ctx := testContext()
17+
18+ stateRepo := repomocks.NewMockSessionStateRepository(t)
19+ sessionRepo := repomocks.NewMockSessionRepository(t)
20+
21+ sessionID := entity.SessionID("20251224_120000_restore")
22+
23+ expectedState := &entity.SessionState{
24+ Version: entity.SessionStateVersion,
25+ SessionID: sessionID,
26+ Tabs: []entity.TabSnapshot{
27+ {
28+ ID: "tab-1",
29+ Name: "Test Tab",
30+ Workspace: entity.WorkspaceSnapshot{
31+ Root: &entity.PaneNodeSnapshot{
32+ Pane: &entity.PaneSnapshot{
33+ ID: "pane-1",
34+ URI: "https://example.com",
35+ Title: "Example",
36+ },
37+ },
38+ },
39+ },
40+ },
41+ SavedAt: time.Now(),
42+ }
43+
44+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(expectedState, nil)
45+
46+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
47+
48+ output, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
49+ require.NoError(t, err)
50+ require.NotNil(t, output)
51+ assert.Equal(t, expectedState, output.State)
52+}
53+
54+func TestRestoreSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
55+ ctx := testContext()
56+
57+ stateRepo := repomocks.NewMockSessionStateRepository(t)
58+ sessionRepo := repomocks.NewMockSessionRepository(t)
59+
60+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
61+
62+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: ""})
63+ require.Error(t, err)
64+ assert.Contains(t, err.Error(), "session id required")
65+}
66+
67+func TestRestoreSessionUseCase_Execute_SessionNotFound(t *testing.T) {
68+ ctx := testContext()
69+
70+ stateRepo := repomocks.NewMockSessionStateRepository(t)
71+ sessionRepo := repomocks.NewMockSessionRepository(t)
72+
73+ sessionID := entity.SessionID("20251224_120000_notfound")
74+
75+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil)
76+
77+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
78+
79+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
80+ require.Error(t, err)
81+ assert.ErrorIs(t, err, usecase.ErrSessionNotFound)
82+}
83+
84+func TestRestoreSessionUseCase_Execute_VersionMismatch(t *testing.T) {
85+ ctx := testContext()
86+
87+ stateRepo := repomocks.NewMockSessionStateRepository(t)
88+ sessionRepo := repomocks.NewMockSessionRepository(t)
89+
90+ sessionID := entity.SessionID("20251224_120000_version")
91+
92+ // Return a state with a future version
93+ futureState := &entity.SessionState{
94+ Version: entity.SessionStateVersion + 1,
95+ SessionID: sessionID,
96+ Tabs: []entity.TabSnapshot{},
97+ SavedAt: time.Now(),
98+ }
99+
100+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(futureState, nil)
101+
102+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
103+
104+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, usecase.ErrVersionMismatch)
107+}
108+
109+func TestRestoreSessionUseCase_Execute_GetSnapshotError(t *testing.T) {
110+ ctx := testContext()
111+
112+ stateRepo := repomocks.NewMockSessionStateRepository(t)
113+ sessionRepo := repomocks.NewMockSessionRepository(t)
114+
115+ sessionID := entity.SessionID("20251224_120000_error")
116+
117+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, errors.New("db error"))
118+
119+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
120+
121+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "get session snapshot")
124+}
125+
126+func TestRestoreSessionUseCase_DeleteSnapshot(t *testing.T) {
127+ ctx := testContext()
128+
129+ stateRepo := repomocks.NewMockSessionStateRepository(t)
130+ sessionRepo := repomocks.NewMockSessionRepository(t)
131+
132+ sessionID := entity.SessionID("20251224_120000_delete")
133+
134+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
135+
136+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
137+
138+ err := uc.DeleteSnapshot(ctx, sessionID)
139+ require.NoError(t, err)
140+}
@@ -0,0 +1,140 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestRestoreSessionUseCase_Execute_ReturnsState(t *testing.T) {
16+ ctx := testContext()
17+
18+ stateRepo := repomocks.NewMockSessionStateRepository(t)
19+ sessionRepo := repomocks.NewMockSessionRepository(t)
20+
21+ sessionID := entity.SessionID("20251224_120000_restore")
22+
23+ expectedState := &entity.SessionState{
24+ Version: entity.SessionStateVersion,
25+ SessionID: sessionID,
26+ Tabs: []entity.TabSnapshot{
27+ {
28+ ID: "tab-1",
29+ Name: "Test Tab",
30+ Workspace: entity.WorkspaceSnapshot{
31+ Root: &entity.PaneNodeSnapshot{
32+ Pane: &entity.PaneSnapshot{
33+ ID: "pane-1",
34+ URI: "https://example.com",
35+ Title: "Example",
36+ },
37+ },
38+ },
39+ },
40+ },
41+ SavedAt: time.Now(),
42+ }
43+
44+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(expectedState, nil)
45+
46+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
47+
48+ output, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
49+ require.NoError(t, err)
50+ require.NotNil(t, output)
51+ assert.Equal(t, expectedState, output.State)
52+}
53+
54+func TestRestoreSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
55+ ctx := testContext()
56+
57+ stateRepo := repomocks.NewMockSessionStateRepository(t)
58+ sessionRepo := repomocks.NewMockSessionRepository(t)
59+
60+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
61+
62+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: ""})
63+ require.Error(t, err)
64+ assert.Contains(t, err.Error(), "session id required")
65+}
66+
67+func TestRestoreSessionUseCase_Execute_SessionNotFound(t *testing.T) {
68+ ctx := testContext()
69+
70+ stateRepo := repomocks.NewMockSessionStateRepository(t)
71+ sessionRepo := repomocks.NewMockSessionRepository(t)
72+
73+ sessionID := entity.SessionID("20251224_120000_notfound")
74+
75+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil)
76+
77+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
78+
79+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
80+ require.Error(t, err)
81+ assert.ErrorIs(t, err, usecase.ErrSessionNotFound)
82+}
83+
84+func TestRestoreSessionUseCase_Execute_VersionMismatch(t *testing.T) {
85+ ctx := testContext()
86+
87+ stateRepo := repomocks.NewMockSessionStateRepository(t)
88+ sessionRepo := repomocks.NewMockSessionRepository(t)
89+
90+ sessionID := entity.SessionID("20251224_120000_version")
91+
92+ // Return a state with a future version
93+ futureState := &entity.SessionState{
94+ Version: entity.SessionStateVersion + 1,
95+ SessionID: sessionID,
96+ Tabs: []entity.TabSnapshot{},
97+ SavedAt: time.Now(),
98+ }
99+
100+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(futureState, nil)
101+
102+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
103+
104+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, usecase.ErrVersionMismatch)
107+}
108+
109+func TestRestoreSessionUseCase_Execute_GetSnapshotError(t *testing.T) {
110+ ctx := testContext()
111+
112+ stateRepo := repomocks.NewMockSessionStateRepository(t)
113+ sessionRepo := repomocks.NewMockSessionRepository(t)
114+
115+ sessionID := entity.SessionID("20251224_120000_error")
116+
117+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, errors.New("db error"))
118+
119+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
120+
121+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "get session snapshot")
124+}
125+
126+func TestRestoreSessionUseCase_DeleteSnapshot(t *testing.T) {
127+ ctx := testContext()
128+
129+ stateRepo := repomocks.NewMockSessionStateRepository(t)
130+ sessionRepo := repomocks.NewMockSessionRepository(t)
131+
132+ sessionID := entity.SessionID("20251224_120000_delete")
133+
134+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
135+
136+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
137+
138+ err := uc.DeleteSnapshot(ctx, sessionID)
139+ require.NoError(t, err)
140+}
A internal/application/usecase/snapshot_session.go +49 -0
@@ -0,0 +1,49 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// SnapshotSessionUseCase handles saving session state snapshots.
13+type SnapshotSessionUseCase struct {
14+ stateRepo repository.SessionStateRepository
15+}
16+
17+// NewSnapshotSessionUseCase creates a new SnapshotSessionUseCase.
18+func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *SnapshotSessionUseCase {
19+ return &SnapshotSessionUseCase{stateRepo: stateRepo}
20+}
21+
22+// SnapshotInput contains the parameters for creating a session snapshot.
23+type SnapshotInput struct {
24+ SessionID entity.SessionID
25+ TabList *entity.TabList
26+}
27+
28+// Execute creates a snapshot of the current session state and saves it.
29+func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {
30+ log := logging.FromContext(ctx)
31+
32+ if input.SessionID == "" {
33+ return fmt.Errorf("session id required")
34+ }
35+
36+ state := entity.SnapshotFromTabList(input.SessionID, input.TabList)
37+
38+ log.Debug().
39+ Str("session_id", string(input.SessionID)).
40+ Int("tab_count", len(state.Tabs)).
41+ Int("pane_count", state.CountPanes()).
42+ Msg("creating session snapshot")
43+
44+ if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {
45+ return fmt.Errorf("save session snapshot: %w", err)
46+ }
47+
48+ return nil
49+}
@@ -0,0 +1,49 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// SnapshotSessionUseCase handles saving session state snapshots.
13+type SnapshotSessionUseCase struct {
14+ stateRepo repository.SessionStateRepository
15+}
16+
17+// NewSnapshotSessionUseCase creates a new SnapshotSessionUseCase.
18+func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *SnapshotSessionUseCase {
19+ return &SnapshotSessionUseCase{stateRepo: stateRepo}
20+}
21+
22+// SnapshotInput contains the parameters for creating a session snapshot.
23+type SnapshotInput struct {
24+ SessionID entity.SessionID
25+ TabList *entity.TabList
26+}
27+
28+// Execute creates a snapshot of the current session state and saves it.
29+func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {
30+ log := logging.FromContext(ctx)
31+
32+ if input.SessionID == "" {
33+ return fmt.Errorf("session id required")
34+ }
35+
36+ state := entity.SnapshotFromTabList(input.SessionID, input.TabList)
37+
38+ log.Debug().
39+ Str("session_id", string(input.SessionID)).
40+ Int("tab_count", len(state.Tabs)).
41+ Int("pane_count", state.CountPanes()).
42+ Msg("creating session snapshot")
43+
44+ if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {
45+ return fmt.Errorf("save session snapshot: %w", err)
46+ }
47+
48+ return nil
49+}
A internal/application/usecase/snapshot_session_test.go +173 -0
@@ -0,0 +1,173 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) {
17+ ctx := testContext()
18+
19+ stateRepo := repomocks.NewMockSessionStateRepository(t)
20+
21+ // Create a tab list with some tabs
22+ tabList := entity.NewTabList()
23+ pane := entity.NewPane("pane-1")
24+ pane.URI = "https://example.com"
25+ pane.Title = "Example"
26+ tab := entity.NewTab("tab-1", "ws-1", pane)
27+ tab.Name = "Test Tab"
28+ tabList.Add(tab)
29+
30+ sessionID := entity.SessionID("20251224_120000_test")
31+
32+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
33+ Run(func(_ context.Context, state *entity.SessionState) {
34+ require.Equal(t, sessionID, state.SessionID)
35+ require.Len(t, state.Tabs, 1)
36+ require.Equal(t, "Test Tab", state.Tabs[0].Name)
37+ require.Equal(t, entity.SessionStateVersion, state.Version)
38+ }).
39+ Return(nil)
40+
41+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
42+
43+ err := uc.Execute(ctx, usecase.SnapshotInput{
44+ SessionID: sessionID,
45+ TabList: tabList,
46+ })
47+ require.NoError(t, err)
48+}
49+
50+func TestSnapshotSessionUseCase_Execute_EmptyTabList(t *testing.T) {
51+ ctx := testContext()
52+
53+ stateRepo := repomocks.NewMockSessionStateRepository(t)
54+
55+ sessionID := entity.SessionID("20251224_120000_empty")
56+
57+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
58+ Run(func(_ context.Context, state *entity.SessionState) {
59+ require.Equal(t, sessionID, state.SessionID)
60+ require.Empty(t, state.Tabs)
61+ }).
62+ Return(nil)
63+
64+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
65+
66+ err := uc.Execute(ctx, usecase.SnapshotInput{
67+ SessionID: sessionID,
68+ TabList: nil,
69+ })
70+ require.NoError(t, err)
71+}
72+
73+func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
74+ ctx := testContext()
75+
76+ stateRepo := repomocks.NewMockSessionStateRepository(t)
77+
78+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
79+
80+ err := uc.Execute(ctx, usecase.SnapshotInput{
81+ SessionID: "",
82+ TabList: entity.NewTabList(),
83+ })
84+ require.Error(t, err)
85+ assert.Contains(t, err.Error(), "session id required")
86+}
87+
88+func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
89+ sessionID := entity.SessionID("20251224_120000_snap")
90+
91+ // Create a workspace with multiple panes
92+ pane1 := entity.NewPane("pane-1")
93+ pane1.URI = "https://github.com"
94+ pane1.Title = "GitHub"
95+ pane1.ZoomFactor = 1.2
96+
97+ pane2 := entity.NewPane("pane-2")
98+ pane2.URI = "https://google.com"
99+ pane2.Title = "Google"
100+
101+ tab := entity.NewTab("tab-1", "ws-1", pane1)
102+ tab.Name = "Dev Tab"
103+ tab.IsPinned = true
104+
105+ tabList := entity.NewTabList()
106+ tabList.Add(tab)
107+
108+ state := entity.SnapshotFromTabList(sessionID, tabList)
109+
110+ require.NotNil(t, state)
111+ assert.Equal(t, entity.SessionStateVersion, state.Version)
112+ assert.Equal(t, sessionID, state.SessionID)
113+ assert.Len(t, state.Tabs, 1)
114+ assert.Equal(t, "Dev Tab", state.Tabs[0].Name)
115+ assert.True(t, state.Tabs[0].IsPinned)
116+ assert.NotNil(t, state.Tabs[0].Workspace.Root)
117+ assert.Equal(t, "https://github.com", state.Tabs[0].Workspace.Root.Pane.URI)
118+ assert.InDelta(t, 1.2, state.Tabs[0].Workspace.Root.Pane.ZoomFactor, 0.001)
119+ assert.False(t, state.SavedAt.IsZero())
120+}
121+
122+func TestSessionState_CountPanes(t *testing.T) {
123+ state := &entity.SessionState{
124+ Tabs: []entity.TabSnapshot{
125+ {
126+ Workspace: entity.WorkspaceSnapshot{
127+ Root: &entity.PaneNodeSnapshot{
128+ Pane: &entity.PaneSnapshot{ID: "pane-1"},
129+ },
130+ },
131+ },
132+ {
133+ Workspace: entity.WorkspaceSnapshot{
134+ Root: &entity.PaneNodeSnapshot{
135+ Children: []*entity.PaneNodeSnapshot{
136+ {Pane: &entity.PaneSnapshot{ID: "pane-2"}},
137+ {Pane: &entity.PaneSnapshot{ID: "pane-3"}},
138+ },
139+ },
140+ },
141+ },
142+ },
143+ }
144+
145+ assert.Equal(t, 3, state.CountPanes())
146+}
147+
148+func TestGetRelativeTime(t *testing.T) {
149+ now := time.Now()
150+
151+ tests := []struct {
152+ name string
153+ t time.Time
154+ expected string
155+ }{
156+ {"just now", now.Add(-30 * time.Second), "just now"},
157+ {"1 minute", now.Add(-1 * time.Minute), "1m ago"},
158+ {"5 minutes", now.Add(-5 * time.Minute), "05m ago"},
159+ {"1 hour", now.Add(-1 * time.Hour), "1h ago"},
160+ {"3 hours", now.Add(-3 * time.Hour), "03h ago"},
161+ {"1 day", now.Add(-24 * time.Hour), "1d ago"},
162+ {"2 days", now.Add(-48 * time.Hour), "02d ago"},
163+ {"1 week", now.Add(-7 * 24 * time.Hour), "1w ago"},
164+ {"2 weeks", now.Add(-14 * 24 * time.Hour), "02w ago"},
165+ }
166+
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ result := usecase.GetRelativeTime(tt.t)
170+ assert.Equal(t, tt.expected, result)
171+ })
172+ }
173+}
@@ -0,0 +1,173 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) {
17+ ctx := testContext()
18+
19+ stateRepo := repomocks.NewMockSessionStateRepository(t)
20+
21+ // Create a tab list with some tabs
22+ tabList := entity.NewTabList()
23+ pane := entity.NewPane("pane-1")
24+ pane.URI = "https://example.com"
25+ pane.Title = "Example"
26+ tab := entity.NewTab("tab-1", "ws-1", pane)
27+ tab.Name = "Test Tab"
28+ tabList.Add(tab)
29+
30+ sessionID := entity.SessionID("20251224_120000_test")
31+
32+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
33+ Run(func(_ context.Context, state *entity.SessionState) {
34+ require.Equal(t, sessionID, state.SessionID)
35+ require.Len(t, state.Tabs, 1)
36+ require.Equal(t, "Test Tab", state.Tabs[0].Name)
37+ require.Equal(t, entity.SessionStateVersion, state.Version)
38+ }).
39+ Return(nil)
40+
41+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
42+
43+ err := uc.Execute(ctx, usecase.SnapshotInput{
44+ SessionID: sessionID,
45+ TabList: tabList,
46+ })
47+ require.NoError(t, err)
48+}
49+
50+func TestSnapshotSessionUseCase_Execute_EmptyTabList(t *testing.T) {
51+ ctx := testContext()
52+
53+ stateRepo := repomocks.NewMockSessionStateRepository(t)
54+
55+ sessionID := entity.SessionID("20251224_120000_empty")
56+
57+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
58+ Run(func(_ context.Context, state *entity.SessionState) {
59+ require.Equal(t, sessionID, state.SessionID)
60+ require.Empty(t, state.Tabs)
61+ }).
62+ Return(nil)
63+
64+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
65+
66+ err := uc.Execute(ctx, usecase.SnapshotInput{
67+ SessionID: sessionID,
68+ TabList: nil,
69+ })
70+ require.NoError(t, err)
71+}
72+
73+func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
74+ ctx := testContext()
75+
76+ stateRepo := repomocks.NewMockSessionStateRepository(t)
77+
78+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
79+
80+ err := uc.Execute(ctx, usecase.SnapshotInput{
81+ SessionID: "",
82+ TabList: entity.NewTabList(),
83+ })
84+ require.Error(t, err)
85+ assert.Contains(t, err.Error(), "session id required")
86+}
87+
88+func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
89+ sessionID := entity.SessionID("20251224_120000_snap")
90+
91+ // Create a workspace with multiple panes
92+ pane1 := entity.NewPane("pane-1")
93+ pane1.URI = "https://github.com"
94+ pane1.Title = "GitHub"
95+ pane1.ZoomFactor = 1.2
96+
97+ pane2 := entity.NewPane("pane-2")
98+ pane2.URI = "https://google.com"
99+ pane2.Title = "Google"
100+
101+ tab := entity.NewTab("tab-1", "ws-1", pane1)
102+ tab.Name = "Dev Tab"
103+ tab.IsPinned = true
104+
105+ tabList := entity.NewTabList()
106+ tabList.Add(tab)
107+
108+ state := entity.SnapshotFromTabList(sessionID, tabList)
109+
110+ require.NotNil(t, state)
111+ assert.Equal(t, entity.SessionStateVersion, state.Version)
112+ assert.Equal(t, sessionID, state.SessionID)
113+ assert.Len(t, state.Tabs, 1)
114+ assert.Equal(t, "Dev Tab", state.Tabs[0].Name)
115+ assert.True(t, state.Tabs[0].IsPinned)
116+ assert.NotNil(t, state.Tabs[0].Workspace.Root)
117+ assert.Equal(t, "https://github.com", state.Tabs[0].Workspace.Root.Pane.URI)
118+ assert.InDelta(t, 1.2, state.Tabs[0].Workspace.Root.Pane.ZoomFactor, 0.001)
119+ assert.False(t, state.SavedAt.IsZero())
120+}
121+
122+func TestSessionState_CountPanes(t *testing.T) {
123+ state := &entity.SessionState{
124+ Tabs: []entity.TabSnapshot{
125+ {
126+ Workspace: entity.WorkspaceSnapshot{
127+ Root: &entity.PaneNodeSnapshot{
128+ Pane: &entity.PaneSnapshot{ID: "pane-1"},
129+ },
130+ },
131+ },
132+ {
133+ Workspace: entity.WorkspaceSnapshot{
134+ Root: &entity.PaneNodeSnapshot{
135+ Children: []*entity.PaneNodeSnapshot{
136+ {Pane: &entity.PaneSnapshot{ID: "pane-2"}},
137+ {Pane: &entity.PaneSnapshot{ID: "pane-3"}},
138+ },
139+ },
140+ },
141+ },
142+ },
143+ }
144+
145+ assert.Equal(t, 3, state.CountPanes())
146+}
147+
148+func TestGetRelativeTime(t *testing.T) {
149+ now := time.Now()
150+
151+ tests := []struct {
152+ name string
153+ t time.Time
154+ expected string
155+ }{
156+ {"just now", now.Add(-30 * time.Second), "just now"},
157+ {"1 minute", now.Add(-1 * time.Minute), "1m ago"},
158+ {"5 minutes", now.Add(-5 * time.Minute), "05m ago"},
159+ {"1 hour", now.Add(-1 * time.Hour), "1h ago"},
160+ {"3 hours", now.Add(-3 * time.Hour), "03h ago"},
161+ {"1 day", now.Add(-24 * time.Hour), "1d ago"},
162+ {"2 days", now.Add(-48 * time.Hour), "02d ago"},
163+ {"1 week", now.Add(-7 * 24 * time.Hour), "1w ago"},
164+ {"2 weeks", now.Add(-14 * 24 * time.Hour), "02w ago"},
165+ }
166+
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ result := usecase.GetRelativeTime(tt.t)
170+ assert.Equal(t, tt.expected, result)
171+ })
172+ }
173+}

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