v0.33.2 57f21d6

session.go RawHistoryFolder

1package logging
2
3import (
4	"context"
5	"crypto/rand"
6	"encoding/hex"
7	"errors"
8	"fmt"
9	"os"
10	"path/filepath"
11	"sort"
12	"time"
13)
14
15// GenerateSessionID creates a unique session identifier.
16// Format: YYYYMMDD_HHMMSS_xxxx (timestamp + 4 random hex chars)
17// Example: 20251217_205106_a7b3
18func GenerateSessionID() string {
19	now := time.Now()
20	random := make([]byte, 2)
21	_, _ = rand.Read(random)
22	return now.Format("20060102_150405") + "_" + hex.EncodeToString(random)
23}
24
25// ShortSessionID extracts the short ID (last 4 hex chars) from a full session ID.
26// Example: "20251217_205106_a7b3" -> "a7b3"
27func ShortSessionID(sessionID string) string {
28	if len(sessionID) < 4 {
29		return sessionID
30	}
31	return sessionID[len(sessionID)-4:]
32}
33
34// ParseSessionFilename extracts session info from a log filename.
35// Example: "session_20251217_205106_a7b3.log" -> "20251217_205106_a7b3", true
36func ParseSessionFilename(filename string) (sessionID string, ok bool) {
37	const prefix = "session_"
38	const suffix = ".log"
39
40	if len(filename) < len(prefix)+len(suffix) {
41		return "", false
42	}
43	if filename[:len(prefix)] != prefix {
44		return "", false
45	}
46	if filename[len(filename)-len(suffix):] != suffix {
47		return "", false
48	}
49
50	return filename[len(prefix) : len(filename)-len(suffix)], true
51}
52
53// SessionFilename generates the log filename for a session ID.
54// Example: "20251217_205106_a7b3" -> "session_20251217_205106_a7b3.log"
55func SessionFilename(sessionID string) string {
56	return "session_" + sessionID + ".log"
57}
58
59type sessionMetadataKey struct{}
60
61// SessionMetadata describes the active browser session log identity carried by context.
62type SessionMetadata struct {
63	ID      string
64	LogPath string
65}
66
67// WithSessionMetadata attaches active session metadata to ctx.
68func WithSessionMetadata(ctx context.Context, id, logPath string) context.Context {
69	if ctx == nil {
70		ctx = context.Background()
71	}
72	return context.WithValue(ctx, sessionMetadataKey{}, SessionMetadata{ID: id, LogPath: logPath})
73}
74
75// SessionMetadataFromContext returns active session metadata from ctx, if present.
76func SessionMetadataFromContext(ctx context.Context) (SessionMetadata, bool) {
77	if ctx == nil {
78		return SessionMetadata{}, false
79	}
80	metadata, ok := ctx.Value(sessionMetadataKey{}).(SessionMetadata)
81	return metadata, ok
82}
83
84type sessionLogFile struct {
85	path      string
86	filename  string
87	sessionID string
88	modTime   time.Time
89}
90
91// CleanupSessionLogFiles removes old session log files when their count exceeds maxFiles.
92// It keeps up to maxFiles of the newest requested session IDs, then the newest remaining files by modification time.
93// A maxFiles value of 0 disables count-based cleanup.
94func CleanupSessionLogFiles(logDir string, maxFiles int, keepSessionIDs ...string) (int, error) {
95	if logDir == "" || maxFiles <= 0 {
96		return 0, nil
97	}
98
99	entries, err := os.ReadDir(logDir)
100	if err != nil {
101		if os.IsNotExist(err) {
102			return 0, nil
103		}
104		return 0, fmt.Errorf("read log directory: %w", err)
105	}
106
107	requestedKeep := make(map[string]struct{}, len(keepSessionIDs))
108	for _, sessionID := range keepSessionIDs {
109		if sessionID == "" {
110			continue
111		}
112		requestedKeep[sessionID] = struct{}{}
113	}
114
115	files := make([]sessionLogFile, 0, len(entries))
116	operationErrs := make([]error, 0)
117	for _, entry := range entries {
118		if entry.IsDir() {
119			continue
120		}
121
122		sessionID, ok := ParseSessionFilename(entry.Name())
123		if !ok {
124			continue
125		}
126
127		info, infoErr := entry.Info()
128		if infoErr != nil {
129			operationErrs = append(operationErrs, fmt.Errorf("stat %s: %w", filepath.Join(logDir, entry.Name()), infoErr))
130			continue
131		}
132
133		files = append(files, sessionLogFile{
134			path:      filepath.Join(logDir, entry.Name()),
135			filename:  entry.Name(),
136			sessionID: sessionID,
137			modTime:   info.ModTime(),
138		})
139	}
140
141	sort.Slice(files, func(i, j int) bool {
142		if files[i].modTime.Equal(files[j].modTime) {
143			return files[i].filename > files[j].filename
144		}
145		return files[i].modTime.After(files[j].modTime)
146	})
147
148	allowedKeep := boundedKeepSessionIDs(files, requestedKeep, maxFiles)
149	maxNewestFiles := maxFiles - len(allowedKeep)
150	kept := 0
151	keptNewest := 0
152	removed := 0
153	for _, file := range files {
154		if _, shouldKeep := allowedKeep[file.sessionID]; shouldKeep {
155			if kept < maxFiles {
156				kept++
157				continue
158			}
159		} else if keptNewest < maxNewestFiles && kept < maxFiles {
160			kept++
161			keptNewest++
162			continue
163		}
164
165		if err := os.Remove(file.path); err != nil {
166			operationErrs = append(operationErrs, fmt.Errorf("remove %s: %w", file.path, err))
167			continue
168		}
169		removed++
170	}
171
172	return removed, errors.Join(operationErrs...)
173}
174
175func boundedKeepSessionIDs(files []sessionLogFile, requestedKeep map[string]struct{}, maxFiles int) map[string]struct{} {
176	allowedKeep := make(map[string]struct{}, len(requestedKeep))
177	for _, file := range files {
178		if len(allowedKeep) >= maxFiles {
179			break
180		}
181		if _, shouldKeep := requestedKeep[file.sessionID]; shouldKeep {
182			allowedKeep[file.sessionID] = struct{}{}
183		}
184	}
185	return allowedKeep
186}

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