@cueloop/client 0.1.0-alpha.92 → 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.
- package/package.json +5 -5
- 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 +21 -12
- package/src/App.workbench.test.tsx +208 -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/thread-controller.curate.test.ts +27 -0
- package/src/thread-controller.ts +4 -6
- 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-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
|
})}
|
|
@@ -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());
|
package/src/thread-controller.ts
CHANGED
|
@@ -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
|
|
297
|
-
copyFilePath(file: string):
|
|
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):
|
|
786
|
-
|
|
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 }> {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
16
16
|
import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
|
|
17
|
-
import {
|
|
17
|
+
import { flushSync } from "@opentui/react";
|
|
18
|
+
import { useSharedKeyboard } from "./use-shared-keyboard";
|
|
18
19
|
import type { Annotation, Thread } from "@cueloop/schema";
|
|
19
20
|
import type { Mark } from "./view-plan";
|
|
20
21
|
import type { QuickAction } from "./config";
|
|
@@ -97,6 +98,13 @@ export interface AnnotationSurfaceOptions {
|
|
|
97
98
|
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
98
99
|
/** Reports the caret's block, so block-level primitives (cut, restore) act where the caret is. */
|
|
99
100
|
onCursorChange?: (blockIndex: number) => void;
|
|
101
|
+
/** Scroll a visual row before crossing a tall gap; true defers the caret move. */
|
|
102
|
+
onVerticalStep?: (
|
|
103
|
+
fromBlock: number,
|
|
104
|
+
toBlock: number,
|
|
105
|
+
direction: -1 | 1,
|
|
106
|
+
targetY?: number,
|
|
107
|
+
) => boolean;
|
|
100
108
|
/** The rail's focused card; the discussion holding it takes focus here. */
|
|
101
109
|
focusedAnnotationId?: string;
|
|
102
110
|
/** Reports the focused discussion's root comment, so the rail follows. */
|
|
@@ -129,6 +137,10 @@ export interface AnnotationSurface {
|
|
|
129
137
|
focusedDiscussion: string | null;
|
|
130
138
|
/** The block to keep in view: an opening card, a focused discussion, else the caret. */
|
|
131
139
|
revealBlockIndex: number;
|
|
140
|
+
/** Screen row of the mounted visual line carrying the caret. */
|
|
141
|
+
headVisualY: () => number | undefined;
|
|
142
|
+
/** Capture mounted lines before a viewport scroll, then keep an exiting caret at its visible edge. */
|
|
143
|
+
prepareViewportScroll: () => (delta: number, top: number, bottom: number) => void;
|
|
132
144
|
/** The text a span covers, blocks joined by a space, for previews. */
|
|
133
145
|
spanQuote: (span: TextSpan) => string;
|
|
134
146
|
/** Ref callback for one visual line, so a drag can hit-test it. */
|
|
@@ -192,6 +204,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
192
204
|
onComposingChange,
|
|
193
205
|
onObserverBlocked,
|
|
194
206
|
onCursorChange,
|
|
207
|
+
onVerticalStep,
|
|
195
208
|
focusedAnnotationId,
|
|
196
209
|
onFocusAnnotation,
|
|
197
210
|
onAnnotate,
|
|
@@ -336,15 +349,55 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
336
349
|
x: entry.renderable.x,
|
|
337
350
|
y: entry.renderable.y,
|
|
338
351
|
}));
|
|
339
|
-
const
|
|
352
|
+
const headVisualY = (): number | undefined => {
|
|
353
|
+
const textLength = textLengthOf(head.blockIndex);
|
|
354
|
+
const cell = Math.max(0, Math.min(textLength - 1, head.char));
|
|
355
|
+
|
|
356
|
+
return allGeometry().find(
|
|
357
|
+
(line) =>
|
|
358
|
+
line.blockIndex === head.blockIndex &&
|
|
359
|
+
line.start <= cell &&
|
|
360
|
+
(cell < line.end || line.start === line.end),
|
|
361
|
+
)?.y;
|
|
362
|
+
};
|
|
363
|
+
const prepareViewportScroll = (): ((delta: number, top: number, bottom: number) => void) => {
|
|
364
|
+
const lines = allGeometry().filter((line) => source.annotatable(line.blockIndex));
|
|
365
|
+
const oldHeadY = headVisualY();
|
|
366
|
+
|
|
367
|
+
return (delta, top, bottom) => {
|
|
368
|
+
if (delta === 0 || oldHeadY === undefined || compose || focusedDiscussion || caretIsSelection)
|
|
369
|
+
return;
|
|
370
|
+
const shiftedHeadY = oldHeadY - delta;
|
|
371
|
+
|
|
372
|
+
if (shiftedHeadY >= top && shiftedHeadY <= bottom) return;
|
|
373
|
+
const visible = lines
|
|
374
|
+
.filter((line) => line.y - delta >= top && line.y - delta <= bottom)
|
|
375
|
+
.toSorted((left, right) => left.y - right.y || left.x - right.x);
|
|
376
|
+
const target = delta < 0 ? visible.at(-1) : visible[0];
|
|
377
|
+
|
|
378
|
+
if (!target) return;
|
|
379
|
+
const next = { blockIndex: target.blockIndex, char: target.start };
|
|
380
|
+
|
|
381
|
+
flushSync(() => {
|
|
382
|
+
setCaret({ head: next, anchor: next });
|
|
383
|
+
setCursor(next.blockIndex);
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
const verticalTextPosition = (
|
|
388
|
+
position: TextPosition,
|
|
389
|
+
direction: -1 | 1,
|
|
390
|
+
): { position: TextPosition; y: number } | null => {
|
|
391
|
+
const textLength = textLengthOf(position.blockIndex);
|
|
392
|
+
const cell = Math.max(0, Math.min(textLength - 1, position.char));
|
|
340
393
|
const lines = allGeometry()
|
|
341
394
|
.filter((line) => source.annotatable(line.blockIndex))
|
|
342
395
|
.toSorted((left, right) => left.y - right.y || left.x - right.x);
|
|
343
396
|
const currentIndex = lines.findIndex(
|
|
344
397
|
(line) =>
|
|
345
398
|
line.blockIndex === position.blockIndex &&
|
|
346
|
-
line.start <=
|
|
347
|
-
|
|
399
|
+
line.start <= cell &&
|
|
400
|
+
(cell < line.end || line.start === line.end),
|
|
348
401
|
);
|
|
349
402
|
const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
|
|
350
403
|
|
|
@@ -353,8 +406,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
353
406
|
const column = Math.max(0, position.char - current.start);
|
|
354
407
|
|
|
355
408
|
return {
|
|
356
|
-
|
|
357
|
-
|
|
409
|
+
position: {
|
|
410
|
+
blockIndex: target.blockIndex,
|
|
411
|
+
char: Math.min(target.end, target.start + column),
|
|
412
|
+
},
|
|
413
|
+
y: target.y,
|
|
358
414
|
};
|
|
359
415
|
};
|
|
360
416
|
|
|
@@ -671,22 +727,31 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
671
727
|
);
|
|
672
728
|
};
|
|
673
729
|
|
|
730
|
+
const moveCaretVertical = (key: KeyEvent, vertical: -1 | 1): void => {
|
|
731
|
+
const nextBlock = nearestAnnotatable(source, cursor, vertical);
|
|
732
|
+
const visualTarget = verticalTextPosition(head, vertical);
|
|
733
|
+
|
|
734
|
+
if (!visualTarget && nextBlock === head.blockIndex) return;
|
|
735
|
+
const nextHead = visualTarget?.position ?? {
|
|
736
|
+
blockIndex: nextBlock,
|
|
737
|
+
char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
if (onVerticalStep?.(head.blockIndex, nextHead.blockIndex, vertical, visualTarget?.y)) return;
|
|
741
|
+
|
|
742
|
+
setCaret({
|
|
743
|
+
head: nextHead,
|
|
744
|
+
anchor: key.shift ? caret.anchor : nextHead,
|
|
745
|
+
});
|
|
746
|
+
setFocusedDiscussion(null);
|
|
747
|
+
setCursor(nextHead.blockIndex);
|
|
748
|
+
};
|
|
749
|
+
|
|
674
750
|
const handleCaretKey = (key: KeyEvent): boolean => {
|
|
675
751
|
const vertical = verticalCaretDelta(key);
|
|
676
752
|
|
|
677
753
|
if (vertical !== 0) {
|
|
678
|
-
|
|
679
|
-
const nextHead = verticalTextPosition(head, vertical) ?? {
|
|
680
|
-
blockIndex: nextBlock,
|
|
681
|
-
char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
|
|
682
|
-
};
|
|
683
|
-
|
|
684
|
-
setCaret({
|
|
685
|
-
head: nextHead,
|
|
686
|
-
anchor: key.shift ? caret.anchor : nextHead,
|
|
687
|
-
});
|
|
688
|
-
setFocusedDiscussion(null);
|
|
689
|
-
setCursor(nextHead.blockIndex);
|
|
754
|
+
moveCaretVertical(key, vertical);
|
|
690
755
|
|
|
691
756
|
return true;
|
|
692
757
|
}
|
|
@@ -726,7 +791,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
726
791
|
openNewCompose(sequence);
|
|
727
792
|
};
|
|
728
793
|
|
|
729
|
-
|
|
794
|
+
useSharedKeyboard((key) => {
|
|
730
795
|
if (suspended) return;
|
|
731
796
|
if (key.ctrl && key.name === "q") return onExit();
|
|
732
797
|
const activeCompose = composeRef.current;
|
|
@@ -963,6 +1028,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
963
1028
|
navMode,
|
|
964
1029
|
focusedDiscussion,
|
|
965
1030
|
revealBlockIndex,
|
|
1031
|
+
headVisualY,
|
|
1032
|
+
prepareViewportScroll,
|
|
966
1033
|
spanQuote,
|
|
967
1034
|
registerLine,
|
|
968
1035
|
onLineMouseDown,
|