v0.33.1 9577fee

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

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