v0.29.0 21b3150

list_sessions.go RawHistoryFolder

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

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