@cueloop/client 0.1.0-alpha.65 → 0.1.0-alpha.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +79 -112
  4. package/src/App.plan-review.test.tsx +23 -205
  5. package/src/App.prototype.test.tsx +1 -8
  6. package/src/App.readonly.test.tsx +9 -27
  7. package/src/App.submit-confirm.test.tsx +19 -72
  8. package/src/App.test.tsx +136 -31
  9. package/src/App.tsx +491 -322
  10. package/src/App.walk.test.tsx +15 -16
  11. package/src/App.workbench.test.tsx +390 -0
  12. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  13. package/src/annotation-palette.ts +61 -0
  14. package/src/app-screens.tsx +197 -77
  15. package/src/app-view-model.ts +9 -69
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +1 -1
  18. package/src/components/AnnotationCards.stories.tsx +87 -0
  19. package/src/components/AnnotationCards.tsx +303 -0
  20. package/src/components/AppHeader.stories.tsx +50 -0
  21. package/src/components/AppHeader.tsx +83 -0
  22. package/src/components/AppShell.stories.tsx +89 -0
  23. package/src/components/AppShell.tsx +239 -0
  24. package/src/components/AppShell.zoom.test.tsx +94 -0
  25. package/src/components/Breadcrumb.stories.tsx +1 -1
  26. package/src/components/ChangesColumn.stories.tsx +39 -0
  27. package/src/components/ChangesColumn.tsx +170 -0
  28. package/src/components/CodeBlock.stories.tsx +1 -1
  29. package/src/components/CompletionOverlay.stories.tsx +1 -1
  30. package/src/components/ConfirmCard.stories.tsx +1 -1
  31. package/src/components/ConfirmCard.tsx +3 -4
  32. package/src/components/ConfirmDialog.stories.tsx +1 -1
  33. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  34. package/src/components/DiffSheet.split.test.tsx +140 -0
  35. package/src/components/DiffSheet.stories.tsx +56 -54
  36. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  37. package/src/components/DiffSheet.tsx +607 -276
  38. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  39. package/src/components/DiscussionMarkerRail.tsx +181 -0
  40. package/src/components/EditorGrid.stories.tsx +59 -0
  41. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  42. package/src/components/EditorGrid.tsx +338 -0
  43. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  44. package/src/components/FileContentsView.stories.tsx +16 -0
  45. package/src/components/FileContentsView.test.tsx +29 -0
  46. package/src/components/FileContentsView.tsx +78 -0
  47. package/src/components/InboxList.stories.tsx +23 -8
  48. package/src/components/InboxList.tsx +188 -33
  49. package/src/components/KeybindsDialog.stories.tsx +1 -1
  50. package/src/components/MarkerPopover.stories.tsx +1 -1
  51. package/src/components/MenuBar.stories.tsx +3 -12
  52. package/src/components/MenuBar.tsx +25 -56
  53. package/src/components/PaneGrid.stories.tsx +84 -0
  54. package/src/components/PaneGrid.tsx +128 -0
  55. package/src/components/PanelColumn.stories.tsx +25 -0
  56. package/src/components/PanelColumn.tsx +95 -0
  57. package/src/components/ProjectTreeView.stories.tsx +25 -0
  58. package/src/components/ProjectTreeView.test.tsx +32 -0
  59. package/src/components/ProjectTreeView.tsx +82 -0
  60. package/src/components/PromptDialog.stories.tsx +1 -1
  61. package/src/components/PromptDialog.tsx +8 -1
  62. package/src/components/PrototypeSheet.stories.tsx +1 -1
  63. package/src/components/RootOverlay.stories.tsx +44 -0
  64. package/src/components/RootOverlay.tsx +35 -0
  65. package/src/components/SettingsDialog.stories.tsx +7 -1
  66. package/src/components/SettingsDialog.tsx +114 -67
  67. package/src/components/SettingsRows.stories.tsx +1 -1
  68. package/src/components/ShellHeader.stories.tsx +50 -0
  69. package/src/components/ShellHeader.tsx +137 -0
  70. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  71. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  72. package/src/components/SurfaceScrollbar.tsx +93 -0
  73. package/src/components/ThreadFooter.stories.tsx +30 -0
  74. package/src/components/ThreadFooter.test.tsx +31 -0
  75. package/src/components/ThreadFooter.tsx +54 -0
  76. package/src/components/ThreadView.slash.test.tsx +113 -0
  77. package/src/components/ThreadView.stories.tsx +1 -1
  78. package/src/components/ThreadView.tsx +62 -1352
  79. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  80. package/src/components/ThreadsSidebar.tsx +70 -0
  81. package/src/components/Toast.stories.tsx +1 -1
  82. package/src/components/Tooltip.stories.tsx +26 -0
  83. package/src/components/Tooltip.tsx +72 -0
  84. package/src/components/TreePane.stories.tsx +1 -1
  85. package/src/components/WalkWizard.stories.tsx +1 -1
  86. package/src/components/WelcomeSurface.stories.tsx +12 -0
  87. package/src/components/WelcomeSurface.tsx +52 -0
  88. package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
  89. package/src/components/agent-launcher.stories.tsx +1 -1
  90. package/src/components/diff-sheet-layout.test.ts +125 -55
  91. package/src/components/diff-sheet-layout.ts +3 -103
  92. package/src/components/editor-grid.test.ts +86 -0
  93. package/src/components/editor-grid.ts +138 -0
  94. package/src/components/fade-title.test.ts +49 -0
  95. package/src/components/fade-title.ts +61 -0
  96. package/src/components/file-tree.test.ts +60 -0
  97. package/src/components/file-tree.ts +112 -0
  98. package/src/components/pages/Workspace.stories.tsx +181 -0
  99. package/src/components/primitives/Button.stories.tsx +1 -1
  100. package/src/components/primitives/Card.stories.tsx +1 -1
  101. package/src/components/primitives/Dialog.stories.tsx +1 -1
  102. package/src/components/primitives/IconButton.stories.tsx +36 -0
  103. package/src/components/primitives/IconButton.tsx +62 -0
  104. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  105. package/src/components/primitives/Tabs.stories.tsx +1 -1
  106. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  107. package/src/components/primitives/Tree.stories.tsx +124 -0
  108. package/src/components/primitives/Tree.tsx +96 -0
  109. package/src/components/primitives/icons.ts +61 -0
  110. package/src/components/primitives/tree-model.test.ts +99 -0
  111. package/src/components/primitives/tree-model.ts +137 -0
  112. package/src/components/quick-actions-editor.stories.tsx +1 -1
  113. package/src/components/session-tree.test.ts +134 -0
  114. package/src/components/session-tree.ts +108 -0
  115. package/src/components/stories-app.tsx +142 -82
  116. package/src/components/story-tree.test.ts +100 -0
  117. package/src/components/story.ts +55 -0
  118. package/src/components/theme-context.stories.tsx +1 -1
  119. package/src/config.test.ts +20 -49
  120. package/src/config.ts +26 -28
  121. package/src/diff-fold.test.ts +78 -0
  122. package/src/diff-fold.ts +126 -0
  123. package/src/diff-syntax.test.ts +16 -0
  124. package/src/diff-syntax.ts +6 -3
  125. package/src/discussions.ts +79 -0
  126. package/src/intent-dispatch.test.ts +31 -11
  127. package/src/intent-dispatch.ts +93 -48
  128. package/src/key-bindings.test.ts +1 -1
  129. package/src/keymap.test.ts +3 -25
  130. package/src/keymap.ts +17 -18
  131. package/src/mark-runs.ts +120 -0
  132. package/src/pty.ts +10 -2
  133. package/src/run.ts +17 -7
  134. package/src/serve.test.ts +4 -8
  135. package/src/session-controller.curate.test.ts +75 -0
  136. package/src/session-controller.share.test.ts +1 -0
  137. package/src/session-controller.tree.test.ts +26 -0
  138. package/src/session-controller.ts +160 -31
  139. package/src/slash-palette.ts +83 -0
  140. package/src/split-diff.test.ts +110 -0
  141. package/src/split-diff.ts +149 -0
  142. package/src/test-support.test.ts +3 -3
  143. package/src/test-support.ts +39 -8
  144. package/src/theme.ts +4 -4
  145. package/src/thread-chords.test.ts +25 -9
  146. package/src/thread-chords.ts +33 -8
  147. package/src/use-annotation-surface.tsx +837 -0
  148. package/src/use-changes-workbench.ts +151 -0
  149. package/src/use-frame-measure.ts +38 -0
  150. package/src/use-settings-dialog.tsx +23 -24
  151. package/src/use-terminal-virtualizer.ts +233 -0
  152. package/src/view-diff.ts +114 -0
  153. package/src/App.review-panel.test.tsx +0 -140
  154. package/src/App.tree.test.tsx +0 -224
  155. package/src/components/ReviewPanel.stories.tsx +0 -100
  156. package/src/components/ReviewPanel.tsx +0 -103
  157. package/src/components/ReviewRail.stories.tsx +0 -232
  158. package/src/components/ReviewRail.tsx +0 -327
  159. package/src/review-panel.test.ts +0 -113
  160. package/src/review-panel.ts +0 -83
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
4
4
  import { AgentLauncher } from "./agent-launcher";
5
5
  import { fixturePlanSession } from "./story-fixtures";
6
6
 
7
- export const meta: StoryMeta = { title: "AgentLauncher" };
7
+ export const meta: StoryMeta = { title: "Chrome/AgentLauncher" };
8
8
 
9
9
  /** The launcher expects the rail's narrow, height-constrained column. */
10
10
  function RailFrame({ children }: { children: React.ReactNode }): React.ReactNode {
@@ -1,99 +1,169 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { Annotation } from "@cueloop/schema";
3
- import type { DiffRow } from "../view-diff";
3
+ import {
4
+ commentCountsByFile,
5
+ diffRowText,
6
+ fileChangeCounts,
7
+ marksByRows,
8
+ type DiffRow,
9
+ } from "../view-diff";
10
+ import { fixtureDiffSession } from "./story-fixtures";
4
11
  import type { IntralineRun } from "../diff-intraline";
5
12
  import type { SyntaxSpan } from "../diff-syntax";
6
13
  import { DARK } from "../theme";
7
- import {
8
- annotatedRowsByIndex,
9
- coloredRowSpans,
10
- rowContentOffsets,
11
- rowLine,
12
- segmentRows,
13
- } from "./diff-sheet-layout";
14
+ import { coloredRowSpans } from "./diff-sheet-layout";
14
15
 
15
16
  function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
16
17
  return { kind, text, file: "a.ts", ...extra };
17
18
  }
18
19
 
19
- function annotation(quote: string): Annotation {
20
- return {
21
- id: "a1",
22
- kind: "comment",
23
- body: "note",
24
- anchor: { quote, prefix: "", suffix: "" },
25
- createdAt: "2026-01-01T00:00:00Z",
26
- };
20
+ function annotation(id: string, anchor: Annotation["anchor"]): Annotation {
21
+ return { id, kind: "comment", body: "note", anchor, createdAt: "2026-01-01T00:00:00Z" };
27
22
  }
28
23
 
29
- describe("rowLine", () => {
24
+ describe("diffRowText", () => {
30
25
  test("strips the trailing newline the patch carries", () => {
31
26
  // Arrange / Act / Assert
32
- expect(rowLine(row("add", "const x = 1;\n"))).toBe("const x = 1;");
27
+ expect(diffRowText(row("add", "const x = 1;\n"))).toBe("const x = 1;");
33
28
  });
34
29
  });
35
30
 
36
- describe("annotatedRowsByIndex", () => {
37
- test("maps an annotation to the code row whose text matches its quote", () => {
31
+ describe("fileChangeCounts", () => {
32
+ test("tallies added and removed lines per file, ignoring context and headers", () => {
38
33
  // Arrange
39
- const rows = [row("file", "a.ts"), row("ctx", "keep"), row("add", "new line")];
34
+ const rows = [
35
+ row("file", "a.ts"),
36
+ row("hunk", "@@"),
37
+ row("ctx", "unchanged"),
38
+ row("del", "gone"),
39
+ row("add", "one"),
40
+ row("add", "two"),
41
+ row("file", "b.ts", { file: "b.ts" }),
42
+ row("add", "only add", { file: "b.ts" }),
43
+ ];
40
44
 
41
45
  // Act
42
- const byRow = annotatedRowsByIndex(rows, [annotation("new line")]);
46
+ const counts = fileChangeCounts(rows);
43
47
 
44
48
  // Assert
45
- expect([...byRow.keys()]).toEqual([2]);
49
+ expect(counts.get("a.ts")).toEqual({ additions: 2, deletions: 1 });
50
+ expect(counts.get("b.ts")).toEqual({ additions: 1, deletions: 0 });
46
51
  });
47
52
  });
48
53
 
49
- describe("segmentRows", () => {
50
- test("splits header rows out and closes a chunk after an annotated row", () => {
51
- // Arrange
52
- const rows = [
53
- row("file", "a.ts"),
54
- row("hunk", "@@"),
55
- row("ctx", "keep"),
56
- row("add", "changed"),
57
- ];
58
- const annotatedByRow = new Map<number, Annotation>([[3, annotation("changed")]]);
54
+ describe("marksByRows", () => {
55
+ const rows = [
56
+ row("file", "a.ts"),
57
+ row("hunk", "@@"),
58
+ row("ctx", "keep\n"),
59
+ row("add", "const items = new Map();\n"),
60
+ row("add", "return items;\n"),
61
+ ];
59
62
 
63
+ test("a whole-row quote (the legacy row anchor) marks that row end to end", () => {
60
64
  // Act
61
- const segments = segmentRows(rows, annotatedByRow);
65
+ const marks = marksByRows(
66
+ [annotation("a1", { quote: "return items;", prefix: "", suffix: "" })],
67
+ rows,
68
+ );
62
69
 
63
70
  // Assert
64
- expect(segments.map((segment) => segment.kind)).toEqual(["header", "header", "chunk"]);
65
- const chunk = segments[2];
71
+ expect([...marks.keys()]).toEqual([4]);
72
+ expect(marks.get(4)![0]).toMatchObject({ start: 0, end: 13, annotationId: "a1" });
73
+ });
74
+
75
+ test("a word quote marks only its characters and carries the span", () => {
76
+ // Act
77
+ const marks = marksByRows(
78
+ [annotation("a1", { quote: "new Map()", prefix: "const items = ", suffix: ";" })],
79
+ rows,
80
+ );
66
81
 
67
- expect(chunk?.kind).toBe("chunk");
68
- if (chunk?.kind === "chunk") expect(chunk.annotation?.id).toBe("a1");
82
+ // Assert
83
+ const mark = marks.get(3)![0]!;
84
+
85
+ expect(mark.start).toBe(14);
86
+ expect(mark.end).toBe(23);
87
+ expect(mark.span).toEqual({
88
+ start: { blockIndex: 3, char: 14 },
89
+ end: { blockIndex: 3, char: 23 },
90
+ });
69
91
  });
70
92
 
71
- test("closes a chunk at the compose row so a draft card can follow it", () => {
72
- // Arrange - two code rows, composing on the first
73
- const rows = [row("add", "one"), row("add", "two")];
93
+ test("a quote across two rows paints a mark on each row with one shared span", () => {
94
+ // Arrange - the anchor a two-row drag produces: rows joined by the block separator
95
+ const marks = marksByRows(
96
+ [
97
+ annotation("a1", {
98
+ quote: "Map();\n\nreturn",
99
+ prefix: "const items = new ",
100
+ suffix: " items;",
101
+ blockIndex: 3,
102
+ start: 18,
103
+ end: 6,
104
+ endBlockIndex: 4,
105
+ }),
106
+ ],
107
+ rows,
108
+ );
109
+
110
+ // Assert
111
+ expect([...marks.keys()].toSorted()).toEqual([3, 4]);
112
+ expect(marks.get(3)![0]!.span).toEqual(marks.get(4)![0]!.span);
113
+ expect(marks.get(3)![0]).toMatchObject({ start: 18, end: 24 });
114
+ expect(marks.get(4)![0]).toMatchObject({ start: 0, end: 6 });
115
+ });
74
116
 
117
+ test("the focused annotation wears the focus role", () => {
75
118
  // Act
76
- const segments = segmentRows(rows, new Map(), 0);
119
+ const marks = marksByRows(
120
+ [annotation("a1", { quote: "keep", prefix: "", suffix: "" })],
121
+ rows,
122
+ "a1",
123
+ );
77
124
 
78
- // Assert - the first row is its own chunk, the second a separate chunk
79
- expect(segments).toHaveLength(2);
80
- expect(segments.every((segment) => segment.kind === "chunk")).toBe(true);
125
+ // Assert
126
+ expect(marks.get(2)![0]!.role).toBe("mark-focus");
81
127
  });
82
128
  });
83
129
 
84
- describe("rowContentOffsets", () => {
85
- test("counts one row per header and an extra row for a chunk's annotation card", () => {
86
- // Arrange
87
- const rows = [row("file", "a.ts"), row("add", "changed")];
88
- const annotatedByRow = new Map<number, Annotation>([[1, annotation("changed")]]);
89
- const segments = segmentRows(rows, annotatedByRow);
130
+ describe("commentCountsByFile", () => {
131
+ const rows = [
132
+ row("file", "a.ts"),
133
+ row("add", "const items = new Map();\n"),
134
+ row("file", "b.ts", { file: "b.ts" }),
135
+ row("add", "return other;\n", { file: "b.ts" }),
136
+ ];
137
+ const annotation = (id: string, quote: string, replyTo?: string): Annotation => {
138
+ const comment: Annotation = {
139
+ id,
140
+ kind: "comment",
141
+ body: id,
142
+ anchor: { quote, prefix: "", suffix: "" },
143
+ createdAt: "2026-01-01T00:00:00Z",
144
+ };
145
+
146
+ if (replyTo !== undefined) comment.replyTo = replyTo;
147
+
148
+ return comment;
149
+ };
150
+
151
+ test("counts a discussion's comments (root plus replies) toward its file", () => {
152
+ // Arrange - one thread on a.ts with a reply, one on b.ts
153
+ const session = fixtureDiffSession({
154
+ annotations: [
155
+ annotation("a1", "const items = new Map();"),
156
+ annotation("a1r", "const items = new Map();", "a1"),
157
+ annotation("b1", "return other;"),
158
+ ],
159
+ });
90
160
 
91
161
  // Act
92
- const offsets = rowContentOffsets(segments);
162
+ const counts = commentCountsByFile(session, rows);
93
163
 
94
- // Assert - header at y0, its chunk row at y1; the card would sit at y2
95
- expect(offsets[0]).toBe(0);
96
- expect(offsets[1]).toBe(1);
164
+ // Assert
165
+ expect(counts.get("a.ts")).toBe(2);
166
+ expect(counts.get("b.ts")).toBe(1);
97
167
  });
98
168
  });
99
169
 
@@ -1,120 +1,20 @@
1
1
  /**
2
- * Pure layout logic for the diff review sheet, split out so it can be tested
3
- * without rendering: grouping rows into chunks around headers and annotations,
4
- * resolving each row's colored spans (syntax under the intra-line change color),
5
- * mapping annotations to rows, and the content-y offset of each row for scroll.
6
- * DiffSheet.tsx is the thin renderer over these.
2
+ * Pure color logic for the diff review sheet, split out so it can be tested
3
+ * without rendering: a row's colored spans (syntax under the intra-line change
4
+ * color). DiffSheet.tsx paints marks and the caret over these per character.
7
5
  */
8
6
 
9
- import type { Annotation } from "@cueloop/schema";
10
- import type { DiffRow } from "../view-diff";
11
7
  import type { Theme } from "../theme";
12
8
  import type { IntralineRun } from "../diff-intraline";
13
9
  import type { SyntaxSpan } from "../diff-syntax";
14
10
  import { colorForSyntaxGroup } from "./syntax-highlight";
15
11
 
16
- /** A header row rendered on its own line, or a contiguous run of code rows. */
17
- export type DiffSegment =
18
- | { kind: "header"; rowIndex: number; row: DiffRow }
19
- | { kind: "chunk"; firstRowIndex: number; rows: DiffRow[]; annotation: Annotation | null };
20
-
21
12
  /** One rendered span of a row: its text and its resolved foreground color. */
22
13
  export interface ColoredSpan {
23
14
  text: string;
24
15
  foreground: string;
25
16
  }
26
17
 
27
- /** Row text carries the patch's trailing newline; rendering strips it. */
28
- export function rowLine(row: DiffRow): string {
29
- return row.text.replace(/\n$/, "");
30
- }
31
-
32
- /** The annotation anchored to each row, matched by its verbatim quote. */
33
- export function annotatedRowsByIndex(
34
- rows: DiffRow[],
35
- annotations: Annotation[],
36
- ): Map<number, Annotation> {
37
- const byRow = new Map<number, Annotation>();
38
-
39
- for (const annotation of annotations) {
40
- const rowIndex = rows.findIndex(
41
- (row) =>
42
- row.text === annotation.anchor.quote &&
43
- (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
44
- );
45
-
46
- if (rowIndex !== -1) byRow.set(rowIndex, annotation);
47
- }
48
-
49
- return byRow;
50
- }
51
-
52
- /** Group rows into header segments and code chunks; a chunk closes after an
53
- * annotated or composed row so a card can render directly below that line. */
54
- export function segmentRows(
55
- rows: DiffRow[],
56
- annotatedByRow: Map<number, Annotation>,
57
- composeRowIndex?: number,
58
- ): DiffSegment[] {
59
- const segments: DiffSegment[] = [];
60
- let chunk: DiffRow[] = [];
61
- let chunkStart = 0;
62
-
63
- const closeChunk = (annotation: Annotation | null): void => {
64
- if (chunk.length) {
65
- segments.push({ kind: "chunk", firstRowIndex: chunkStart, rows: chunk, annotation });
66
- }
67
- chunk = [];
68
- };
69
-
70
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
71
- const row = rows[rowIndex]!;
72
-
73
- if (row.kind === "file" || row.kind === "hunk") {
74
- closeChunk(null);
75
- segments.push({ kind: "header", rowIndex, row });
76
- chunkStart = rowIndex + 1;
77
- continue;
78
- }
79
- if (!chunk.length) chunkStart = rowIndex;
80
- chunk.push(row);
81
-
82
- const annotation = annotatedByRow.get(rowIndex);
83
-
84
- if (annotation) {
85
- closeChunk(annotation);
86
- chunkStart = rowIndex + 1;
87
- } else if (rowIndex === composeRowIndex) {
88
- closeChunk(null);
89
- chunkStart = rowIndex + 1;
90
- }
91
- }
92
- closeChunk(null);
93
-
94
- return segments;
95
- }
96
-
97
- /** The content-y offset of each row index, so the cursor can be scrolled into
98
- * view; a chunk's annotation adds one row for its card. */
99
- export function rowContentOffsets(segments: DiffSegment[]): number[] {
100
- const offsets: number[] = [];
101
- let contentY = 0;
102
-
103
- for (const segment of segments) {
104
- if (segment.kind === "header") {
105
- offsets[segment.rowIndex] = contentY;
106
- contentY += 1;
107
- } else {
108
- segment.rows.forEach((_, lineIndex) => {
109
- offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
110
- });
111
- contentY += segment.rows.length + (segment.annotation ? 1 : 0);
112
- }
113
- }
114
-
115
- return offsets;
116
- }
117
-
118
18
  /**
119
19
  * Colored spans for one row, resolved per character: an intra-line changed word
120
20
  * keeps the diff color, otherwise the syntax color applies, falling back to the
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ addTab,
4
+ changesTab,
5
+ closeTab,
6
+ containsGroup,
7
+ fileTab,
8
+ firstGroupId,
9
+ makeGroup,
10
+ splitGroup,
11
+ type EditorBranch,
12
+ type EditorGroup,
13
+ type EditorNode,
14
+ } from "./editor-grid";
15
+
16
+ function asBranch(node: EditorNode | null): EditorBranch {
17
+ if (node === null || node.type !== "branch") throw new Error("expected a branch node");
18
+ return node;
19
+ }
20
+ function asGroup(node: EditorNode | null): EditorGroup {
21
+ if (node === null || node.type !== "group") throw new Error("expected a group node");
22
+ return node;
23
+ }
24
+
25
+ describe("editor grid", () => {
26
+ test("a fresh group holds its tabs with the first active", () => {
27
+ const tab = changesTab();
28
+ const group = makeGroup([tab]);
29
+ expect(group.type).toBe("group");
30
+ expect(group.activeTabId).toBe(tab.id);
31
+ });
32
+
33
+ test("adding a tab focuses it", () => {
34
+ const group = makeGroup([changesTab()]);
35
+ const file = fileTab("App.tsx", "src/App.tsx", "diff");
36
+ const next = asGroup(addTab(group, group.id, file));
37
+ expect(next.tabs).toHaveLength(2);
38
+ expect(next.activeTabId).toBe(file.id);
39
+ });
40
+
41
+ test("splitting right makes a horizontal branch and focuses the new group", () => {
42
+ const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
43
+ const { tree, focusGroupId } = splitGroup(group, group.id, "right");
44
+ expect(tree.type).toBe("branch");
45
+ const branch = asBranch(tree);
46
+ expect(branch.orientation).toBe("horizontal");
47
+ expect(branch.children).toHaveLength(2);
48
+ // the new group is the second child (right edge) and takes focus
49
+ expect(branch.children[1]!.id).toBe(focusGroupId);
50
+ expect(firstGroupId(tree)).toBe(group.id);
51
+ });
52
+
53
+ test("splitting up makes a vertical branch with the new group first", () => {
54
+ const group = makeGroup([fileTab("a.ts", "a.ts", "contents")]);
55
+ const { tree, focusGroupId } = splitGroup(group, group.id, "up");
56
+ const branch = asBranch(tree);
57
+ expect(branch.orientation).toBe("vertical");
58
+ expect(branch.children[0]!.id).toBe(focusGroupId);
59
+ });
60
+
61
+ test("closing the last tab of a split collapses the branch back to one group", () => {
62
+ const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
63
+ const { tree } = splitGroup(group, group.id, "right");
64
+ const branch = asBranch(tree);
65
+ const rightGroup = asGroup(branch.children[1]!);
66
+ const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
67
+ expect(collapsed?.type).toBe("group");
68
+ expect(asGroup(collapsed).id).toBe(group.id);
69
+ });
70
+
71
+ test("closing the only tab of the whole grid returns null", () => {
72
+ const tab = changesTab();
73
+ const group = makeGroup([tab]);
74
+ expect(closeTab(group, group.id, tab.id)).toBeNull();
75
+ });
76
+
77
+ test("containsGroup finds a live group and misses a pruned one", () => {
78
+ const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
79
+ const { tree } = splitGroup(group, group.id, "right");
80
+ const branch = asBranch(tree);
81
+ const rightGroup = asGroup(branch.children[1]!);
82
+ expect(containsGroup(tree, group.id)).toBe(true);
83
+ const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
84
+ expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,138 @@
1
+ // The Changes pane's editor layout: a tree of nodes where a leaf is an editor group (a tab strip plus
2
+ // its one active tab) and a branch splits child nodes along an orientation. Splitting an editor group
3
+ // moves its active tab into a new group; a group is pruned when its last tab closes, collapsing the
4
+ // branch that held it.
5
+
6
+ /** One editor in a group's tab strip: the Changes tab (the whole diff), a per-file tab, or Welcome. */
7
+ export interface EditorTab {
8
+ id: string;
9
+ kind: "changes" | "file" | "welcome";
10
+ label: string;
11
+ /** Repo-relative path for a file tab; absent for the Changes and Welcome tabs. */
12
+ path?: string;
13
+ /** A file tab shows either a single-file diff or the file's plain contents. */
14
+ fileView?: "diff" | "contents";
15
+ }
16
+
17
+ /** A leaf of the grid: one editor group holding tabs, with the active tab id. */
18
+ export interface EditorGroup {
19
+ type: "group";
20
+ id: string;
21
+ tabs: EditorTab[];
22
+ activeTabId: string | null;
23
+ }
24
+
25
+ /** A branch of the grid: children laid out along one orientation (horizontal = side by side). */
26
+ export interface EditorBranch {
27
+ type: "branch";
28
+ id: string;
29
+ orientation: "horizontal" | "vertical";
30
+ children: EditorNode[];
31
+ }
32
+
33
+ export type EditorNode = EditorGroup | EditorBranch;
34
+
35
+ /** Which edge a split places the new group on; Left/Right split horizontally, Up/Down vertically. */
36
+ export type SplitDirection = "left" | "right" | "up" | "down";
37
+
38
+ /** A split's outcome: the new grid and the id of the group that should take focus. */
39
+ export interface SplitResult {
40
+ tree: EditorNode;
41
+ focusGroupId: string;
42
+ }
43
+
44
+ const freshId = (): string => crypto.randomUUID();
45
+
46
+ /** A new editor group holding the given tabs, with the first tab active. */
47
+ export function makeGroup(tabs: EditorTab[]): EditorGroup {
48
+ return { type: "group", id: freshId(), tabs, activeTabId: tabs[0]?.id ?? null };
49
+ }
50
+
51
+ /** The Changes tab: the whole diff in one scroll, the disposable default of the Changes pane. */
52
+ export function changesTab(): EditorTab {
53
+ return { id: freshId(), kind: "changes", label: "Changes" };
54
+ }
55
+
56
+ /** The Welcome tab: the getting-started surface, a disposable tab shown before any thread is opened. */
57
+ export function welcomeTab(): EditorTab {
58
+ return { id: freshId(), kind: "welcome", label: "Welcome" };
59
+ }
60
+
61
+ /** A file tab, opened from a tree; a diff view for a changed file, plain contents otherwise. */
62
+ export function fileTab(label: string, path: string, fileView: "diff" | "contents"): EditorTab {
63
+ return { id: freshId(), kind: "file", label, path, fileView };
64
+ }
65
+
66
+ /** The id of the first editor group in reading order; the fallback focus target. */
67
+ export function firstGroupId(node: EditorNode): string {
68
+ return node.type === "group" ? node.id : firstGroupId(node.children[0]!);
69
+ }
70
+
71
+ /** Whether a group id still exists in the grid; used to re-home focus after a prune. */
72
+ export function containsGroup(node: EditorNode, groupId: string): boolean {
73
+ if (node.type === "group") return node.id === groupId;
74
+ return node.children.some((child) => containsGroup(child, groupId));
75
+ }
76
+
77
+ function replaceGroup(
78
+ node: EditorNode,
79
+ id: string,
80
+ fn: (group: EditorGroup) => EditorNode,
81
+ ): EditorNode {
82
+ if (node.type === "group") return node.id === id ? fn(node) : node;
83
+ return { ...node, children: node.children.map((child) => replaceGroup(child, id, fn)) };
84
+ }
85
+
86
+ /** Make a tab the active one in its group. */
87
+ export function activateTab(node: EditorNode, groupId: string, tabId: string): EditorNode {
88
+ return replaceGroup(node, groupId, (group) => ({ ...group, activeTabId: tabId }));
89
+ }
90
+
91
+ /** Append a tab to a group and focus it. */
92
+ export function addTab(node: EditorNode, groupId: string, tab: EditorTab): EditorNode {
93
+ return replaceGroup(node, groupId, (group) => ({
94
+ ...group,
95
+ tabs: [...group.tabs, tab],
96
+ activeTabId: tab.id,
97
+ }));
98
+ }
99
+
100
+ /** Drop empty groups and collapse a branch that ends up with one child; null when nothing remains. */
101
+ export function pruneEmpty(node: EditorNode): EditorNode | null {
102
+ if (node.type === "group") return node.tabs.length > 0 ? node : null;
103
+ const kept = node.children.map(pruneEmpty).filter((child): child is EditorNode => child !== null);
104
+ if (kept.length === 0) return null;
105
+ if (kept.length === 1) return kept[0]!;
106
+ return { ...node, children: kept };
107
+ }
108
+
109
+ /** Close a tab; returns the pruned tree, or null when the last tab of the whole grid is closed. */
110
+ export function closeTab(node: EditorNode, groupId: string, tabId: string): EditorNode | null {
111
+ const next = replaceGroup(node, groupId, (group) => {
112
+ const tabs = group.tabs.filter((tab) => tab.id !== tabId);
113
+ const activeTabId = group.activeTabId === tabId ? (tabs[0]?.id ?? null) : group.activeTabId;
114
+ return { ...group, tabs, activeTabId };
115
+ });
116
+ return pruneEmpty(next);
117
+ }
118
+
119
+ /** Split a group's active tab into a new group on the given edge; returns the tree and the new focus. */
120
+ export function splitGroup(
121
+ node: EditorNode,
122
+ groupId: string,
123
+ direction: SplitDirection,
124
+ ): SplitResult {
125
+ let focusGroupId = groupId;
126
+ const tree = replaceGroup(node, groupId, (group) => {
127
+ const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
128
+ if (active === undefined) return group;
129
+ const movedTab: EditorTab = { ...active, id: freshId() };
130
+ const newGroup = makeGroup([movedTab]);
131
+ focusGroupId = newGroup.id;
132
+ const orientation = direction === "left" || direction === "right" ? "horizontal" : "vertical";
133
+ const newFirst = direction === "left" || direction === "up";
134
+ const children = newFirst ? [newGroup, group] : [group, newGroup];
135
+ return { type: "branch", id: freshId(), orientation, children };
136
+ });
137
+ return { tree, focusGroupId };
138
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fadeTitle, mixHex } from "./fade-title";
3
+
4
+ describe("mixHex", () => {
5
+ test("returns the endpoints at t=0 and t=1", () => {
6
+ expect(mixHex("#000000", "#ffffff", 0)).toBe("#000000");
7
+ expect(mixHex("#000000", "#ffffff", 1)).toBe("#ffffff");
8
+ });
9
+
10
+ test("blends halfway", () => {
11
+ expect(mixHex("#000000", "#ffffff", 0.5)).toBe("#808080");
12
+ });
13
+
14
+ test("clamps out-of-range t", () => {
15
+ expect(mixHex("#101010", "#202020", 2)).toBe("#202020");
16
+ expect(mixHex("#101010", "#202020", -1)).toBe("#101010");
17
+ });
18
+ });
19
+
20
+ describe("fadeTitle", () => {
21
+ test("a title that fits is one plain segment", () => {
22
+ const segments = fadeTitle("short", 20, "#aabbcc", "#000000");
23
+
24
+ expect(segments).toEqual([{ text: "short", fg: "#aabbcc" }]);
25
+ });
26
+
27
+ test("a long title is clipped to the width and its tail fades toward the background", () => {
28
+ const segments = fadeTitle("Review the accent change in the theme", 20, "#ffffff", "#000000");
29
+
30
+ expect(segments.map((segment) => segment.text).join("")).toHaveLength(20);
31
+ // the head keeps the base color, the last characters step toward the fade color
32
+ expect(segments[0]!.fg).toBe("#ffffff");
33
+ expect(segments.at(-1)!.fg).not.toBe("#ffffff");
34
+ // the very last character is the most faded (closest to the background)
35
+ const lastTwo = segments.slice(-2);
36
+
37
+ expect(mixHex("#ffffff", "#000000", 0)).not.toBe(lastTwo[1]!.fg);
38
+ });
39
+
40
+ test("newlines collapse to spaces so the row never wraps", () => {
41
+ const segments = fadeTitle("line one\nline two", 40, "#ffffff", "#000000");
42
+
43
+ expect(segments[0]!.text).toBe("line one line two");
44
+ });
45
+
46
+ test("a zero width yields nothing", () => {
47
+ expect(fadeTitle("anything", 0, "#ffffff", "#000000")).toEqual([]);
48
+ });
49
+ });