1package usecase
2
3import (
4 "context"
5 "time"
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// ListSessionsUseCase handles listing sessions with their state information.
13type ListSessionsUseCase struct {
14 sessionRepo repository.SessionRepository
15 stateRepo repository.SessionStateRepository
16 lockDir string // Directory where lock files are stored
17}
18
19// NewListSessionsUseCase creates a new ListSessionsUseCase.
20func NewListSessionsUseCase(
21 sessionRepo repository.SessionRepository,
22 stateRepo repository.SessionStateRepository,
23 lockDir string,
24) *ListSessionsUseCase {
25 return &ListSessionsUseCase{
26 sessionRepo: sessionRepo,
27 stateRepo: stateRepo,
28 lockDir: lockDir,
29 }
30}
31
32// ListSessionsOutput contains the list of sessions with their info.
33type ListSessionsOutput struct {
34 Sessions []entity.SessionInfo
35}
36
37// Execute returns a list of sessions with their state information.
38// Sessions are sorted with active first, then by date (most recent first).
39func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
40 log := logging.FromContext(ctx)
41
42 if limit <= 0 {
43 limit = 50
44 }
45
46 sessions, err := uc.sessionRepo.GetRecent(ctx, limit)
47 if err != nil {
48 return nil, err
49 }
50
51 // Get all snapshots
52 snapshots, err := uc.stateRepo.GetAllSnapshots(ctx)
53 if err != nil {
54 log.Warn().Err(err).Msg("failed to get session snapshots")
55 // Continue without snapshots
56 snapshots = nil
57 }
58
59 // Build snapshot lookup map
60 snapshotMap := make(map[entity.SessionID]*entity.SessionState)
61 for _, s := range snapshots {
62 snapshotMap[s.SessionID] = s
63 }
64
65 // Build session info list
66 result := make([]entity.SessionInfo, 0, len(sessions))
67 for _, session := range sessions {
68 // Only include browser sessions
69 if session.Type != entity.SessionTypeBrowser {
70 continue
71 }
72
73 info := entity.SessionInfo{
74 Session: session,
75 IsCurrent: session.ID == currentSessionID,
76 IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
77 }
78
79 if state, ok := snapshotMap[session.ID]; ok {
80 info.State = state
81 info.TabCount = len(state.Tabs)
82 info.PaneCount = state.CountPanes()
83 info.UpdatedAt = state.SavedAt
84 } else {
85 info.UpdatedAt = session.StartedAt
86 }
87
88 result = append(result, info)
89 }
90
91 // Sort: current first, then active, then by date
92 sortSessionInfos(result, currentSessionID)
93
94 return &ListSessionsOutput{Sessions: result}, nil
95}
96
97// hasActiveLock checks if a session has an active lock file.
98func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
99 return isSessionLocked(uc.lockDir, sessionID)
100}
101
102// sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
103func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
104 // Simple bubble sort for small lists
105 n := len(infos)
106 for i := 0; i < n-1; i++ {
107 for j := 0; j < n-i-1; j++ {
108 if shouldSwap(infos[j], infos[j+1], currentID) {
109 infos[j], infos[j+1] = infos[j+1], infos[j]
110 }
111 }
112 }
113}
114
115func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool {
116 // Current session always first
117 if a.Session.ID == currentID {
118 return false
119 }
120 if b.Session.ID == currentID {
121 return true
122 }
123
124 // Active sessions before inactive
125 if a.IsActive && !b.IsActive {
126 return false
127 }
128 if !a.IsActive && b.IsActive {
129 return true
130 }
131
132 // Most recent first
133 return a.UpdatedAt.Before(b.UpdatedAt)
134}
135
136// GetSessionInfo returns info for a specific session.
137func (uc *ListSessionsUseCase) GetSessionInfo(
138 ctx context.Context,
139 sessionID, currentSessionID entity.SessionID,
140) (*entity.SessionInfo, error) {
141 session, err := uc.sessionRepo.FindByID(ctx, sessionID)
142 if err != nil {
143 return nil, err
144 }
145 if session == nil {
146 return nil, nil
147 }
148
149 info := &entity.SessionInfo{
150 Session: session,
151 IsCurrent: session.ID == currentSessionID,
152 IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
153 UpdatedAt: session.StartedAt,
154 }
155
156 state, err := uc.stateRepo.GetSnapshot(ctx, sessionID)
157 if err != nil {
158 logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state")
159 }
160 if state != nil {
161 info.State = state
162 info.TabCount = len(state.Tabs)
163 info.PaneCount = state.CountPanes()
164 info.UpdatedAt = state.SavedAt
165 }
166
167 return info, nil
168}
169
170const (
171 hoursPerDay = 24
172 daysPerWeek = 7
173 maxIDDisplay = 20
174)
175
176// GetRelativeTime returns a human-readable relative time string.
177func GetRelativeTime(t time.Time) string {
178 now := time.Now()
179 diff := now.Sub(t)
180
181 switch {
182 case diff < time.Minute:
183 return "just now"
184 case diff < time.Hour:
185 mins := int(diff.Minutes())
186 if mins == 1 {
187 return "1m ago"
188 }
189 return formatDuration(mins, "m")
190 case diff < hoursPerDay*time.Hour:
191 hours := int(diff.Hours())
192 if hours == 1 {
193 return "1h ago"
194 }
195 return formatDuration(hours, "h")
196 case diff < daysPerWeek*hoursPerDay*time.Hour:
197 days := int(diff.Hours() / hoursPerDay)
198 if days == 1 {
199 return "1d ago"
200 }
201 return formatDuration(days, "d")
202 default:
203 weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
204 if weeks == 1 {
205 return "1w ago"
206 }
207 return formatDuration(weeks, "w")
208 }
209}
210
211func formatDuration(n int, unit string) string {
212 return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"
213}
214
215// PurgeableSessionsOutput contains inactive sessions that can be purged.
216type 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.
223func (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
256const maxPurgeableSessions = 1000