v0.0.57 ef80f9c

session_portable.go RawHistoryFolder

1package core
2
3import (
4	"bufio"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"io"
9	"os"
10	"path/filepath"
11	"strings"
12	"time"
13
14	"github.com/google/uuid"
15)
16
17// PortableExt is the filesystem extension used for exported sessions.
18// A ".zotsession" is just a zot JSONL session file with the meta
19// header rewritten so the importing user gets fresh ownership.
20const PortableExt = ".zotsession"
21
22// ExportSession writes the session at srcPath to dstPath as a
23// portable .zotsession file. If dstPath is an existing directory the
24// file is created inside it with a name derived from the session's
25// meta ("YYYYMMDD-HHMMSS-<first-prompt-excerpt>.zotsession"). The
26// destination's directory is created if needed. Returns the final
27// resolved path so the caller can tell the user where it landed.
28//
29// The on-disk format is unchanged from a live session; only the
30// meta.cwd is stripped of its per-machine prefix (the importing
31// user doesn't care what directory it came from). Everything else
32// round-trips byte-for-byte.
33func ExportSession(srcPath, dstPath string) (string, error) {
34	if srcPath == "" {
35		return "", errors.New("export: source path is empty")
36	}
37	if dstPath == "" {
38		return "", errors.New("export: destination path is empty")
39	}
40
41	// Read the source meta up-front so we can name the output sensibly
42	// when dstPath is a directory, and so we can validate it's a real
43	// session before starting to write.
44	src, err := os.Open(srcPath)
45	if err != nil {
46		return "", fmt.Errorf("export: open source: %w", err)
47	}
48	defer src.Close()
49
50	sc := bufio.NewScanner(src)
51	sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
52	if !sc.Scan() {
53		return "", errors.New("export: session file is empty")
54	}
55	var head sessionLine
56	if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
57		return "", fmt.Errorf("export: parse meta: %w", err)
58	}
59	if head.Type != "meta" || head.Meta == nil {
60		return "", errors.New("export: first line is not a meta row")
61	}
62
63	// Scan the rest of the file for the first user message so we can
64	// build a humane filename. Only reads if dstPath doesn't already
65	// end in .zotsession.
66	firstPrompt := ""
67	if !strings.HasSuffix(strings.ToLower(dstPath), PortableExt) {
68		if fi, _ := os.Stat(dstPath); fi == nil || fi.IsDir() {
69			firstPrompt = firstUserPrompt(sc)
70		}
71	}
72
73	// Resolve dstPath: if it's a directory, build a name inside it.
74	outPath := dstPath
75	if fi, err := os.Stat(dstPath); err == nil && fi.IsDir() {
76		name := filenameFor(head.Meta.Started, head.Meta.ID, firstPrompt)
77		outPath = filepath.Join(dstPath, name)
78	} else if !strings.HasSuffix(strings.ToLower(outPath), PortableExt) {
79		outPath += PortableExt
80	}
81
82	// Re-open the source from the top since we advanced the scanner.
83	if _, err := src.Seek(0, io.SeekStart); err != nil {
84		return "", fmt.Errorf("export: rewind: %w", err)
85	}
86
87	if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
88		return "", fmt.Errorf("export: mkdir dst: %w", err)
89	}
90	dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
91	if err != nil {
92		return "", fmt.Errorf("export: create dst: %w", err)
93	}
94	defer dst.Close()
95	bw := bufio.NewWriter(dst)
96
97	// Rewrite the meta row: strip the cwd (the importing user has
98	// their own) and keep everything else identical. ID stays so the
99	// export is traceable; the importer will rotate to a fresh ID.
100	exportMeta := *head.Meta
101	exportMeta.CWD = ""
102	metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
103	if err != nil {
104		return "", fmt.Errorf("export: marshal meta: %w", err)
105	}
106	if _, err := bw.Write(metaLine); err != nil {
107		return "", err
108	}
109	if err := bw.WriteByte('\n'); err != nil {
110		return "", err
111	}
112
113	// Stream every non-meta row verbatim.
114	sc2 := bufio.NewScanner(src)
115	sc2.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
116	for sc2.Scan() {
117		line := sc2.Bytes()
118		var h sessionLineHead
119		if err := json.Unmarshal(line, &h); err != nil {
120			continue
121		}
122		if h.Type == "meta" {
123			continue // already wrote a rewritten copy above
124		}
125		if _, err := bw.Write(line); err != nil {
126			return "", err
127		}
128		if err := bw.WriteByte('\n'); err != nil {
129			return "", err
130		}
131	}
132	if err := sc2.Err(); err != nil {
133		return "", fmt.Errorf("export: read source: %w", err)
134	}
135	if err := bw.Flush(); err != nil {
136		return "", err
137	}
138	return outPath, nil
139}
140
141// ImportSession copies the .zotsession file at srcPath into the
142// running user's session store under the given root+cwd, rewriting
143// the meta's id / cwd / started fields so the imported session is
144// owned by the current user / directory / clock. Returns the path
145// of the created session file, ready to pass to OpenSession.
146//
147// The imported session is a first-class zot session: it'll show up
148// in /sessions, /jump, and on-disk summaries just like any other.
149// Messages and usage rows are preserved verbatim.
150func ImportSession(srcPath, root, cwd, version string) (string, error) {
151	if srcPath == "" {
152		return "", errors.New("import: source path is empty")
153	}
154	src, err := os.Open(srcPath)
155	if err != nil {
156		return "", fmt.Errorf("import: open source: %w", err)
157	}
158	defer src.Close()
159
160	// Validate the file header before committing to a destination.
161	sc := bufio.NewScanner(src)
162	sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
163	if !sc.Scan() {
164		return "", errors.New("import: session file is empty")
165	}
166	var head sessionLine
167	if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
168		return "", fmt.Errorf("import: parse meta: %w", err)
169	}
170	if head.Type != "meta" || head.Meta == nil {
171		return "", errors.New("import: first line is not a meta row")
172	}
173
174	// Build the destination inside the current cwd's session dir
175	// with a fresh timestamped name.
176	dir := SessionsDir(root, cwd)
177	if err := os.MkdirAll(dir, 0o755); err != nil {
178		return "", err
179	}
180	newID := uuid.NewString()
181	name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
182	outPath := filepath.Join(dir, name)
183	dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
184	if err != nil {
185		return "", fmt.Errorf("import: create dst: %w", err)
186	}
187	defer dst.Close()
188	bw := bufio.NewWriter(dst)
189
190	// Write a fresh meta row claiming ownership.
191	importMeta := SessionMeta{
192		ID:       newID,
193		CWD:      cwd,
194		Model:    head.Meta.Model,
195		Provider: head.Meta.Provider,
196		Started:  time.Now().UTC(),
197		Version:  version,
198	}
199	metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
200	if err != nil {
201		return "", fmt.Errorf("import: marshal meta: %w", err)
202	}
203	if _, err := bw.Write(metaLine); err != nil {
204		return "", err
205	}
206	if err := bw.WriteByte('\n'); err != nil {
207		return "", err
208	}
209
210	// Rewind the source and stream every non-meta row.
211	if _, err := src.Seek(0, io.SeekStart); err != nil {
212		return "", fmt.Errorf("import: rewind: %w", err)
213	}
214	sc2 := bufio.NewScanner(src)
215	sc2.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
216	for sc2.Scan() {
217		line := sc2.Bytes()
218		var h sessionLineHead
219		if err := json.Unmarshal(line, &h); err != nil {
220			continue
221		}
222		if h.Type == "meta" {
223			continue
224		}
225		if _, err := bw.Write(line); err != nil {
226			return "", err
227		}
228		if err := bw.WriteByte('\n'); err != nil {
229			return "", err
230		}
231	}
232	if err := sc2.Err(); err != nil {
233		return "", fmt.Errorf("import: read source: %w", err)
234	}
235	if err := bw.Flush(); err != nil {
236		return "", err
237	}
238	return outPath, nil
239}
240
241// firstUserPrompt scans forward from the current scanner position
242// looking for the first user-role message and returns its text
243// (trimmed, short). Used to build a humane export filename.
244func firstUserPrompt(sc *bufio.Scanner) string {
245	for sc.Scan() {
246		var line sessionLine
247		if err := json.Unmarshal(sc.Bytes(), &line); err != nil {
248			continue
249		}
250		if line.Type != "message" || line.Message == nil || line.Message.Role != "user" {
251			continue
252		}
253		for _, c := range line.Message.Content {
254			// Use type name to avoid an import of provider here beyond
255			// the already-imported alias; TextBlock is the only content
256			// shape that yields a useful preview, so we just look for
257			// something with a reasonable string form.
258			s := fmt.Sprintf("%v", c)
259			_ = s // formatted value is too noisy; go straight to typed path
260		}
261		// Simpler: marshal the message and fish out the first "text"
262		// we can find. Avoids reaching into the provider package just
263		// for an interface type check.
264		b, _ := json.Marshal(line.Message)
265		var m struct {
266			Content []struct {
267				Text string `json:"text"`
268			} `json:"content"`
269		}
270		_ = json.Unmarshal(b, &m)
271		for _, c := range m.Content {
272			if c.Text != "" {
273				return c.Text
274			}
275		}
276	}
277	return ""
278}
279
280// filenameFor builds a descriptive .zotsession filename from the
281// session's start time and, when available, an excerpt of the
282// first user prompt.
283func filenameFor(started time.Time, id, firstPrompt string) string {
284	base := started.UTC().Format("20060102-150405")
285	if id != "" && len(id) >= 8 {
286		base += "-" + id[:8]
287	}
288	slug := slugify(firstPrompt, 40)
289	if slug != "" {
290		base += "-" + slug
291	}
292	return base + PortableExt
293}
294
295// slugify lowercases, strips punctuation, collapses whitespace to
296// hyphens, and truncates to max runes so it's safe as a filename.
297func slugify(s string, max int) string {
298	s = strings.TrimSpace(strings.ToLower(s))
299	if s == "" {
300		return ""
301	}
302	var out strings.Builder
303	prevDash := false
304	for _, r := range s {
305		switch {
306		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
307			out.WriteRune(r)
308			prevDash = false
309		default:
310			if !prevDash && out.Len() > 0 {
311				out.WriteByte('-')
312				prevDash = true
313			}
314		}
315		if out.Len() >= max {
316			break
317		}
318	}
319	return strings.TrimRight(out.String(), "-")
320}

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