v0.38.0 58133ea

session_picker.go RawHistoryFolder

1package core
2
3import (
4	"bufio"
5	"bytes"
6	"context"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"io"
11	"os"
12	"path/filepath"
13	"sort"
14	"strings"
15	"time"
16	"unicode"
17
18	"github.com/bnema/zut/packages/provider"
19)
20
21const (
22	maxSessionSearchRowBytes = 1 << 20
23	maxSessionSearchRunes    = 512
24)
25
26// SessionSearchSegment is one bounded, display-safe user or assistant text
27// excerpt from a persisted session. It deliberately never contains tool,
28// developer, image, or raw JSON data.
29type SessionSearchSegment struct {
30	Path       string
31	Text       string
32	Normalized string
33	Role       provider.Role
34	Time       time.Time
35	Order      int
36}
37
38// ListSessionPathsContext lists eligible session files for cwd or, when all is
39// true, for every cwd bucket in root's active session namespace. It ignores
40// symlinks, malformed buckets, files with invalid metadata, and files whose
41// stored cwd does not hash to the directory that contains them.
42func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) []string {
43	if err := contextErr(ctx); err != nil || strings.TrimSpace(root) == "" {
44		return nil
45	}
46
47	var dirs []string
48	if all {
49		sessionsDir := filepath.Join(root, "sessions")
50		entries, err := os.ReadDir(sessionsDir)
51		if err != nil {
52			return nil
53		}
54		for _, entry := range entries {
55			if err := contextErr(ctx); err != nil {
56				return nil
57			}
58			if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !isSessionBucketName(entry.Name()) {
59				continue
60			}
61			dirs = append(dirs, filepath.Join(sessionsDir, entry.Name()))
62		}
63	} else {
64		dir := SessionsDir(root, cwd)
65		info, err := os.Lstat(dir)
66		if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
67			return nil
68		}
69		dirs = []string{dir}
70	}
71
72	type candidate struct {
73		path string
74		mod  time.Time
75	}
76	var candidates []candidate
77	for _, dir := range dirs {
78		if err := contextErr(ctx); err != nil {
79			return nil
80		}
81		entries, err := os.ReadDir(dir)
82		if err != nil {
83			continue
84		}
85		for _, entry := range entries {
86			if err := contextErr(ctx); err != nil {
87				return nil
88			}
89			if entry.Type()&os.ModeSymlink != 0 || entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
90				continue
91			}
92			info, err := entry.Info()
93			if err != nil || !info.Mode().IsRegular() {
94				continue
95			}
96			path := filepath.Join(dir, entry.Name())
97			meta, err := scanSessionMetaContext(ctx, path)
98			if err != nil || meta.ID == "" || meta.CWD == "" || meta.HideFromSessions {
99				continue
100			}
101			if filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(dir) {
102				continue
103			}
104			if !all && meta.CWD != cwd {
105				continue
106			}
107			candidates = append(candidates, candidate{path: path, mod: info.ModTime()})
108		}
109	}
110	sort.Slice(candidates, func(i, j int) bool {
111		if !candidates[i].mod.Equal(candidates[j].mod) {
112			return candidates[i].mod.After(candidates[j].mod)
113		}
114		return candidates[i].path > candidates[j].path
115	})
116	paths := make([]string, len(candidates))
117	for i, candidate := range candidates {
118		paths[i] = candidate.path
119	}
120	return paths
121}
122
123// ManagedSessionMeta validates that path is a regular session file directly
124// owned by root's active namespace and returns its latest metadata. It is the
125// trust boundary for a picker selection before a host changes workspace state.
126func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, error) {
127	if err := contextErr(ctx); err != nil {
128		return SessionMeta{}, err
129	}
130	if strings.TrimSpace(root) == "" || strings.TrimSpace(path) == "" {
131		return SessionMeta{}, errors.New("managed session: root or path is empty")
132	}
133	info, err := os.Lstat(path)
134	if err != nil {
135		return SessionMeta{}, fmt.Errorf("managed session: stat: %w", err)
136	}
137	if !info.Mode().IsRegular() {
138		return SessionMeta{}, errors.New("managed session: path is not a regular file")
139	}
140	meta, err := scanSessionMetaContext(ctx, path)
141	if err != nil {
142		return SessionMeta{}, err
143	}
144	if meta.ID == "" || meta.CWD == "" {
145		return SessionMeta{}, errors.New("managed session: invalid session metadata")
146	}
147	wantDir := filepath.Clean(SessionsDir(root, meta.CWD))
148	dirInfo, err := os.Lstat(wantDir)
149	if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() {
150		return SessionMeta{}, errors.New("managed session: cwd bucket is not a directory")
151	}
152	if filepath.Clean(filepath.Dir(path)) != wantDir {
153		return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata")
154	}
155	return meta, nil
156}
157
158// ReadSessionSearchSegments extracts bounded searchable text from persisted
159// message rows. It keeps historical user/assistant rows after compaction but
160// excludes compaction payloads and every non-text content block.
161func ReadSessionSearchSegments(ctx context.Context, path string) ([]SessionSearchSegment, error) {
162	if err := contextErr(ctx); err != nil {
163		return nil, err
164	}
165	file, err := os.Open(path)
166	if err != nil {
167		return nil, fmt.Errorf("read session search text: open: %w", err)
168	}
169	defer file.Close()
170
171	reader := bufio.NewReaderSize(file, 64*1024)
172	var segments []SessionSearchSegment
173	order := 0
174	for {
175		if err := contextErr(ctx); err != nil {
176			return nil, err
177		}
178		line, err := readBoundedSessionSearchLine(reader)
179		if err != nil && !errors.Is(err, io.EOF) {
180			return nil, fmt.Errorf("read session search text: %w", err)
181		}
182		if len(line) > 0 {
183			var head sessionLineHead
184			if jsonErr := json.Unmarshal(line, &head); jsonErr == nil && head.Type == "message" {
185				message, messageErr := readSearchMessage(line)
186				if messageErr == nil && (message.Role == provider.RoleUser || message.Role == provider.RoleAssistant) {
187					for _, rawText := range message.Texts {
188						for _, text := range splitSessionSearchText(rawText) {
189							segments = append(segments, SessionSearchSegment{Path: path, Text: text, Normalized: NormalizeSessionSearchText(text), Role: message.Role, Time: message.Time, Order: order})
190							order++
191						}
192					}
193				}
194			}
195		}
196		if errors.Is(err, io.EOF) {
197			break
198		}
199	}
200	return segments, nil
201}
202
203type sessionSearchMessage struct {
204	Role  provider.Role
205	Time  time.Time
206	Texts []string
207}
208
209func readSearchMessage(line []byte) (sessionSearchMessage, error) {
210	var row struct {
211		Message json.RawMessage `json:"message"`
212	}
213	if err := json.Unmarshal(line, &row); err != nil {
214		return sessionSearchMessage{}, err
215	}
216	if len(row.Message) == 0 || bytes.Equal(bytes.TrimSpace(row.Message), []byte("null")) {
217		return sessionSearchMessage{}, errors.New("message row has no message")
218	}
219	var message struct {
220		Role    provider.Role     `json:"role"`
221		Time    time.Time         `json:"time"`
222		Content []json.RawMessage `json:"content"`
223	}
224	if err := json.Unmarshal(row.Message, &message); err != nil {
225		return sessionSearchMessage{}, err
226	}
227	if message.Role != provider.RoleUser && message.Role != provider.RoleAssistant && message.Role != provider.RoleTool && message.Role != provider.RoleDeveloper {
228		return sessionSearchMessage{}, fmt.Errorf("message has invalid role %q", message.Role)
229	}
230	if len(message.Content) == 0 {
231		return sessionSearchMessage{}, errors.New("message has no content")
232	}
233	result := sessionSearchMessage{Role: message.Role, Time: message.Time}
234	for _, rawContent := range message.Content {
235		var textBlock struct {
236			Text string `json:"text"`
237		}
238		if err := json.Unmarshal(rawContent, &textBlock); err != nil {
239			return sessionSearchMessage{}, err
240		}
241		if textBlock.Text != "" {
242			result.Texts = append(result.Texts, textBlock.Text)
243		}
244	}
245	return result, nil
246}
247
248func readBoundedSessionSearchLine(reader *bufio.Reader) ([]byte, error) {
249	var line []byte
250	for {
251		fragment, err := reader.ReadSlice('\n')
252		line = append(line, fragment...)
253		if len(line) > maxSessionSearchRowBytes {
254			return nil, fmt.Errorf("JSONL row exceeds %d bytes", maxSessionSearchRowBytes)
255		}
256		switch {
257		case err == nil:
258			return bytes.TrimRight(line, "\r\n"), nil
259		case errors.Is(err, bufio.ErrBufferFull):
260			continue
261		case errors.Is(err, io.EOF):
262			return bytes.TrimRight(line, "\r\n"), io.EOF
263		default:
264			return nil, err
265		}
266	}
267}
268
269func splitSessionSearchText(text string) []string {
270	text = sanitizeSessionSearchText(text)
271	if text == "" {
272		return nil
273	}
274	runes := []rune(text)
275	if len(runes) <= maxSessionSearchRunes {
276		return []string{text}
277	}
278	segments := make([]string, 0, len(runes)/maxSessionSearchRunes+1)
279	for len(runes) > 0 {
280		end := min(len(runes), maxSessionSearchRunes)
281		if end < len(runes) {
282			for split := end - 1; split > end/2; split-- {
283				if unicode.IsSpace(runes[split]) || unicode.Is(unicode.Sentence_Terminal, runes[split]) {
284					end = split + 1
285					break
286				}
287			}
288		}
289		segment := strings.TrimSpace(string(runes[:end]))
290		if segment != "" {
291			segments = append(segments, segment)
292		}
293		runes = runes[end:]
294	}
295	return segments
296}
297
298// NormalizeSessionSearchText returns the display-safe case-folded form used
299// by the picker matcher. It removes terminal and bidi controls and collapses
300// whitespace without retaining any raw transcript representation.
301func NormalizeSessionSearchText(text string) string {
302	return strings.ToLower(sanitizeSessionSearchText(text))
303}
304
305func sanitizeSessionSearchText(text string) string {
306	var out strings.Builder
307	out.Grow(len(text))
308	space := true
309	for _, r := range text {
310		if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || unicode.Is(unicode.Cf, r) {
311			continue
312		}
313		if unicode.IsSpace(r) {
314			if !space {
315				out.WriteByte(' ')
316				space = true
317			}
318			continue
319		}
320		out.WriteRune(r)
321		space = false
322	}
323	return strings.TrimSpace(out.String())
324}

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 1

Tags 476

↑↓ moveenter openesc close