@cueloop/client 0.1.0-alpha.92 → 0.1.0-alpha.94
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.
- package/package.json +10 -10
- package/src/App.deleted-scroll.test.tsx +186 -0
- package/src/App.diff.test.tsx +3 -4
- package/src/App.test.tsx +1 -0
- package/src/App.toast.test.tsx +27 -1
- package/src/App.tsx +52 -12
- package/src/App.workbench.test.tsx +290 -2
- package/src/app-screens.tsx +34 -8
- package/src/components/AppShell.zoom.test.tsx +16 -3
- package/src/components/ChangesColumn.tsx +10 -4
- package/src/components/ChangesFileTree.test.tsx +122 -4
- package/src/components/DiffContentView.scroll.test.tsx +285 -1
- package/src/components/DiffContentView.tsx +214 -56
- package/src/components/EditorGrid.split.test.tsx +118 -0
- package/src/components/EditorGrid.stories.tsx +27 -0
- package/src/components/EditorGrid.tsx +170 -50
- package/src/components/EditorGrid.zoom.test.tsx +65 -1
- package/src/components/GridTabContent.actions.test.tsx +151 -0
- package/src/components/GridTabContent.stories.tsx +2 -2
- package/src/components/GridTabContent.tsx +5 -1
- package/src/components/ProjectTreeView.test.tsx +166 -8
- package/src/components/ProjectTreeView.tsx +53 -27
- package/src/components/ScrollArea.tsx +21 -2
- package/src/components/Toast.tsx +3 -1
- package/src/components/WelcomePlayground.test.tsx +28 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
- package/src/components/editor-grid.test.ts +87 -0
- package/src/components/editor-grid.ts +142 -6
- package/src/components/primitives/Tree.tsx +51 -15
- package/src/run.ts +6 -0
- package/src/thread-controller.curate.test.ts +27 -0
- package/src/thread-controller.live-diff.test.ts +30 -0
- package/src/thread-controller.ts +29 -7
- package/src/use-annotation-surface.tsx +86 -19
- package/src/use-changes-workbench.ts +30 -18
- package/src/use-frame-measure.ts +40 -7
- package/src/use-repo-changes.ts +5 -1
- package/src/use-settings-dialog.tsx +1 -13
- package/src/use-shared-keyboard.ts +55 -0
- 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
|
|
3
|
-
// moves
|
|
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(
|
|
63
|
-
|
|
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 =
|
|
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
|
|
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={() =>
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
})}
|
package/src/run.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { createRoot } from "@opentui/react";
|
|
|
6
6
|
import { App } from "./App";
|
|
7
7
|
import { closePrototypeBrowser } from "./prototype-browser";
|
|
8
8
|
import type { Appearance } from "./theme-presets";
|
|
9
|
+
import type { DiffFileContents, Thread } from "@cueloop/schema";
|
|
10
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
9
11
|
import { loadConfig } from "./config";
|
|
10
12
|
import { perfMark } from "./perf/perf-timings";
|
|
11
13
|
import { reportPerfMarks } from "./perf/perf-report";
|
|
@@ -17,6 +19,8 @@ import {
|
|
|
17
19
|
|
|
18
20
|
export interface RunClientOptions {
|
|
19
21
|
sessionId?: string;
|
|
22
|
+
initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
|
|
23
|
+
initialClient?: ThreadClient;
|
|
20
24
|
home?: string;
|
|
21
25
|
/** The layout a create-command opens in; omit to restore the remembered one, then the default. */
|
|
22
26
|
layout?: LaunchLayout;
|
|
@@ -73,6 +77,8 @@ export async function runClient(options: RunClientOptions): Promise<number> {
|
|
|
73
77
|
React.createElement(App, {
|
|
74
78
|
home: options.home,
|
|
75
79
|
sessionId: options.sessionId,
|
|
80
|
+
initialReview: options.initialReview,
|
|
81
|
+
initialClient: options.initialClient,
|
|
76
82
|
appearance,
|
|
77
83
|
layout,
|
|
78
84
|
extensionRegistry,
|
|
@@ -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());
|
|
@@ -97,6 +97,36 @@ function fakeClient(session: Thread): ThreadClient {
|
|
|
97
97
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
98
98
|
|
|
99
99
|
describe("live working-tree diff for a non-diff thread", () => {
|
|
100
|
+
test("a preconnected workbench accepts a comment before its inbox refresh", async () => {
|
|
101
|
+
const session = planSession();
|
|
102
|
+
const sent: Annotation[] = [];
|
|
103
|
+
const client = {
|
|
104
|
+
...fakeClient(session),
|
|
105
|
+
sessionComment: async (_id: string, annotation: Annotation) => {
|
|
106
|
+
sent.push(annotation);
|
|
107
|
+
|
|
108
|
+
return { ...session, annotations: sent };
|
|
109
|
+
},
|
|
110
|
+
} satisfies ThreadClient;
|
|
111
|
+
const controller = createReviewController({
|
|
112
|
+
sessionId: session.id,
|
|
113
|
+
initialSession: session,
|
|
114
|
+
initialDiff: { patch: PATCH, files: FILES },
|
|
115
|
+
initialClient: client,
|
|
116
|
+
shareTransport,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
controller.addComment(
|
|
120
|
+
{ quote: "const a = 2;", prefix: "", suffix: "" },
|
|
121
|
+
{ kind: "file", path: "src/x.ts", rev: "worktree" },
|
|
122
|
+
"ready frame comment",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(sent).toHaveLength(1);
|
|
126
|
+
expect(sent[0]?.body).toBe("ready frame comment");
|
|
127
|
+
controller.close();
|
|
128
|
+
});
|
|
129
|
+
|
|
100
130
|
test("repoChanges carries file contents and rows derive from the patch", async () => {
|
|
101
131
|
const session = planSession();
|
|
102
132
|
const controller = createReviewController({
|
package/src/thread-controller.ts
CHANGED
|
@@ -251,6 +251,11 @@ export interface NewShareLink {
|
|
|
251
251
|
export interface ReviewControllerOptions {
|
|
252
252
|
home?: string;
|
|
253
253
|
sessionId?: string;
|
|
254
|
+
/** Captured before the UI mounts, so the first review frame has its thread and live diff. */
|
|
255
|
+
initialSession?: Thread;
|
|
256
|
+
initialDiff?: { patch: string; files: DiffFileContents[] };
|
|
257
|
+
/** The owner connection that supplied the first review snapshot. */
|
|
258
|
+
initialClient?: ThreadClient;
|
|
254
259
|
/** The directory the client launched in; its git repo backs the no-session welcome tree. Defaults to process.cwd(). */
|
|
255
260
|
cwd?: string;
|
|
256
261
|
/** Observer mode: stored for the key reducer's read-only gate. */
|
|
@@ -293,8 +298,8 @@ export interface ReviewController {
|
|
|
293
298
|
isFileExpanded(file: string): boolean;
|
|
294
299
|
/** Whether the file carries the full contents weaving needs (curatable diffs do). */
|
|
295
300
|
canExpandFile(file: string): boolean;
|
|
296
|
-
/** Copy a file's path to the clipboard; reports the outcome
|
|
297
|
-
copyFilePath(file: string):
|
|
301
|
+
/** Copy a file's path to the clipboard; the control reports the outcome. */
|
|
302
|
+
copyFilePath(file: string): Promise<boolean>;
|
|
298
303
|
/** Per-file added/removed line counts from the base rows (survives collapse). */
|
|
299
304
|
fileStats(): Map<string, { additions: number; deletions: number }>;
|
|
300
305
|
/** The walk's step list, derived from the diff rows. */
|
|
@@ -491,6 +496,7 @@ class Controller implements ReviewController {
|
|
|
491
496
|
private derivedFor: Thread | null = null;
|
|
492
497
|
/** The launch/workspace repo's live working-tree diff, feeding the Changes view for non-diff threads. */
|
|
493
498
|
private liveDiff: LiveWorkingDiff | null = null;
|
|
499
|
+
private initialDiffAvailable = false;
|
|
494
500
|
private derivedForLiveDiff: LiveWorkingDiff | null = null;
|
|
495
501
|
private derived: DerivedSessionProjection = {
|
|
496
502
|
display: [],
|
|
@@ -514,6 +520,12 @@ class Controller implements ReviewController {
|
|
|
514
520
|
}
|
|
515
521
|
|
|
516
522
|
constructor(private readonly options: ReviewControllerOptions) {
|
|
523
|
+
this.client = options.initialClient ?? null;
|
|
524
|
+
if (options.initialSession?.id === options.sessionId && options.initialDiff) {
|
|
525
|
+
this.liveDiff = options.initialDiff;
|
|
526
|
+
this.initialDiffAvailable = true;
|
|
527
|
+
this.store.setState({ session: options.initialSession });
|
|
528
|
+
}
|
|
517
529
|
this.readOnly = options.readOnly ?? false;
|
|
518
530
|
this.clock = options.clock ?? new SystemClock();
|
|
519
531
|
this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
|
|
@@ -541,7 +553,9 @@ class Controller implements ReviewController {
|
|
|
541
553
|
try {
|
|
542
554
|
const openClient =
|
|
543
555
|
this.options.openClient ??
|
|
544
|
-
(() =>
|
|
556
|
+
(() =>
|
|
557
|
+
this.options.initialClient ??
|
|
558
|
+
DaemonClient.connect({ home: this.options.home, autostart: true }));
|
|
545
559
|
const client = await openClient();
|
|
546
560
|
|
|
547
561
|
if (this.closed) return void client.close();
|
|
@@ -782,10 +796,8 @@ class Controller implements ReviewController {
|
|
|
782
796
|
return (this.foldFiles() ?? []).some((entry) => entry.path === file);
|
|
783
797
|
}
|
|
784
798
|
|
|
785
|
-
copyFilePath(file: string):
|
|
786
|
-
|
|
787
|
-
this.setStatus(copied ? `copied ${file}` : "no clipboard tool available"),
|
|
788
|
-
);
|
|
799
|
+
copyFilePath(file: string): Promise<boolean> {
|
|
800
|
+
return copyToClipboard(file);
|
|
789
801
|
}
|
|
790
802
|
|
|
791
803
|
fileStats(): Map<string, { additions: number; deletions: number }> {
|
|
@@ -841,6 +853,9 @@ class Controller implements ReviewController {
|
|
|
841
853
|
// a plain diff review pins its captured snapshot; a workbench thread and every other thread reflect the live working tree
|
|
842
854
|
const session = this.snapshot.session;
|
|
843
855
|
if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
|
|
856
|
+
const initialFiles = this.consumeInitialDiffFiles(session);
|
|
857
|
+
|
|
858
|
+
if (initialFiles !== null) return initialFiles;
|
|
844
859
|
// eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
|
|
845
860
|
if (this.client?.repoDiff !== undefined) {
|
|
846
861
|
const diff = await this.client.repoDiff(
|
|
@@ -881,6 +896,13 @@ class Controller implements ReviewController {
|
|
|
881
896
|
}));
|
|
882
897
|
}
|
|
883
898
|
|
|
899
|
+
private consumeInitialDiffFiles(session: Thread | null): readonly DiffFileContents[] | null {
|
|
900
|
+
if (!this.initialDiffAvailable) return null;
|
|
901
|
+
this.initialDiffAvailable = false;
|
|
902
|
+
|
|
903
|
+
return session?.id === this.options.sessionId ? (this.liveDiff?.files ?? []) : null;
|
|
904
|
+
}
|
|
905
|
+
|
|
884
906
|
async refreshDiff(): Promise<void> {
|
|
885
907
|
const session = this.snapshot.session;
|
|
886
908
|
|