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// ListAllSessionsLimit asks Execute to return every known session.
13const ListAllSessionsLimit = -1
14
15// ListSessionsUseCase handles listing sessions with their state information.
16type ListSessionsUseCase struct {
17 sessionRepo repository.SessionRepository
18 stateRepo repository.SessionStateRepository
19}
20
21// NewListSessionsUseCase creates a new ListSessionsUseCase.
22func NewListSessionsUseCase(
23 sessionRepo repository.SessionRepository,
24 stateRepo repository.SessionStateRepository,
25) *ListSessionsUseCase {
26 return &ListSessionsUseCase{
27 sessionRepo: sessionRepo,
28 stateRepo: stateRepo,
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(),
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// sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
98func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
99 // Simple bubble sort for small lists
100 n := len(infos)
101 for i := 0; i < n-1; i++ {
102 for j := 0; j < n-i-1; j++ {
103 if shouldSwap(infos[j], infos[j+1], currentID) {
104 infos[j], infos[j+1] = infos[j+1], infos[j]
105 }
106 }
107 }
108}
109
110func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool {
111 // Current session always first
112 if a.Session.ID == currentID {
113 return false
114 }
115 if b.Session.ID == currentID {
116 return true
117 }
118
119 // Active sessions before inactive
120 if a.IsActive && !b.IsActive {
121 return false
122 }
123 if !a.IsActive && b.IsActive {
124 return true
125 }
126
127 // Most recent first
128 return a.UpdatedAt.Before(b.UpdatedAt)
129}
130
131// GetSessionInfo returns info for a specific session.
132func (uc *ListSessionsUseCase) GetSessionInfo(
133 ctx context.Context,
134 sessionID, currentSessionID entity.SessionID,
135) (*entity.SessionInfo, error) {
136 session, err := uc.sessionRepo.FindByID(ctx, sessionID)
137 if err != nil {
138 return nil, err
139 }
140 if session == nil {
141 return nil, nil
142 }
143
144 info := &entity.SessionInfo{
145 Session: session,
146 IsCurrent: session.ID == currentSessionID,
147 IsActive: session.IsActive(),
148 UpdatedAt: session.StartedAt,
149 }
150
151 state, err := uc.stateRepo.GetSnapshot(ctx, sessionID)
152 if err != nil {
153 logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state")
154 }
155 if state != nil {
156 info.State = state
157 info.TabCount = len(state.Tabs)
158 info.PaneCount = state.CountPanes()
159 info.UpdatedAt = state.SavedAt
160 }
161
162 return info, nil
163}
164
165const (
166 hoursPerDay = 24
167 daysPerWeek = 7
168 maxIDDisplay = 20
169)
170
171// GetRelativeTime returns a human-readable relative time string.
172func GetRelativeTime(t time.Time) string {
173 now := time.Now()
174 diff := now.Sub(t)
175
176 switch {
177 case diff < time.Minute:
178 return "just now"
179 case diff < time.Hour:
180 mins := int(diff.Minutes())
181 if mins == 1 {
182 return "1m ago"
183 }
184 return formatDuration(mins, "m")
185 case diff < hoursPerDay*time.Hour:
186 hours := int(diff.Hours())
187 if hours == 1 {
188 return "1h ago"
189 }
190 return formatDuration(hours, "h")
191 case diff < daysPerWeek*hoursPerDay*time.Hour:
192 days := int(diff.Hours() / hoursPerDay)
193 if days == 1 {
194 return "1d ago"
195 }
196 return formatDuration(days, "d")
197 default:
198 weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
199 if weeks == 1 {
200 return "1w ago"
201 }
202 return formatDuration(weeks, "w")
203 }
204}
205
206func formatDuration(n int, unit string) string {
207 return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"
208}
209
210// PurgeableSessionsOutput contains inactive sessions that can be purged.
211type PurgeableSessionsOutput struct {
212 Sessions []entity.SessionPurgeItem
213 TotalSize int64
214}
215
216// GetPurgeableSessions returns all inactive sessions that can be safely deleted.
217// These are sessions that have ended and have no active lock file.
218func (uc *ListSessionsUseCase) GetPurgeableSessions(ctx context.Context) (*PurgeableSessionsOutput, error) {
219 log := logging.FromContext(ctx)
220
221 // Get all sessions (no current session in CLI context)
222 output, err := uc.Execute(ctx, "", maxPurgeableSessions)
223 if err != nil {
224 return nil, err
225 }
226
227 // Filter to only inactive sessions
228 var purgeable []entity.SessionPurgeItem
229 for _, info := range output.Sessions {
230 if !info.IsActive {
231 purgeable = append(purgeable, entity.SessionPurgeItem{
232 Info: info,
233 Selected: true, // Default to selected
234 })
235 }
236 }
237
238 // Get total size of all snapshots
239 totalSize, err := uc.stateRepo.GetTotalSnapshotsSize(ctx)
240 if err != nil {
241 log.Warn().Err(err).Msg("failed to get total snapshots size")
242 totalSize = 0
243 }
244
245 return &PurgeableSessionsOutput{
246 Sessions: purgeable,
247 TotalSize: totalSize,
248 }, nil
249}
250
251const maxPurgeableSessions = 1000