v0.23.1 6c872d5

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	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

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