@cueloop/client 0.1.0-alpha.91 → 0.1.0-alpha.93

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 (40) hide show
  1. package/package.json +5 -5
  2. package/src/App.deleted-scroll.test.tsx +186 -0
  3. package/src/App.diff.test.tsx +3 -4
  4. package/src/App.test.tsx +1 -0
  5. package/src/App.toast.test.tsx +27 -1
  6. package/src/App.tsx +21 -12
  7. package/src/App.workbench.test.tsx +208 -2
  8. package/src/app-screens.tsx +34 -8
  9. package/src/components/AnnotatableFileView.test.tsx +48 -1
  10. package/src/components/AppShell.zoom.test.tsx +16 -3
  11. package/src/components/ChangesColumn.tsx +10 -4
  12. package/src/components/ChangesFileTree.test.tsx +122 -4
  13. package/src/components/DiffContentView.scroll.test.tsx +285 -1
  14. package/src/components/DiffContentView.tsx +214 -56
  15. package/src/components/EditorGrid.split.test.tsx +118 -0
  16. package/src/components/EditorGrid.stories.tsx +27 -0
  17. package/src/components/EditorGrid.tsx +170 -50
  18. package/src/components/EditorGrid.zoom.test.tsx +65 -1
  19. package/src/components/GridTabContent.actions.test.tsx +151 -0
  20. package/src/components/GridTabContent.stories.tsx +2 -2
  21. package/src/components/GridTabContent.tsx +5 -1
  22. package/src/components/ProjectTreeView.test.tsx +166 -8
  23. package/src/components/ProjectTreeView.tsx +53 -27
  24. package/src/components/ScrollArea.tsx +21 -2
  25. package/src/components/Toast.tsx +3 -1
  26. package/src/components/WelcomePlayground.test.tsx +28 -1
  27. package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
  28. package/src/components/editor-grid.test.ts +87 -0
  29. package/src/components/editor-grid.ts +142 -6
  30. package/src/components/primitives/Tree.tsx +51 -15
  31. package/src/diff-syntax.test.ts +15 -2
  32. package/src/diff-syntax.ts +13 -5
  33. package/src/thread-controller.curate.test.ts +27 -0
  34. package/src/thread-controller.ts +4 -6
  35. package/src/use-annotation-surface.tsx +86 -19
  36. package/src/use-changes-workbench.ts +30 -18
  37. package/src/use-frame-measure.ts +40 -7
  38. package/src/use-settings-dialog.tsx +1 -13
  39. package/src/use-shared-keyboard.ts +55 -0
  40. package/src/use-terminal-virtualizer.ts +8 -2
@@ -3,10 +3,14 @@ import {
3
3
  addTab,
4
4
  changesTab,
5
5
  closeTab,
6
+ countEditorGroups,
6
7
  containsGroup,
7
8
  fileTab,
8
9
  firstGroupId,
10
+ keepFileTab,
9
11
  makeGroup,
12
+ MAX_EDITOR_GROUPS,
13
+ openFileTab,
10
14
  splitGroup,
11
15
  type EditorBranch,
12
16
  type EditorGroup,
@@ -38,6 +42,42 @@ describe("editor grid", () => {
38
42
  expect(next.activeTabId).toBe(file.id);
39
43
  });
40
44
 
45
+ test("a new preview replaces the group's previous preview", () => {
46
+ const group = makeGroup([changesTab()]);
47
+ const first = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", false).tree);
48
+ const second = asGroup(openFileTab(first, group.id, "src/b.ts", "contents", false).tree);
49
+
50
+ expect(second.tabs.map((tab) => tab.path)).toEqual([undefined, "src/b.ts"]);
51
+ expect(second.tabs[1]?.preview).toBe(true);
52
+ });
53
+
54
+ test("keeping a preview makes subsequent previews open beside it", () => {
55
+ const group = makeGroup([changesTab()]);
56
+ const preview = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", false).tree);
57
+ const kept = asGroup(keepFileTab(preview, group.id, preview.activeTabId!));
58
+ const next = asGroup(openFileTab(kept, group.id, "src/b.ts", "contents", false).tree);
59
+
60
+ expect(next.tabs.map((tab) => tab.path)).toEqual([undefined, "src/a.ts", "src/b.ts"]);
61
+ expect(next.tabs[1]?.preview).toBe(false);
62
+ });
63
+
64
+ test("reopening a file focuses its existing tab across groups and changes its view", () => {
65
+ const group = makeGroup([changesTab()]);
66
+ const open = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", true).tree);
67
+ const { tree } = splitGroup(open, group.id, "right");
68
+ const branch = asBranch(tree);
69
+ const right = asGroup(branch.children[1]!);
70
+ const reopened = openFileTab(tree, group.id, "src/a.ts", "diff", false);
71
+
72
+ expect(reopened.focusGroupId).toBe(right.id);
73
+ expect(asGroup(asBranch(reopened.tree).children[1]!).tabs).toHaveLength(1);
74
+ expect(asGroup(asBranch(reopened.tree).children[1]!).tabs[0]).toMatchObject({
75
+ path: "src/a.ts",
76
+ fileView: "diff",
77
+ preview: false,
78
+ });
79
+ });
80
+
41
81
  test("splitting right makes a horizontal branch and focuses the new group", () => {
42
82
  const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
43
83
  const { tree, focusGroupId } = splitGroup(group, group.id, "right");
@@ -48,6 +88,7 @@ describe("editor grid", () => {
48
88
  // the new group is the second child (right edge) and takes focus
49
89
  expect(branch.children[1]!.id).toBe(focusGroupId);
50
90
  expect(firstGroupId(tree)).toBe(group.id);
91
+ expect(asGroup(branch.children[0]!).tabs.some((tab) => tab.path === "src/App.tsx")).toBe(false);
51
92
  });
52
93
 
53
94
  test("splitting up makes a vertical branch with the new group first", () => {
@@ -58,6 +99,32 @@ describe("editor grid", () => {
58
99
  expect(branch.children[0]!.id).toBe(focusGroupId);
59
100
  });
60
101
 
102
+ test("splitting a file-only group keeps the Changes tab unique", () => {
103
+ const changes = makeGroup([changesTab()]);
104
+ const file = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
105
+ const tree: EditorNode = {
106
+ type: "branch",
107
+ id: "two-groups",
108
+ orientation: "horizontal",
109
+ children: [changes, file],
110
+ };
111
+ const split = splitGroup(tree, file.id, "down");
112
+
113
+ expect(asBranch(split.tree).children[0]).toBe(changes);
114
+ expect(asBranch(asBranch(split.tree).children[1]!).children[0]).toMatchObject({
115
+ tabs: [{ kind: "welcome" }],
116
+ });
117
+ });
118
+
119
+ test("splitting Changes moves it to the new group", () => {
120
+ const group = makeGroup([changesTab()]);
121
+ const split = splitGroup(group, group.id, "right");
122
+ const branch = asBranch(split.tree);
123
+
124
+ expect(asGroup(branch.children[0]!).tabs[0]?.kind).toBe("welcome");
125
+ expect(asGroup(branch.children[1]!).tabs[0]?.kind).toBe("changes");
126
+ });
127
+
61
128
  test("closing the last tab of a split collapses the branch back to one group", () => {
62
129
  const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
63
130
  const { tree } = splitGroup(group, group.id, "right");
@@ -83,4 +150,24 @@ describe("editor grid", () => {
83
150
  const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
84
151
  expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
85
152
  });
153
+
154
+ test("mixed split directions stop at eight editor groups", () => {
155
+ const first = makeGroup([changesTab()]);
156
+ let tree: EditorNode = first;
157
+ let focusGroupId = first.id;
158
+ const directions = ["right", "up", "left", "down"] as const;
159
+
160
+ for (const direction of [...directions, ...directions]) {
161
+ const result = splitGroup(tree, focusGroupId, direction);
162
+
163
+ tree = result.tree;
164
+ focusGroupId = result.focusGroupId;
165
+ }
166
+
167
+ expect(countEditorGroups(tree)).toBe(MAX_EDITOR_GROUPS);
168
+ const excess = splitGroup(tree, focusGroupId, "right");
169
+
170
+ expect(excess.tree).toBe(tree);
171
+ expect(excess.focusGroupId).toBe(focusGroupId);
172
+ });
86
173
  });
@@ -1,6 +1,6 @@
1
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
2
+ // its one active tab) and a branch splits child nodes along an orientation. Splitting a file tab
3
+ // moves it into a new group; a group is pruned when its last tab closes, collapsing the
4
4
  // branch that held it.
5
5
 
6
6
  /** One editor in a group's tab strip: the Changes tab (the whole diff), a per-file tab, or Welcome. */
@@ -12,6 +12,8 @@ export interface EditorTab {
12
12
  path?: string;
13
13
  /** A file tab shows either a single-file diff or the file's plain contents. */
14
14
  fileView?: "diff" | "contents";
15
+ /** A preview is replaced by the next file opened in its group. */
16
+ preview?: boolean;
15
17
  }
16
18
 
17
19
  /** A leaf of the grid: one editor group holding tabs, with the active tab id. */
@@ -35,6 +37,9 @@ export type EditorNode = EditorGroup | EditorBranch;
35
37
  /** Which edge a split places the new group on; Left/Right split horizontally, Up/Down vertically. */
36
38
  export type SplitDirection = "left" | "right" | "up" | "down";
37
39
 
40
+ /** The editor grid stops at eight groups so every tile remains usable. */
41
+ export const MAX_EDITOR_GROUPS = 8;
42
+
38
43
  /** A split's outcome: the new grid and the id of the group that should take focus. */
39
44
  export interface SplitResult {
40
45
  tree: EditorNode;
@@ -59,8 +64,13 @@ export function welcomeTab(): EditorTab {
59
64
  }
60
65
 
61
66
  /** 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 };
67
+ export function fileTab(
68
+ label: string,
69
+ path: string,
70
+ fileView: "diff" | "contents",
71
+ preview = false,
72
+ ): EditorTab {
73
+ return { id: freshId(), kind: "file", label, path, fileView, preview };
64
74
  }
65
75
 
66
76
  /** The id of the first editor group in reading order; the fallback focus target. */
@@ -74,6 +84,13 @@ export function containsGroup(node: EditorNode, groupId: string): boolean {
74
84
  return node.children.some((child) => containsGroup(child, groupId));
75
85
  }
76
86
 
87
+ /** Count editor tiles, including groups inside nested horizontal and vertical splits. */
88
+ export function countEditorGroups(node: EditorNode): number {
89
+ if (node.type === "group") return 1;
90
+
91
+ return node.children.reduce((count, child) => count + countEditorGroups(child), 0);
92
+ }
93
+
77
94
  function replaceGroup(
78
95
  node: EditorNode,
79
96
  id: string,
@@ -97,6 +114,111 @@ export function addTab(node: EditorNode, groupId: string, tab: EditorTab): Edito
97
114
  }));
98
115
  }
99
116
 
117
+ /** Activate the one Changes tab, or add it beside the active group's existing tabs. */
118
+ export function openChangesTab(node: EditorNode, groupId: string): SplitResult {
119
+ const existing = findTabByKind(node, "changes");
120
+
121
+ if (existing) {
122
+ return {
123
+ tree: activateTab(node, existing.groupId, existing.tab.id),
124
+ focusGroupId: existing.groupId,
125
+ };
126
+ }
127
+
128
+ return { tree: addTab(node, groupId, changesTab()), focusGroupId: groupId };
129
+ }
130
+
131
+ function findTabByKind(
132
+ node: EditorNode,
133
+ kind: EditorTab["kind"],
134
+ ): { groupId: string; tab: EditorTab } | null {
135
+ if (node.type === "group") {
136
+ const tab = node.tabs.find((candidate) => candidate.kind === kind);
137
+
138
+ return tab ? { groupId: node.id, tab } : null;
139
+ }
140
+
141
+ for (const child of node.children) {
142
+ const found = findTabByKind(child, kind);
143
+
144
+ if (found) return found;
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ interface FileTabLocation {
151
+ groupId: string;
152
+ tab: EditorTab;
153
+ }
154
+
155
+ /** A file path is one editor tab across the grid, even when the groups are split. */
156
+ function findFileTab(node: EditorNode, path: string): FileTabLocation | null {
157
+ if (node.type === "group") {
158
+ const tab = node.tabs.find((candidate) => candidate.kind === "file" && candidate.path === path);
159
+
160
+ return tab ? { groupId: node.id, tab } : null;
161
+ }
162
+
163
+ for (const child of node.children) {
164
+ const found = findFileTab(child, path);
165
+
166
+ if (found) return found;
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ /** Open a file once, replacing only a preview; reopening focuses its existing tab. */
173
+ export function openFileTab(
174
+ node: EditorNode,
175
+ groupId: string,
176
+ path: string,
177
+ fileView: "diff" | "contents",
178
+ persistent: boolean,
179
+ ): SplitResult {
180
+ const existing = findFileTab(node, path);
181
+
182
+ if (existing) {
183
+ return {
184
+ tree: replaceGroup(node, existing.groupId, (group) => ({
185
+ ...group,
186
+ tabs: group.tabs.map((tab) =>
187
+ tab.id === existing.tab.id
188
+ ? { ...tab, fileView, preview: persistent ? false : tab.preview }
189
+ : tab,
190
+ ),
191
+ activeTabId: existing.tab.id,
192
+ })),
193
+ focusGroupId: existing.groupId,
194
+ };
195
+ }
196
+
197
+ const label = path.split("/").pop() ?? path;
198
+ const tab = fileTab(label, path, fileView, !persistent);
199
+
200
+ return {
201
+ tree: replaceGroup(node, groupId, (group) => {
202
+ const previewIndex = persistent ? -1 : group.tabs.findIndex((item) => item.preview);
203
+ const tabs = [...group.tabs];
204
+
205
+ if (previewIndex >= 0) tabs.splice(previewIndex, 1, tab);
206
+ else tabs.push(tab);
207
+
208
+ return { ...group, tabs, activeTabId: tab.id };
209
+ }),
210
+ focusGroupId: groupId,
211
+ };
212
+ }
213
+
214
+ /** Keep a preview file tab open when its tab is double-clicked. */
215
+ export function keepFileTab(node: EditorNode, groupId: string, tabId: string): EditorNode {
216
+ return replaceGroup(node, groupId, (group) => ({
217
+ ...group,
218
+ tabs: group.tabs.map((tab) => (tab.id === tabId ? { ...tab, preview: false } : tab)),
219
+ }));
220
+ }
221
+
100
222
  /** Drop empty groups and collapse a branch that ends up with one child; null when nothing remains. */
101
223
  export function pruneEmpty(node: EditorNode): EditorNode | null {
102
224
  if (node.type === "group") return node.tabs.length > 0 ? node : null;
@@ -122,16 +244,30 @@ export function splitGroup(
122
244
  groupId: string,
123
245
  direction: SplitDirection,
124
246
  ): SplitResult {
247
+ if (countEditorGroups(node) >= MAX_EDITOR_GROUPS) return { tree: node, focusGroupId: groupId };
248
+
125
249
  let focusGroupId = groupId;
126
250
  const tree = replaceGroup(node, groupId, (group) => {
127
251
  const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
128
252
  if (active === undefined) return group;
129
- const movedTab: EditorTab = { ...active, id: freshId() };
253
+ const movedTab: EditorTab =
254
+ active.kind === "welcome" ? { ...active, id: freshId() } : { ...active, preview: false };
130
255
  const newGroup = makeGroup([movedTab]);
131
256
  focusGroupId = newGroup.id;
132
257
  const orientation = direction === "left" || direction === "right" ? "horizontal" : "vertical";
133
258
  const newFirst = direction === "left" || direction === "up";
134
- const children = newFirst ? [newGroup, group] : [group, newGroup];
259
+ const remainingTabs =
260
+ active.kind === "welcome" ? group.tabs : group.tabs.filter((tab) => tab.id !== active.id);
261
+ const sourceTabs =
262
+ remainingTabs.length > 0
263
+ ? remainingTabs
264
+ : [findTabByKind(node, "changes") ? welcomeTab() : changesTab()];
265
+ const sourceGroup = {
266
+ ...group,
267
+ tabs: sourceTabs,
268
+ activeTabId: sourceTabs[0]!.id,
269
+ };
270
+ const children = newFirst ? [newGroup, sourceGroup] : [sourceGroup, newGroup];
135
271
  return { type: "branch", id: freshId(), orientation, children };
136
272
  });
137
273
  return { tree, focusGroupId };
@@ -1,7 +1,8 @@
1
1
  // OpenTUI renderer for the headless tree-model; the caller owns expansion and selection.
2
2
 
3
- import React from "react";
3
+ import React, { useRef } from "react";
4
4
  import { DARK, type Theme } from "../../theme";
5
+ import { isDoubleClick, type ClickStamp } from "../../thread-selection";
5
6
  import { flattenTree, statusMeta, type TreeNode, type TreeTone } from "./tree-model";
6
7
  import { NERD_TREE_ICONS, type TreeIcons } from "./icons";
7
8
 
@@ -13,11 +14,14 @@ export interface TreeProps {
13
14
  showStatus?: boolean;
14
15
  /** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
15
16
  hideIcons?: boolean;
17
+ /** Keep file navigator entries to one visual row, truncating long names. */
18
+ singleLine?: boolean;
16
19
  /** The selected row's backdrop; defaults to `elevated` (invisible on an elevated dialog, use `border` there). */
17
20
  selectedBackground?: string;
18
21
  indentWidth?: number;
19
22
  icons?: TreeIcons;
20
23
  onSelect?: (id: string) => void;
24
+ onDoubleSelect?: (id: string) => void;
21
25
  onToggle?: (id: string) => void;
22
26
  theme?: Theme;
23
27
  }
@@ -43,16 +47,19 @@ export function Tree({
43
47
  flattenEmptyDirectories,
44
48
  showStatus,
45
49
  hideIcons,
50
+ singleLine = false,
46
51
  selectedBackground,
47
52
  indentWidth = 2,
48
53
  icons = NERD_TREE_ICONS,
49
54
  onSelect,
55
+ onDoubleSelect,
50
56
  onToggle,
51
57
  theme,
52
58
  }: TreeProps): React.ReactNode {
53
59
  const tokens = theme ?? DARK;
54
60
  const selectedBackdrop = selectedBackground ?? tokens.elevated;
55
61
  const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
62
+ const lastClick = useRef<{ id: string; stamp: ClickStamp } | null>(null);
56
63
 
57
64
  return (
58
65
  <box style={{ flexDirection: "column" }}>
@@ -73,25 +80,54 @@ export function Tree({
73
80
  id={`tree-row-${row.id}`}
74
81
  style={{
75
82
  flexDirection: "row",
83
+ justifyContent: "space-between",
84
+ height: singleLine ? 1 : undefined,
85
+ overflow: singleLine ? "hidden" : undefined,
76
86
  paddingLeft: 1 + row.depth * indentWidth,
77
- paddingRight: 1,
87
+ paddingRight: singleLine && status ? 0 : 1,
78
88
  backgroundColor: selected ? selectedBackdrop : undefined,
79
89
  }}
80
- onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
90
+ onMouseUp={(event) => {
91
+ const stamp = { time: Date.now(), x: event.x, y: event.y };
92
+ const doubleClick =
93
+ lastClick.current?.id === row.id && isDoubleClick(lastClick.current.stamp, stamp);
94
+
95
+ lastClick.current = { id: row.id, stamp };
96
+ if (row.isFolder) onToggle?.(row.id);
97
+ else {
98
+ onSelect?.(row.id);
99
+ if (doubleClick) onDoubleSelect?.(row.id);
100
+ }
101
+ }}
81
102
  >
82
- {hideIcons ? null : (
83
- <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
84
- {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
85
- </text>
86
- )}
87
- <box style={{ flexShrink: 1, minWidth: 0 }}>
88
- <text fg={selected ? tokens.accent : labelColor} truncate>
89
- {row.label}
90
- </text>
103
+ <box
104
+ style={{
105
+ flexDirection: "row",
106
+ flexShrink: 1,
107
+ minWidth: 0,
108
+ overflow: singleLine ? "hidden" : undefined,
109
+ }}
110
+ >
111
+ {hideIcons ? null : (
112
+ <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
113
+ {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
114
+ </text>
115
+ )}
116
+ <box style={{ flexShrink: 1, minWidth: 0 }}>
117
+ <text
118
+ id={`tree-label-${row.id}`}
119
+ fg={selected ? tokens.accent : labelColor}
120
+ wrapMode={singleLine ? "none" : undefined}
121
+ truncate={!singleLine}
122
+ >
123
+ {singleLine ? `${row.label} ` : row.label}
124
+ </text>
125
+ </box>
126
+ </box>
127
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
128
+ {row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
129
+ {status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
91
130
  </box>
92
- <box style={{ flexGrow: 1 }} />
93
- {row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
94
- {status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
95
131
  </box>
96
132
  );
97
133
  })}
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { highlightJobs, spansByLine } from "./diff-syntax";
3
- import type { DiffRow } from "./view-diff";
3
+ import { fileContentsRows, type DiffRow } from "./view-diff";
4
4
  import type { SimpleHighlight } from "@opentui/core";
5
5
 
6
6
  function row(kind: DiffRow["kind"], text: string, file = "src/x.ts"): DiffRow {
@@ -74,6 +74,19 @@ describe("spansByLine", () => {
74
74
  });
75
75
 
76
76
  describe("highlightJobs", () => {
77
+ for (const { path, filetype } of [
78
+ { path: "src/add.ts", filetype: "typescript" },
79
+ { path: "src/add.py", filetype: "python" },
80
+ { path: "config.json", filetype: "json" },
81
+ ]) {
82
+ test(`detects ${filetype} in a plain project file without a diff header`, () => {
83
+ const source = "first line\nsecond line";
84
+ const rows = fileContentsRows(path, source);
85
+
86
+ expect(highlightJobs(rows)).toEqual([{ filetype, source, rowIndexByLine: [0, 1] }]);
87
+ });
88
+ }
89
+
77
90
  test("reconstructs old and new sides of a hunk with row indices", () => {
78
91
  // Arrange
79
92
  const rows = [
@@ -100,7 +113,7 @@ describe("highlightJobs", () => {
100
113
  const rows = [
101
114
  row("file", "notes/thing.unknownext", "notes/thing.unknownext"),
102
115
  row("hunk", "@@"),
103
- row("add", "whatever"),
116
+ row("add", "whatever", "notes/thing.unknownext"),
104
117
  ];
105
118
 
106
119
  // Act
@@ -31,9 +31,8 @@ interface HighlightJob {
31
31
  }
32
32
 
33
33
  /**
34
- * Split diff rows into per-hunk highlight jobs, one per side. A hunk's rows are
35
- * contiguous file lines, so each side reconstructs to valid code; file/hunk
36
- * header rows and files without a known filetype are skipped.
34
+ * Split diff rows into per-hunk highlight jobs, one per side. Plain project
35
+ * files contain only context rows, so detect their language from the row path.
37
36
  */
38
37
  export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
39
38
  const jobs: HighlightJob[] = [];
@@ -42,10 +41,12 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
42
41
  let oldRowIndexByLine: number[] = [];
43
42
  let newRowIndexByLine: number[] = [];
44
43
  let filetype: string | undefined;
44
+ let path: string | undefined;
45
+ let hasChange = false;
45
46
 
46
47
  const flush = (): void => {
47
48
  if (filetype) {
48
- if (oldLines.length)
49
+ if (oldLines.length && (hasChange || newLines.length === 0))
49
50
  jobs.push({ filetype, source: oldLines.join("\n"), rowIndexByLine: oldRowIndexByLine });
50
51
  if (newLines.length)
51
52
  jobs.push({ filetype, source: newLines.join("\n"), rowIndexByLine: newRowIndexByLine });
@@ -54,14 +55,20 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
54
55
  newLines = [];
55
56
  oldRowIndexByLine = [];
56
57
  newRowIndexByLine = [];
58
+ hasChange = false;
57
59
  };
58
60
 
59
61
  for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
60
62
  const row = rows[rowIndex]!;
61
63
 
64
+ if (row.file !== path) {
65
+ flush();
66
+ path = row.file;
67
+ filetype = filetypeForPath(path);
68
+ }
69
+
62
70
  if (row.kind === "file" || row.kind === "hunk") {
63
71
  flush();
64
- filetype = row.kind === "file" ? filetypeForPath(row.file) : filetype;
65
72
  continue;
66
73
  }
67
74
  const text = stripTrailingNewline(row.text);
@@ -74,6 +81,7 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
74
81
  newLines.push(text);
75
82
  newRowIndexByLine.push(rowIndex);
76
83
  }
84
+ if (row.kind === "add" || row.kind === "del") hasChange = true;
77
85
  }
78
86
  flush();
79
87
 
@@ -431,6 +431,33 @@ function tailSession(): Thread {
431
431
  }
432
432
 
433
433
  describe("diff file fold", () => {
434
+ test("added and deleted files with a zero-sided hunk retain their expand action", async () => {
435
+ const patch = [
436
+ "diff --git a/src/new.ts b/src/new.ts",
437
+ "--- /dev/null",
438
+ "+++ b/src/new.ts",
439
+ "@@ -0,0 +1,1 @@",
440
+ "+new line",
441
+ "diff --git a/src/gone.ts b/src/gone.ts",
442
+ "--- a/src/gone.ts",
443
+ "+++ /dev/null",
444
+ "@@ -1,1 +0,0 @@",
445
+ "-old line",
446
+ "",
447
+ ].join("\n");
448
+ const files: DiffFileContents[] = [
449
+ { path: "src/new.ts", oldContents: "", newContents: "new line\n", status: "added" },
450
+ { path: "src/gone.ts", oldContents: "old line\n", newContents: "", status: "deleted" },
451
+ ];
452
+ const session = diffSession(files);
453
+ session.artifact.content = patch;
454
+ session.revisions[0]!.content = patch;
455
+ const { controller } = await connected(session);
456
+
457
+ expect(controller.canExpandFile("src/new.ts")).toBe(true);
458
+ expect(controller.canExpandFile("src/gone.ts")).toBe(true);
459
+ });
460
+
434
461
  test("collapse leaves only the file band; expand restores the body", async () => {
435
462
  // Arrange
436
463
  const { controller } = await connected(tailSession());
@@ -293,8 +293,8 @@ export interface ReviewController {
293
293
  isFileExpanded(file: string): boolean;
294
294
  /** Whether the file carries the full contents weaving needs (curatable diffs do). */
295
295
  canExpandFile(file: string): boolean;
296
- /** Copy a file's path to the clipboard; reports the outcome via status. */
297
- copyFilePath(file: string): void;
296
+ /** Copy a file's path to the clipboard; the control reports the outcome. */
297
+ copyFilePath(file: string): Promise<boolean>;
298
298
  /** Per-file added/removed line counts from the base rows (survives collapse). */
299
299
  fileStats(): Map<string, { additions: number; deletions: number }>;
300
300
  /** The walk's step list, derived from the diff rows. */
@@ -782,10 +782,8 @@ class Controller implements ReviewController {
782
782
  return (this.foldFiles() ?? []).some((entry) => entry.path === file);
783
783
  }
784
784
 
785
- copyFilePath(file: string): void {
786
- void copyToClipboard(file).then((copied) =>
787
- this.setStatus(copied ? `copied ${file}` : "no clipboard tool available"),
788
- );
785
+ copyFilePath(file: string): Promise<boolean> {
786
+ return copyToClipboard(file);
789
787
  }
790
788
 
791
789
  fileStats(): Map<string, { additions: number; deletions: number }> {