v0.6.0 01ec125

session_portable_test.go RawHistoryFolder

1package core
2
3import (
4	"encoding/json"
5	"os"
6	"path/filepath"
7	"strings"
8	"testing"
9
10	"github.com/bnema/zut/packages/provider"
11)
12
13// TestSessionExportImportRoundTrip writes a few messages to a live
14// session, exports it, imports the export under a different cwd,
15// and verifies OpenSession on the imported file yields the same
16// message payloads.
17func TestSessionExportImportRoundTrip(t *testing.T) {
18	root := t.TempDir()
19	originalCWD := "/path/to/project"
20	sess, err := NewSession(root, originalCWD, "anthropic", "claude-opus-4-7", "0.0.0-test")
21	if err != nil {
22		t.Fatal(err)
23	}
24	_ = sess.AppendMessage(provider.Message{
25		Role:    provider.RoleUser,
26		Content: []provider.Content{provider.TextBlock{Text: "hello from the exporter"}},
27	})
28	_ = sess.AppendMessage(provider.Message{
29		Role:    provider.RoleAssistant,
30		Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}},
31	})
32	_ = sess.Close()
33
34	// Export to a directory — helper should build a name inside it.
35	exportDir := t.TempDir()
36	exportPath, err := ExportSession(sess.Path, exportDir)
37	if err != nil {
38		t.Fatalf("ExportSession: %v", err)
39	}
40	if !strings.HasSuffix(exportPath, PortableExt) {
41		t.Errorf("exported path should end in %s, got %q", PortableExt, exportPath)
42	}
43	if _, err := os.Stat(exportPath); err != nil {
44		t.Fatalf("exported file doesn't exist: %v", err)
45	}
46
47	// Import into a different root + cwd.
48	root2 := t.TempDir()
49	cwd2 := "/some/other/project"
50	importedPath, err := ImportSession(exportPath, root2, cwd2, "0.0.0-test")
51	if err != nil {
52		t.Fatalf("ImportSession: %v", err)
53	}
54	if filepath.Dir(importedPath) != SessionsDir(root2, cwd2) {
55		t.Errorf("imported file should land in SessionsDir, got %q", importedPath)
56	}
57
58	// Reopen and verify message round-trip.
59	imported, msgs, err := OpenSession(importedPath)
60	if err != nil {
61		t.Fatalf("OpenSession: %v", err)
62	}
63	defer imported.Close()
64	if imported.Meta.CWD != cwd2 {
65		t.Errorf("meta cwd: want %q, got %q", cwd2, imported.Meta.CWD)
66	}
67	if imported.Meta.ID == sess.ID {
68		t.Errorf("imported session kept the original id %q; must be rotated", sess.ID)
69	}
70	if imported.Meta.Model != "claude-opus-4-7" {
71		t.Errorf("model not preserved: %q", imported.Meta.Model)
72	}
73	if len(msgs) != 2 {
74		t.Fatalf("want 2 messages, got %d", len(msgs))
75	}
76	// Text should round-trip.
77	if extractText(msgs[0]) != "hello from the exporter" {
78		t.Errorf("msg 0 mismatch: %q", extractText(msgs[0]))
79	}
80	if extractText(msgs[1]) != "hi — reply from the assistant" {
81		t.Errorf("msg 1 mismatch: %q", extractText(msgs[1]))
82	}
83}
84
85func TestExportImportUsesLatestProviderModelMetadata(t *testing.T) {
86	root := t.TempDir()
87	session, err := NewSession(root, "/source", "old-provider", "old-model", "test")
88	if err != nil {
89		t.Fatal(err)
90	}
91	_ = session.AppendMessage(provider.Message{
92		Role:    provider.RoleUser,
93		Content: []provider.Content{provider.TextBlock{Text: "metadata prompt"}},
94	})
95	if err := session.UpdateModel("new-provider", "new-model"); err != nil {
96		t.Fatal(err)
97	}
98	path := session.Path
99	if err := session.Close(); err != nil {
100		t.Fatal(err)
101	}
102
103	exportPath, err := ExportSession(path, filepath.Join(t.TempDir(), "latest"+PortableExt))
104	if err != nil {
105		t.Fatalf("ExportSession: %v", err)
106	}
107	exported, err := ReadSessionSnapshot(exportPath)
108	if err != nil {
109		t.Fatalf("Read exported snapshot: %v", err)
110	}
111	if exported.Meta.Provider != "new-provider" || exported.Meta.Model != "new-model" {
112		t.Fatalf("export metadata = provider %q model %q, want latest values", exported.Meta.Provider, exported.Meta.Model)
113	}
114
115	importedPath, err := ImportSession(exportPath, t.TempDir(), "/destination", "test")
116	if err != nil {
117		t.Fatalf("ImportSession: %v", err)
118	}
119	imported, _, err := OpenSession(importedPath)
120	if err != nil {
121		t.Fatalf("Open imported session: %v", err)
122	}
123	defer imported.Close()
124	if imported.Meta.Provider != "new-provider" || imported.Meta.Model != "new-model" {
125		t.Fatalf("import metadata = provider %q model %q, want latest values", imported.Meta.Provider, imported.Meta.Model)
126	}
127}
128
129// TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages verifies that
130// metadata lookup follows append-only model updates without reconstructing the
131// transcript.
132func TestScanSessionMetaReturnsLatestRowWithoutHydratingMessages(t *testing.T) {
133	root := t.TempDir()
134	session, err := NewSession(root, "/workspace", "old-provider", "old-model", "test")
135	if err != nil {
136		t.Fatal(err)
137	}
138	if err := session.AppendMessage(provider.Message{
139		Role:    provider.RoleUser,
140		Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("large transcript ", 1000)}},
141	}); err != nil {
142		t.Fatal(err)
143	}
144	if err := session.UpdateModel("new-provider", "new-model"); err != nil {
145		t.Fatal(err)
146	}
147	if err := session.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
148		t.Fatal(err)
149	}
150	path := session.Path
151	if err := session.Close(); err != nil {
152		t.Fatal(err)
153	}
154
155	meta, err := scanSessionMeta(path)
156	if err != nil {
157		t.Fatal(err)
158	}
159	if meta.Provider != "new-provider" || meta.Model != "new-model" {
160		t.Fatalf("scanned metadata = %q/%q, want latest values", meta.Provider, meta.Model)
161	}
162}
163
164// TestExportToFilePath writes to an explicit file path (no directory
165// guessing) and checks the .zutsession extension is appended when missing.
166func TestExportToFilePath(t *testing.T) {
167	root := t.TempDir()
168	sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
169	if err != nil {
170		t.Fatal(err)
171	}
172	_ = sess.AppendMessage(provider.Message{
173		Role:    provider.RoleUser,
174		Content: []provider.Content{provider.TextBlock{Text: "x"}},
175	})
176	_ = sess.Close()
177
178	// No extension — should add .zutsession.
179	dst := filepath.Join(t.TempDir(), "mysession")
180	out, err := ExportSession(sess.Path, dst)
181	if err != nil {
182		t.Fatal(err)
183	}
184	if !strings.HasSuffix(out, PortableExt) {
185		t.Errorf("want .zutsession suffix on %q", out)
186	}
187}
188
189// TestExportStripsCWDFromMeta verifies the exported meta no longer
190// carries the source user's cwd (not useful to the recipient).
191func TestExportSessionHandlesHugeJSONLRows(t *testing.T) {
192	root := t.TempDir()
193	sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
194	if err != nil {
195		t.Fatal(err)
196	}
197	_ = sess.AppendMessage(provider.Message{
198		Role:    provider.RoleUser,
199		Content: []provider.Content{provider.TextBlock{Text: "first prompt names export"}},
200	})
201	_ = sess.AppendMessage(provider.Message{
202		Role:    provider.RoleAssistant,
203		Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", 22*1024*1024)}},
204	})
205	_ = sess.Close()
206
207	exportDir := t.TempDir()
208	out, err := ExportSession(sess.Path, exportDir)
209	if err != nil {
210		t.Fatalf("ExportSession with huge row: %v", err)
211	}
212	if _, err := os.Stat(out); err != nil {
213		t.Fatalf("exported file missing: %v", err)
214	}
215	opened, msgs, err := OpenSession(sess.Path)
216	if err != nil {
217		t.Fatalf("OpenSession with huge row: %v", err)
218	}
219	_ = opened.Close()
220	if len(msgs) != 2 {
221		t.Fatalf("OpenSession messages=%d, want 2", len(msgs))
222	}
223	// Ensure the exported file is still readable JSONL and contains the
224	// huge assistant message row, not just the meta/header.
225	b, err := os.ReadFile(out)
226	if err != nil {
227		t.Fatal(err)
228	}
229	if !strings.Contains(string(b), strings.Repeat("x", 1024)) {
230		t.Fatalf("export does not appear to contain huge assistant row")
231	}
232	for n, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
233		var raw map[string]any
234		if err := json.Unmarshal([]byte(line), &raw); err != nil {
235			t.Fatalf("exported line %d is invalid json: %v", n+1, err)
236		}
237	}
238}
239
240func TestExportStripsCWDFromMeta(t *testing.T) {
241	root := t.TempDir()
242	sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
243	if err != nil {
244		t.Fatal(err)
245	}
246	_ = sess.AppendMessage(provider.Message{
247		Role:    provider.RoleUser,
248		Content: []provider.Content{provider.TextBlock{Text: "x"}},
249	})
250	_ = sess.Close()
251
252	out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt))
253	if err != nil {
254		t.Fatal(err)
255	}
256	b, _ := os.ReadFile(out)
257	if strings.Contains(string(b), "/original/cwd") {
258		t.Errorf("exported file leaks the source cwd: %s", string(b))
259	}
260}
261
262// TestBranchSessionCopiesPrefix writes several messages to a
263// session, branches at message index 2 (first user + first
264// assistant), and verifies the new session has exactly those two
265// messages with parent + fork_point meta set.
266func TestBranchSessionCopiesPrefix(t *testing.T) {
267	root := t.TempDir()
268	cwd := "/project"
269	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
270	if err != nil {
271		t.Fatal(err)
272	}
273	_ = parent.AppendMessage(provider.Message{
274		Role:    provider.RoleUser,
275		Content: []provider.Content{provider.TextBlock{Text: "first"}},
276	})
277	_ = parent.AppendMessage(provider.Message{
278		Role:    provider.RoleAssistant,
279		Content: []provider.Content{provider.TextBlock{Text: "first reply"}},
280	})
281	_ = parent.AppendMessage(provider.Message{
282		Role:    provider.RoleUser,
283		Content: []provider.Content{provider.TextBlock{Text: "second"}},
284	})
285	_ = parent.Close()
286
287	// Branch at the first user+assistant pair (upToMessageIdx=2).
288	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
289	if err != nil {
290		t.Fatalf("BranchSession: %v", err)
291	}
292	branch, msgs, err := OpenSession(branchPath)
293	if err != nil {
294		t.Fatal(err)
295	}
296	defer branch.Close()
297
298	if len(msgs) != 2 {
299		t.Errorf("want 2 copied messages, got %d", len(msgs))
300	}
301	if branch.Meta.Parent != parent.Meta.ID {
302		t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
303	}
304	if branch.Meta.ForkPoint != 2 {
305		t.Errorf("fork_point: want 2, got %d", branch.Meta.ForkPoint)
306	}
307	if branch.Meta.ID == parent.Meta.ID {
308		t.Errorf("branch kept parent id; must rotate")
309	}
310	if branch.Meta.Title != "" {
311		t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
312	}
313	if summary := describeSession(branchPath); summary.Title != "first" {
314		t.Errorf("branch title: want %q, got %q", "first", summary.Title)
315	}
316}
317
318func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
319	root := t.TempDir()
320	cwd := "/project"
321	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
322	if err != nil {
323		t.Fatal(err)
324	}
325	for _, msg := range []provider.Message{
326		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
327		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
328		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
329	} {
330		_ = parent.AppendMessage(msg)
331	}
332	_ = parent.Close()
333
334	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
335	if err != nil {
336		t.Fatalf("BranchSession: %v", err)
337	}
338	if summary := describeSession(branchPath); summary.Title != "second prompt" {
339		t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
340	}
341}
342
343func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
344	root := t.TempDir()
345	cwd := "/project"
346	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
347	if err != nil {
348		t.Fatal(err)
349	}
350	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
351	_ = parent.Close()
352
353	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
354	if err != nil {
355		t.Fatalf("BranchSession: %v", err)
356	}
357	branch, _, err := OpenSession(branchPath)
358	if err != nil {
359		t.Fatal(err)
360	}
361	_ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
362	_ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
363	_ = branch.Close()
364
365	if summary := describeSession(branchPath); summary.Title != "how are you?" {
366		t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
367	}
368}
369
370func TestGeneratedBranchTitleWinsWhenWrittenBeforeBranchPrompt(t *testing.T) {
371	root := t.TempDir()
372	cwd := "/project"
373	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
374	if err != nil {
375		t.Fatal(err)
376	}
377	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
378	_ = parent.Close()
379
380	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
381	if err != nil {
382		t.Fatalf("BranchSession: %v", err)
383	}
384	branch, _, err := OpenSession(branchPath)
385	if err != nil {
386		t.Fatal(err)
387	}
388	if err := branch.UpdateTitle("AI branch title"); err != nil {
389		t.Fatal(err)
390	}
391	if err := branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}); err != nil {
392		t.Fatal(err)
393	}
394	if err := branch.Close(); err != nil {
395		t.Fatal(err)
396	}
397
398	if summary := describeSession(branchPath); summary.Title != "AI branch title" {
399		t.Errorf("branch title: want %q, got %q", "AI branch title", summary.Title)
400	}
401}
402
403func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
404	root := t.TempDir()
405	cwd := "/project"
406	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
407	if err != nil {
408		t.Fatal(err)
409	}
410	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
411	_ = parent.Close()
412
413	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
414	if err != nil {
415		t.Fatalf("BranchSession: %v", err)
416	}
417	branch, _, err := OpenSession(branchPath)
418	if err != nil {
419		t.Fatal(err)
420	}
421	_ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
422	_ = branch.Close()
423	if err := RenameSession(branchPath, "friendly branch name"); err != nil {
424		t.Fatal(err)
425	}
426
427	if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
428		t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
429	}
430}
431
432func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
433	root := t.TempDir()
434	cwd := "/project"
435	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
436	if err != nil {
437		t.Fatal(err)
438	}
439	for _, text := range []string{"raw-a", "raw-b", "raw-c", "raw-d"} {
440		_ = parent.AppendMessage(provider.Message{
441			Role:    provider.RoleUser,
442			Content: []provider.Content{provider.TextBlock{Text: text}},
443		})
444	}
445	_ = parent.AppendCompaction([]provider.Message{
446		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
447		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail-c"}}},
448		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "tail-d"}}},
449	})
450	_ = parent.AppendMessage(provider.Message{
451		Role:    provider.RoleUser,
452		Content: []provider.Content{provider.TextBlock{Text: "after-compact"}},
453	})
454	_ = parent.Close()
455
456	opened, msgs, err := OpenSession(parent.Path)
457	if err != nil {
458		t.Fatalf("OpenSession parent: %v", err)
459	}
460	_ = opened.Close()
461	assertMessageTexts(t, msgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
462
463	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 4)
464	if err != nil {
465		t.Fatalf("BranchSession: %v", err)
466	}
467	branch, branchMsgs, err := OpenSession(branchPath)
468	if err != nil {
469		t.Fatalf("OpenSession branch: %v", err)
470	}
471	defer branch.Close()
472
473	assertMessageTexts(t, branchMsgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
474	if branch.Meta.Parent != parent.Meta.ID {
475		t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
476	}
477	if branch.Meta.ForkPoint != 4 {
478		t.Errorf("fork_point: want 4, got %d", branch.Meta.ForkPoint)
479	}
480
481	shortBranchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
482	if err != nil {
483		t.Fatalf("BranchSession short fork: %v", err)
484	}
485	shortBranch, shortBranchMsgs, err := OpenSession(shortBranchPath)
486	if err != nil {
487		t.Fatalf("OpenSession short branch: %v", err)
488	}
489	defer shortBranch.Close()
490
491	assertMessageTexts(t, shortBranchMsgs, []string{"summary", "tail-c"})
492	if shortBranch.Meta.ForkPoint != 2 {
493		t.Errorf("short branch fork_point: want 2, got %d", shortBranch.Meta.ForkPoint)
494	}
495}
496
497func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
498	root := t.TempDir()
499	cwd := "/project"
500	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
501	if err != nil {
502		t.Fatal(err)
503	}
504	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}); err != nil {
505		t.Fatal(err)
506	}
507	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
508		t.Fatal(err)
509	}
510	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}); err != nil {
511		t.Fatal(err)
512	}
513	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":2}`)); err != nil {
514		t.Fatal(err)
515	}
516	if err := parent.Close(); err != nil {
517		t.Fatal(err)
518	}
519
520	branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
521	if err != nil {
522		t.Fatal(err)
523	}
524	branch, _, err := OpenSession(branchPath)
525	if err != nil {
526		t.Fatal(err)
527	}
528	t.Cleanup(func() { _ = branch.Close() })
529	if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
530		t.Fatalf("branch extension state = %q, want version 1", got)
531	}
532}
533
534func TestBranchSessionCarriesStateAcrossCompactionBoundary(t *testing.T) {
535	root := t.TempDir()
536	cwd := "/project"
537	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
538	if err != nil {
539		t.Fatal(err)
540	}
541	for _, text := range []string{"before-a", "before-b"} {
542		if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
543			t.Fatal(err)
544		}
545	}
546	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
547		t.Fatal(err)
548	}
549	if err := parent.AppendCompaction([]provider.Message{
550		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
551		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail"}}},
552	}); err != nil {
553		t.Fatal(err)
554	}
555	if err := parent.Close(); err != nil {
556		t.Fatal(err)
557	}
558
559	branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
560	if err != nil {
561		t.Fatal(err)
562	}
563	branch, _, err := OpenSession(branchPath)
564	if err != nil {
565		t.Fatal(err)
566	}
567	t.Cleanup(func() { _ = branch.Close() })
568	if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
569		t.Fatalf("compacted branch extension state = %q, want version 1", got)
570	}
571}
572
573// TestBuildSessionTree verifies parent/child edges are rebuilt
574// from meta + sibling-scan.
575func TestBuildSessionTree(t *testing.T) {
576	root := t.TempDir()
577	cwd := "/project"
578	parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
579	_ = parent.AppendMessage(provider.Message{
580		Role:    provider.RoleUser,
581		Content: []provider.Content{provider.TextBlock{Text: "x"}},
582	})
583	_ = parent.Close()
584
585	childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
586	if err != nil {
587		t.Fatal(err)
588	}
589	childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
590	if err != nil {
591		t.Fatal(err)
592	}
593	_ = childA
594	_ = childB
595
596	tree := BuildSessionTree(root, cwd)
597	if len(tree) != 1 {
598		t.Fatalf("want 1 root, got %d", len(tree))
599	}
600	rootNode := tree[0]
601	if rootNode.Meta.ID != parent.Meta.ID {
602		t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
603	}
604	if len(rootNode.Children) != 2 {
605		t.Errorf("want 2 children, got %d", len(rootNode.Children))
606	}
607}
608
609func assertMessageTexts(t *testing.T, msgs []provider.Message, want []string) {
610	t.Helper()
611	if len(msgs) != len(want) {
612		t.Fatalf("message count: want %d, got %d", len(want), len(msgs))
613	}
614	for i, msg := range msgs {
615		if got := extractText(msg); got != want[i] {
616			t.Errorf("message %d: want %q, got %q", i, want[i], got)
617		}
618	}
619}

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