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