v0.2.0 42a5079

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	path := session.Path
148	if err := session.Close(); err != nil {
149		t.Fatal(err)
150	}
151
152	meta, err := scanSessionMeta(path)
153	if err != nil {
154		t.Fatal(err)
155	}
156	if meta.Provider != "new-provider" || meta.Model != "new-model" {
157		t.Fatalf("scanned metadata = %q/%q, want latest values", meta.Provider, meta.Model)
158	}
159}
160
161// TestExportToFilePath writes to an explicit file path (no directory
162// guessing) and checks the .zutsession extension is appended when missing.
163func TestExportToFilePath(t *testing.T) {
164	root := t.TempDir()
165	sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
166	if err != nil {
167		t.Fatal(err)
168	}
169	_ = sess.AppendMessage(provider.Message{
170		Role:    provider.RoleUser,
171		Content: []provider.Content{provider.TextBlock{Text: "x"}},
172	})
173	_ = sess.Close()
174
175	// No extension — should add .zutsession.
176	dst := filepath.Join(t.TempDir(), "mysession")
177	out, err := ExportSession(sess.Path, dst)
178	if err != nil {
179		t.Fatal(err)
180	}
181	if !strings.HasSuffix(out, PortableExt) {
182		t.Errorf("want .zutsession suffix on %q", out)
183	}
184}
185
186// TestExportStripsCWDFromMeta verifies the exported meta no longer
187// carries the source user's cwd (not useful to the recipient).
188func TestExportSessionHandlesHugeJSONLRows(t *testing.T) {
189	root := t.TempDir()
190	sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
191	if err != nil {
192		t.Fatal(err)
193	}
194	_ = sess.AppendMessage(provider.Message{
195		Role:    provider.RoleUser,
196		Content: []provider.Content{provider.TextBlock{Text: "first prompt names export"}},
197	})
198	_ = sess.AppendMessage(provider.Message{
199		Role:    provider.RoleAssistant,
200		Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", 22*1024*1024)}},
201	})
202	_ = sess.Close()
203
204	exportDir := t.TempDir()
205	out, err := ExportSession(sess.Path, exportDir)
206	if err != nil {
207		t.Fatalf("ExportSession with huge row: %v", err)
208	}
209	if _, err := os.Stat(out); err != nil {
210		t.Fatalf("exported file missing: %v", err)
211	}
212	opened, msgs, err := OpenSession(sess.Path)
213	if err != nil {
214		t.Fatalf("OpenSession with huge row: %v", err)
215	}
216	_ = opened.Close()
217	if len(msgs) != 2 {
218		t.Fatalf("OpenSession messages=%d, want 2", len(msgs))
219	}
220	// Ensure the exported file is still readable JSONL and contains the
221	// huge assistant message row, not just the meta/header.
222	b, err := os.ReadFile(out)
223	if err != nil {
224		t.Fatal(err)
225	}
226	if !strings.Contains(string(b), strings.Repeat("x", 1024)) {
227		t.Fatalf("export does not appear to contain huge assistant row")
228	}
229	for n, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
230		var raw map[string]any
231		if err := json.Unmarshal([]byte(line), &raw); err != nil {
232			t.Fatalf("exported line %d is invalid json: %v", n+1, err)
233		}
234	}
235}
236
237func TestExportStripsCWDFromMeta(t *testing.T) {
238	root := t.TempDir()
239	sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test")
240	if err != nil {
241		t.Fatal(err)
242	}
243	_ = sess.AppendMessage(provider.Message{
244		Role:    provider.RoleUser,
245		Content: []provider.Content{provider.TextBlock{Text: "x"}},
246	})
247	_ = sess.Close()
248
249	out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt))
250	if err != nil {
251		t.Fatal(err)
252	}
253	b, _ := os.ReadFile(out)
254	if strings.Contains(string(b), "/original/cwd") {
255		t.Errorf("exported file leaks the source cwd: %s", string(b))
256	}
257}
258
259// TestBranchSessionCopiesPrefix writes several messages to a
260// session, branches at message index 2 (first user + first
261// assistant), and verifies the new session has exactly those two
262// messages with parent + fork_point meta set.
263func TestBranchSessionCopiesPrefix(t *testing.T) {
264	root := t.TempDir()
265	cwd := "/project"
266	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
267	if err != nil {
268		t.Fatal(err)
269	}
270	_ = parent.AppendMessage(provider.Message{
271		Role:    provider.RoleUser,
272		Content: []provider.Content{provider.TextBlock{Text: "first"}},
273	})
274	_ = parent.AppendMessage(provider.Message{
275		Role:    provider.RoleAssistant,
276		Content: []provider.Content{provider.TextBlock{Text: "first reply"}},
277	})
278	_ = parent.AppendMessage(provider.Message{
279		Role:    provider.RoleUser,
280		Content: []provider.Content{provider.TextBlock{Text: "second"}},
281	})
282	_ = parent.Close()
283
284	// Branch at the first user+assistant pair (upToMessageIdx=2).
285	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
286	if err != nil {
287		t.Fatalf("BranchSession: %v", err)
288	}
289	branch, msgs, err := OpenSession(branchPath)
290	if err != nil {
291		t.Fatal(err)
292	}
293	defer branch.Close()
294
295	if len(msgs) != 2 {
296		t.Errorf("want 2 copied messages, got %d", len(msgs))
297	}
298	if branch.Meta.Parent != parent.Meta.ID {
299		t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
300	}
301	if branch.Meta.ForkPoint != 2 {
302		t.Errorf("fork_point: want 2, got %d", branch.Meta.ForkPoint)
303	}
304	if branch.Meta.ID == parent.Meta.ID {
305		t.Errorf("branch kept parent id; must rotate")
306	}
307	if branch.Meta.Title != "" {
308		t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
309	}
310	if summary := describeSession(branchPath); summary.Title != "first" {
311		t.Errorf("branch title: want %q, got %q", "first", summary.Title)
312	}
313}
314
315func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
316	root := t.TempDir()
317	cwd := "/project"
318	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
319	if err != nil {
320		t.Fatal(err)
321	}
322	for _, msg := range []provider.Message{
323		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
324		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
325		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
326	} {
327		_ = parent.AppendMessage(msg)
328	}
329	_ = parent.Close()
330
331	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
332	if err != nil {
333		t.Fatalf("BranchSession: %v", err)
334	}
335	if summary := describeSession(branchPath); summary.Title != "second prompt" {
336		t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
337	}
338}
339
340func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
341	root := t.TempDir()
342	cwd := "/project"
343	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
344	if err != nil {
345		t.Fatal(err)
346	}
347	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
348	_ = parent.Close()
349
350	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
351	if err != nil {
352		t.Fatalf("BranchSession: %v", err)
353	}
354	branch, _, err := OpenSession(branchPath)
355	if err != nil {
356		t.Fatal(err)
357	}
358	_ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
359	_ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
360	_ = branch.Close()
361
362	if summary := describeSession(branchPath); summary.Title != "how are you?" {
363		t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
364	}
365}
366
367func TestGeneratedBranchTitleWinsWhenWrittenBeforeBranchPrompt(t *testing.T) {
368	root := t.TempDir()
369	cwd := "/project"
370	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
371	if err != nil {
372		t.Fatal(err)
373	}
374	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
375	_ = parent.Close()
376
377	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
378	if err != nil {
379		t.Fatalf("BranchSession: %v", err)
380	}
381	branch, _, err := OpenSession(branchPath)
382	if err != nil {
383		t.Fatal(err)
384	}
385	if err := branch.UpdateTitle("AI branch title"); err != nil {
386		t.Fatal(err)
387	}
388	if err := branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}); err != nil {
389		t.Fatal(err)
390	}
391	if err := branch.Close(); err != nil {
392		t.Fatal(err)
393	}
394
395	if summary := describeSession(branchPath); summary.Title != "AI branch title" {
396		t.Errorf("branch title: want %q, got %q", "AI branch title", summary.Title)
397	}
398}
399
400func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
401	root := t.TempDir()
402	cwd := "/project"
403	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
404	if err != nil {
405		t.Fatal(err)
406	}
407	_ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
408	_ = parent.Close()
409
410	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
411	if err != nil {
412		t.Fatalf("BranchSession: %v", err)
413	}
414	branch, _, err := OpenSession(branchPath)
415	if err != nil {
416		t.Fatal(err)
417	}
418	_ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
419	_ = branch.Close()
420	if err := RenameSession(branchPath, "friendly branch name"); err != nil {
421		t.Fatal(err)
422	}
423
424	if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
425		t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
426	}
427}
428
429func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
430	root := t.TempDir()
431	cwd := "/project"
432	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
433	if err != nil {
434		t.Fatal(err)
435	}
436	for _, text := range []string{"raw-a", "raw-b", "raw-c", "raw-d"} {
437		_ = parent.AppendMessage(provider.Message{
438			Role:    provider.RoleUser,
439			Content: []provider.Content{provider.TextBlock{Text: text}},
440		})
441	}
442	_ = parent.AppendCompaction([]provider.Message{
443		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
444		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail-c"}}},
445		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "tail-d"}}},
446	})
447	_ = parent.AppendMessage(provider.Message{
448		Role:    provider.RoleUser,
449		Content: []provider.Content{provider.TextBlock{Text: "after-compact"}},
450	})
451	_ = parent.Close()
452
453	opened, msgs, err := OpenSession(parent.Path)
454	if err != nil {
455		t.Fatalf("OpenSession parent: %v", err)
456	}
457	_ = opened.Close()
458	assertMessageTexts(t, msgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
459
460	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 4)
461	if err != nil {
462		t.Fatalf("BranchSession: %v", err)
463	}
464	branch, branchMsgs, err := OpenSession(branchPath)
465	if err != nil {
466		t.Fatalf("OpenSession branch: %v", err)
467	}
468	defer branch.Close()
469
470	assertMessageTexts(t, branchMsgs, []string{"summary", "tail-c", "tail-d", "after-compact"})
471	if branch.Meta.Parent != parent.Meta.ID {
472		t.Errorf("parent id: want %q, got %q", parent.Meta.ID, branch.Meta.Parent)
473	}
474	if branch.Meta.ForkPoint != 4 {
475		t.Errorf("fork_point: want 4, got %d", branch.Meta.ForkPoint)
476	}
477
478	shortBranchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
479	if err != nil {
480		t.Fatalf("BranchSession short fork: %v", err)
481	}
482	shortBranch, shortBranchMsgs, err := OpenSession(shortBranchPath)
483	if err != nil {
484		t.Fatalf("OpenSession short branch: %v", err)
485	}
486	defer shortBranch.Close()
487
488	assertMessageTexts(t, shortBranchMsgs, []string{"summary", "tail-c"})
489	if shortBranch.Meta.ForkPoint != 2 {
490		t.Errorf("short branch fork_point: want 2, got %d", shortBranch.Meta.ForkPoint)
491	}
492}
493
494func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
495	root := t.TempDir()
496	cwd := "/project"
497	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
498	if err != nil {
499		t.Fatal(err)
500	}
501	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}); err != nil {
502		t.Fatal(err)
503	}
504	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
505		t.Fatal(err)
506	}
507	if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}); err != nil {
508		t.Fatal(err)
509	}
510	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":2}`)); err != nil {
511		t.Fatal(err)
512	}
513	if err := parent.Close(); err != nil {
514		t.Fatal(err)
515	}
516
517	branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
518	if err != nil {
519		t.Fatal(err)
520	}
521	branch, _, err := OpenSession(branchPath)
522	if err != nil {
523		t.Fatal(err)
524	}
525	t.Cleanup(func() { _ = branch.Close() })
526	if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
527		t.Fatalf("branch extension state = %q, want version 1", got)
528	}
529}
530
531func TestBranchSessionCarriesStateAcrossCompactionBoundary(t *testing.T) {
532	root := t.TempDir()
533	cwd := "/project"
534	parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
535	if err != nil {
536		t.Fatal(err)
537	}
538	for _, text := range []string{"before-a", "before-b"} {
539		if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
540			t.Fatal(err)
541		}
542	}
543	if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
544		t.Fatal(err)
545	}
546	if err := parent.AppendCompaction([]provider.Message{
547		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
548		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail"}}},
549	}); err != nil {
550		t.Fatal(err)
551	}
552	if err := parent.Close(); err != nil {
553		t.Fatal(err)
554	}
555
556	branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
557	if err != nil {
558		t.Fatal(err)
559	}
560	branch, _, err := OpenSession(branchPath)
561	if err != nil {
562		t.Fatal(err)
563	}
564	t.Cleanup(func() { _ = branch.Close() })
565	if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
566		t.Fatalf("compacted branch extension state = %q, want version 1", got)
567	}
568}
569
570// TestBuildSessionTree verifies parent/child edges are rebuilt
571// from meta + sibling-scan.
572func TestBuildSessionTree(t *testing.T) {
573	root := t.TempDir()
574	cwd := "/project"
575	parent, _ := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
576	_ = parent.AppendMessage(provider.Message{
577		Role:    provider.RoleUser,
578		Content: []provider.Content{provider.TextBlock{Text: "x"}},
579	})
580	_ = parent.Close()
581
582	childA, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
583	if err != nil {
584		t.Fatal(err)
585	}
586	childB, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
587	if err != nil {
588		t.Fatal(err)
589	}
590	_ = childA
591	_ = childB
592
593	tree := BuildSessionTree(root, cwd)
594	if len(tree) != 1 {
595		t.Fatalf("want 1 root, got %d", len(tree))
596	}
597	rootNode := tree[0]
598	if rootNode.Meta.ID != parent.Meta.ID {
599		t.Errorf("root should be parent %q, got %q", parent.Meta.ID, rootNode.Meta.ID)
600	}
601	if len(rootNode.Children) != 2 {
602		t.Errorf("want 2 children, got %d", len(rootNode.Children))
603	}
604}
605
606func assertMessageTexts(t *testing.T, msgs []provider.Message, want []string) {
607	t.Helper()
608	if len(msgs) != len(want) {
609		t.Fatalf("message count: want %d, got %d", len(want), len(msgs))
610	}
611	for i, msg := range msgs {
612		if got := extractText(msg); got != want[i] {
613			t.Errorf("message %d: want %q, got %q", i, want[i], got)
614		}
615	}
616}

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