@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.
- 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/AnnotatableFileView.test.tsx +48 -1
- 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/diff-syntax.test.ts +15 -2
- package/src/diff-syntax.ts +13 -5
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.93",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.93",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.93",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.93",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.93",
|
|
34
34
|
"@opentui/core": "^0.5.11",
|
|
35
35
|
"@opentui/keymap": "0.5.11",
|
|
36
36
|
"@opentui/react": "^0.5.11",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/** Keyboard scrolling in a changed-file tab uses caret navigation, even when the file was deleted. */
|
|
2
|
+
|
|
3
|
+
import { expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import { App } from "./App";
|
|
11
|
+
import { annotationPaletteFor } from "./annotation-palette";
|
|
12
|
+
import { DARK } from "./theme";
|
|
13
|
+
import {
|
|
14
|
+
isolateUserConfig,
|
|
15
|
+
locateText,
|
|
16
|
+
press,
|
|
17
|
+
renderReadyApp,
|
|
18
|
+
settle,
|
|
19
|
+
waitForText,
|
|
20
|
+
} from "./test-support";
|
|
21
|
+
|
|
22
|
+
function findScrollbox(node: Renderable): ScrollBoxRenderable | null {
|
|
23
|
+
if (node.id === "diff-scroll" && node instanceof ScrollBoxRenderable) return node;
|
|
24
|
+
|
|
25
|
+
for (const child of node.getChildren()) {
|
|
26
|
+
const found = findScrollbox(child);
|
|
27
|
+
|
|
28
|
+
if (found) return found;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hex(color: { toInts(): [number, number, number, number] }): string {
|
|
35
|
+
const [red, green, blue] = color.toInts();
|
|
36
|
+
|
|
37
|
+
return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("deleted file caret scrolls one row and stays at the file boundaries", async () => {
|
|
41
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-scroll-home-"));
|
|
42
|
+
const repo = mkdtempSync(join(tmpdir(), "cueloop-scroll-repo-"));
|
|
43
|
+
const restoreConfig = isolateUserConfig(home);
|
|
44
|
+
const server = new DaemonServer({ home, idleExitMs: 0 });
|
|
45
|
+
let setup: Awaited<ReturnType<typeof renderReadyApp>> | undefined;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const file = join(repo, "long.md");
|
|
49
|
+
const lines = Array.from({ length: 180 }, (_, index) =>
|
|
50
|
+
index === 113
|
|
51
|
+
? "[nodejs/node#36005](https://github.com/nodejs/node/issues/36005)."
|
|
52
|
+
: `row ${String(index).padStart(3, "0")} ${"a paragraph with wrapped text and a link [source](https://example.test/page) ".repeat((index % 3) + 1)}`,
|
|
53
|
+
);
|
|
54
|
+
const git = (args: string[]) => Bun.spawnSync(["git", "-C", repo, ...args]);
|
|
55
|
+
|
|
56
|
+
writeFileSync(file, lines.join("\n") + "\n");
|
|
57
|
+
git(["init", "-q"]);
|
|
58
|
+
git(["config", "user.email", "fixture@example.com"]);
|
|
59
|
+
git(["config", "user.name", "Fixture"]);
|
|
60
|
+
git(["add", "-A"]);
|
|
61
|
+
git(["commit", "-q", "-m", "seed"]);
|
|
62
|
+
unlinkSync(file);
|
|
63
|
+
server.start();
|
|
64
|
+
setup = await renderReadyApp(<App home={home} sessionId={undefined} cwd={repo} />, {
|
|
65
|
+
width: 180,
|
|
66
|
+
height: 48,
|
|
67
|
+
});
|
|
68
|
+
await waitForText(setup, "long.md");
|
|
69
|
+
const treeFile = locateText(setup, "long.md");
|
|
70
|
+
|
|
71
|
+
await setup.mockMouse.click(treeFile.column, treeFile.row);
|
|
72
|
+
await waitForText(setup, "row 00");
|
|
73
|
+
const box = findScrollbox(setup.renderer.root);
|
|
74
|
+
|
|
75
|
+
if (!box) throw new Error("file diff scrollbox missing");
|
|
76
|
+
for (let offset = 0; offset < 1000; offset += 20) {
|
|
77
|
+
box.scrollTo({ x: 0, y: offset });
|
|
78
|
+
await settle(setup);
|
|
79
|
+
if (setup.captureCharFrame().includes("row 130")) break;
|
|
80
|
+
}
|
|
81
|
+
const target = locateText(setup, "row 130");
|
|
82
|
+
|
|
83
|
+
await setup.mockMouse.click(target.column, target.row);
|
|
84
|
+
await press(setup, "escape");
|
|
85
|
+
|
|
86
|
+
const steps: number[] = [];
|
|
87
|
+
const caretCell = annotationPaletteFor(DARK).caretCell;
|
|
88
|
+
const frameTrace: Array<{ markers: number[]; caret: number }> = [];
|
|
89
|
+
const captureFrame = () => {
|
|
90
|
+
const lines = setup!.captureCharFrame().split("\n");
|
|
91
|
+
const markers = lines.flatMap((line, row) => (line.includes("▎") ? [row] : []));
|
|
92
|
+
const caret = setup!
|
|
93
|
+
.captureSpans()
|
|
94
|
+
.lines.findIndex((line) => line.spans.some((span) => hex(span.bg) === caretCell));
|
|
95
|
+
|
|
96
|
+
frameTrace.push({ markers, caret });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
setup.renderer.on("frame", captureFrame);
|
|
100
|
+
|
|
101
|
+
for (let index = 0; index < 12; index++) {
|
|
102
|
+
const before = box.scrollTop;
|
|
103
|
+
await press(setup, "down");
|
|
104
|
+
steps.push(box.scrollTop - before);
|
|
105
|
+
const caretRow = setup
|
|
106
|
+
.captureSpans()
|
|
107
|
+
.lines.findIndex((line) => line.spans.some((span) => hex(span.bg) === caretCell));
|
|
108
|
+
const markerRows = setup
|
|
109
|
+
.captureCharFrame()
|
|
110
|
+
.split("\n")
|
|
111
|
+
.flatMap((line, row) => (line.includes("▎") ? [row] : []));
|
|
112
|
+
|
|
113
|
+
expect(caretRow).toBeGreaterThanOrEqual(0);
|
|
114
|
+
expect(markerRows).toEqual([caretRow]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
expect(Math.max(...steps)).toBeLessThanOrEqual(1);
|
|
118
|
+
expect(Math.min(...steps)).toBeGreaterThanOrEqual(0);
|
|
119
|
+
let anchored = false;
|
|
120
|
+
|
|
121
|
+
for (let index = 0; index < 80; index++) {
|
|
122
|
+
await press(setup, "up");
|
|
123
|
+
const marker = setup
|
|
124
|
+
.captureCharFrame()
|
|
125
|
+
.split("\n")
|
|
126
|
+
.findIndex((line) => line.includes("▎"));
|
|
127
|
+
|
|
128
|
+
if (marker === box.viewport.screenY) anchored = true;
|
|
129
|
+
if (anchored && box.scrollTop > 0) expect(marker).toBe(box.viewport.screenY);
|
|
130
|
+
}
|
|
131
|
+
expect(anchored).toBe(true);
|
|
132
|
+
box.scrollTo({ x: 0, y: 100000 });
|
|
133
|
+
await settle(setup);
|
|
134
|
+
const last = locateText(setup, "row 179");
|
|
135
|
+
|
|
136
|
+
await setup.mockMouse.click(last.column, last.row);
|
|
137
|
+
await press(setup, "escape");
|
|
138
|
+
let lastMarker = -1;
|
|
139
|
+
|
|
140
|
+
for (let index = 0; index < 12; index++) {
|
|
141
|
+
const traceStart = frameTrace.length;
|
|
142
|
+
const before = box.scrollTop;
|
|
143
|
+
|
|
144
|
+
await press(setup, "down");
|
|
145
|
+
const states = frameTrace.slice(traceStart);
|
|
146
|
+
|
|
147
|
+
expect(box.scrollTop).toBe(before);
|
|
148
|
+
expect(states.length).toBeGreaterThan(0);
|
|
149
|
+
for (const state of states) {
|
|
150
|
+
expect(state.markers).toEqual([state.caret]);
|
|
151
|
+
expect(state.caret).toBeGreaterThanOrEqual(lastMarker);
|
|
152
|
+
lastMarker = state.caret;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
box.scrollTo({ x: 0, y: 0 });
|
|
156
|
+
await settle(setup);
|
|
157
|
+
const first = locateText(setup, "row 000");
|
|
158
|
+
|
|
159
|
+
await setup.mockMouse.click(first.column, first.row);
|
|
160
|
+
await press(setup, "escape");
|
|
161
|
+
let firstMarker = Number.POSITIVE_INFINITY;
|
|
162
|
+
|
|
163
|
+
for (let index = 0; index < 12; index++) {
|
|
164
|
+
const traceStart = frameTrace.length;
|
|
165
|
+
const before = box.scrollTop;
|
|
166
|
+
|
|
167
|
+
await press(setup, "up");
|
|
168
|
+
const states = frameTrace.slice(traceStart);
|
|
169
|
+
|
|
170
|
+
expect(box.scrollTop).toBe(before);
|
|
171
|
+
expect(states.length).toBeGreaterThan(0);
|
|
172
|
+
for (const state of states) {
|
|
173
|
+
expect(state.markers).toEqual([state.caret]);
|
|
174
|
+
expect(state.caret).toBeLessThanOrEqual(firstMarker);
|
|
175
|
+
firstMarker = state.caret;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
setup.renderer.off("frame", captureFrame);
|
|
179
|
+
} finally {
|
|
180
|
+
setup?.renderer.destroy();
|
|
181
|
+
server.stop();
|
|
182
|
+
restoreConfig();
|
|
183
|
+
rmSync(home, { recursive: true, force: true });
|
|
184
|
+
rmSync(repo, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
}, 30000);
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -10,7 +10,6 @@ import type { Thread } from "@cueloop/schema";
|
|
|
10
10
|
import { App } from "./App";
|
|
11
11
|
import { pullRequestReviewLayout } from "./launch-layout";
|
|
12
12
|
import { DARK } from "./theme";
|
|
13
|
-
import { NERD } from "./components/primitives/icons";
|
|
14
13
|
import {
|
|
15
14
|
clickText,
|
|
16
15
|
dragText,
|
|
@@ -220,7 +219,7 @@ describe("diff review", () => {
|
|
|
220
219
|
await waitForText(setup, "● Existing note.");
|
|
221
220
|
});
|
|
222
221
|
|
|
223
|
-
test("option+c folds the caret's file to its band;
|
|
222
|
+
test("option+c folds the caret's file to its band; its title restores the body", async () => {
|
|
224
223
|
// Arrange - the caret opens on the file's first code line
|
|
225
224
|
const setup = await renderApp();
|
|
226
225
|
|
|
@@ -234,8 +233,8 @@ describe("diff review", () => {
|
|
|
234
233
|
expect(collapsed).toContain("src/store.ts");
|
|
235
234
|
expect(collapsed).toContain("+1");
|
|
236
235
|
|
|
237
|
-
// Act - the
|
|
238
|
-
await clickText(setup,
|
|
236
|
+
// Act - the title unfolds it again
|
|
237
|
+
await clickText(setup, "src/store.ts");
|
|
239
238
|
|
|
240
239
|
// Assert
|
|
241
240
|
await waitForText(setup, "new Map()");
|
package/src/App.test.tsx
CHANGED
|
@@ -442,6 +442,7 @@ describe("the thread view and the menu", () => {
|
|
|
442
442
|
const dialog = setup.captureCharFrame();
|
|
443
443
|
|
|
444
444
|
expect(dialog).toContain("nav mode");
|
|
445
|
+
expect(dialog).toContain("open selected file or toggle folder");
|
|
445
446
|
expect(dialog).toContain("place the caret");
|
|
446
447
|
expect(dialog).not.toContain("grow/shrink");
|
|
447
448
|
|
package/src/App.toast.test.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
|
|
2
2
|
|
|
3
|
-
import { afterEach, beforeEach, describe, mock, test } from "bun:test";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
4
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
@@ -13,6 +13,7 @@ import type { ShareTransport } from "./thread-controller";
|
|
|
13
13
|
import {
|
|
14
14
|
clickText,
|
|
15
15
|
isolateUserConfig,
|
|
16
|
+
locateText,
|
|
16
17
|
press,
|
|
17
18
|
pressKey,
|
|
18
19
|
typeText,
|
|
@@ -63,6 +64,31 @@ afterEach(() => {
|
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
describe("share toast", () => {
|
|
67
|
+
test("clicking outside dismisses the toast while clicking inside keeps it open", async () => {
|
|
68
|
+
const setup = await testRender(
|
|
69
|
+
<App home={home} sessionId={session.id} shareTransport={shareTransport} />,
|
|
70
|
+
{ width: 120, height: 32 },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
await waitForText(setup, "cueloop");
|
|
74
|
+
await pressKey(setup, "s", { ctrl: true });
|
|
75
|
+
await waitForText(setup, "+ new link");
|
|
76
|
+
await press(setup, "enter");
|
|
77
|
+
await press(setup, "enter");
|
|
78
|
+
await waitForText(setup, "link name");
|
|
79
|
+
await press(setup, "enter");
|
|
80
|
+
await waitForText(setup, "link copied");
|
|
81
|
+
await press(setup, "escape");
|
|
82
|
+
await waitForTextGone(setup, "share externally");
|
|
83
|
+
const body = locateText(setup, "ssh p_share01@cueloop.dev");
|
|
84
|
+
|
|
85
|
+
await setup.mockMouse.click(body.column, body.row);
|
|
86
|
+
expect(setup.captureCharFrame()).toContain("link copied");
|
|
87
|
+
await setup.mockMouse.click(1, 1);
|
|
88
|
+
await waitForTextGone(setup, "link copied");
|
|
89
|
+
setup.renderer.destroy();
|
|
90
|
+
});
|
|
91
|
+
|
|
66
92
|
test("escape cancels an open composer even while the toast is up", async () => {
|
|
67
93
|
// Arrange
|
|
68
94
|
const setup = await testRender(
|
package/src/App.tsx
CHANGED
|
@@ -330,8 +330,8 @@ function ProjectPanelBody(props: {
|
|
|
330
330
|
diffSourceKey: string;
|
|
331
331
|
liveWorkingTree: boolean;
|
|
332
332
|
frozenFiles?: readonly DiffFileContents[];
|
|
333
|
-
onOpenChangedFile: (path: string) => void;
|
|
334
|
-
onOpenProjectFile: (path: string) => void;
|
|
333
|
+
onOpenChangedFile: (path: string, persistent?: boolean) => void;
|
|
334
|
+
onOpenProjectFile: (path: string, persistent?: boolean) => void;
|
|
335
335
|
commentCounts?: ReadonlyMap<string, number>;
|
|
336
336
|
focused?: boolean;
|
|
337
337
|
theme: Theme;
|
|
@@ -391,12 +391,20 @@ function usableScreenReached(
|
|
|
391
391
|
/** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
|
|
392
392
|
function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
|
|
393
393
|
const base = keyBindings.cheatsheet();
|
|
394
|
+
const fileTree: CheatsheetSection = {
|
|
395
|
+
title: "Changes / Project files",
|
|
396
|
+
entries: [
|
|
397
|
+
{ keys: "enter", label: "open selected file or toggle folder" },
|
|
398
|
+
{ keys: "tab", label: "cycle panes" },
|
|
399
|
+
],
|
|
400
|
+
};
|
|
394
401
|
|
|
395
402
|
if (!threadViewActive) {
|
|
396
|
-
return base;
|
|
403
|
+
return [fileTree, ...base];
|
|
397
404
|
}
|
|
398
405
|
|
|
399
406
|
return [
|
|
407
|
+
fileTree,
|
|
400
408
|
...THREAD_VIEW_CHEATSHEET,
|
|
401
409
|
{ title: "Nav mode · session", entries: sessionCommandEntries() },
|
|
402
410
|
{ title: "Nav mode · diff", entries: diffCommandEntries() },
|
|
@@ -630,7 +638,7 @@ export function App({
|
|
|
630
638
|
const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
|
|
631
639
|
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
|
632
640
|
const [autoClose, setAutoClose] = useState<AutoClose>("off");
|
|
633
|
-
// unified or side-by-side diff; split
|
|
641
|
+
// unified or side-by-side diff; split lays out when the Changes pane is zoomed
|
|
634
642
|
const [diffView, setDiffView] = useState<DiffViewMode>("split");
|
|
635
643
|
const [defaultMessage, setDefaultMessage] = useState<MessageOutcome>("approved");
|
|
636
644
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
|
|
@@ -656,10 +664,6 @@ export function App({
|
|
|
656
664
|
const [skills, setSkills] = useState<SlashItem[]>([]);
|
|
657
665
|
const [reviewSkill, setReviewSkill] = useState("code-review");
|
|
658
666
|
const [reviewWorkspace, setReviewWorkspace] = useState<ReviewWorkspaceMode>("worktree");
|
|
659
|
-
const reviewSkillOptions = useMemo(
|
|
660
|
-
() => [...new Set([reviewSkill, "code-review", ...skills.map((skill) => skill.name)])],
|
|
661
|
-
[reviewSkill, skills],
|
|
662
|
-
);
|
|
663
667
|
const paletteNames = useMemo(
|
|
664
668
|
() => new Set(mergeSlashItems(slashItemsFrom(quickActions), skills).map((item) => item.name)),
|
|
665
669
|
[quickActions, skills],
|
|
@@ -767,8 +771,6 @@ export function App({
|
|
|
767
771
|
setMode({ type: "renameSelf", text: identity.name ?? "" });
|
|
768
772
|
},
|
|
769
773
|
reviewSkill,
|
|
770
|
-
reviewSkillOptions,
|
|
771
|
-
setReviewSkill,
|
|
772
774
|
reviewWorkspace,
|
|
773
775
|
setReviewWorkspace,
|
|
774
776
|
});
|
|
@@ -1116,6 +1118,7 @@ export function App({
|
|
|
1116
1118
|
onPin={togglePin}
|
|
1117
1119
|
onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
|
|
1118
1120
|
quickActions={quickActions}
|
|
1121
|
+
diffView={diffView}
|
|
1119
1122
|
onWelcomeComposingChange={setWelcomeComposing}
|
|
1120
1123
|
layout={layout}
|
|
1121
1124
|
/>
|
|
@@ -1367,6 +1370,7 @@ export function App({
|
|
|
1367
1370
|
commentCounts={diffCommentCounts}
|
|
1368
1371
|
onFocusGroup={workbench.focusGroup}
|
|
1369
1372
|
onActivateTab={workbench.activate}
|
|
1373
|
+
onKeepTab={workbench.keepTab}
|
|
1370
1374
|
onCloseTab={workbench.close}
|
|
1371
1375
|
onSplit={workbench.split}
|
|
1372
1376
|
onZoom={workbench.toggleZoom}
|
|
@@ -1447,8 +1451,12 @@ export function App({
|
|
|
1447
1451
|
{...projectDiffFiles(activeSession)}
|
|
1448
1452
|
// the Changes navigator always opens a changed file as a diff - a diff review shows its
|
|
1449
1453
|
// captured snapshot, every other thread the live working-tree diff
|
|
1450
|
-
onOpenChangedFile={(path) =>
|
|
1451
|
-
|
|
1454
|
+
onOpenChangedFile={(path, persistent) =>
|
|
1455
|
+
workbench.openFile(path, "diff", persistent)
|
|
1456
|
+
}
|
|
1457
|
+
onOpenProjectFile={(path, persistent) =>
|
|
1458
|
+
workbench.openFile(path, "contents", persistent)
|
|
1459
|
+
}
|
|
1452
1460
|
commentCounts={diffCommentCounts}
|
|
1453
1461
|
focused={focusedPane === "project"}
|
|
1454
1462
|
theme={theme}
|
|
@@ -1466,6 +1474,7 @@ export function App({
|
|
|
1466
1474
|
theme={theme}
|
|
1467
1475
|
mode={mode}
|
|
1468
1476
|
toast={toast}
|
|
1477
|
+
onDismissToast={() => controller.dismissToast()}
|
|
1469
1478
|
setMode={setMode}
|
|
1470
1479
|
dispatch={dispatch}
|
|
1471
1480
|
/>
|
|
@@ -113,6 +113,44 @@ const rightToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sid
|
|
|
113
113
|
const railToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRightOff);
|
|
114
114
|
|
|
115
115
|
describe("the four-pane workbench", () => {
|
|
116
|
+
test("rapid wheel scrolling a large Changes diff keeps the app responsive", async () => {
|
|
117
|
+
const filePatches = Array.from({ length: 40 }, (_, fileIndex) => [
|
|
118
|
+
`diff --git a/file-${fileIndex}.ts b/file-${fileIndex}.ts`,
|
|
119
|
+
`--- a/file-${fileIndex}.ts`,
|
|
120
|
+
`+++ b/file-${fileIndex}.ts`,
|
|
121
|
+
"@@ -0,0 +1,10 @@",
|
|
122
|
+
...Array.from(
|
|
123
|
+
{ length: 10 },
|
|
124
|
+
(_, lineIndex) => `+line ${lineIndex} ${"dense source text ".repeat(40)}`,
|
|
125
|
+
),
|
|
126
|
+
]).flat();
|
|
127
|
+
const large = server.core.sessionCreate({
|
|
128
|
+
workspace: { repoRoot: repo, branch: "main" },
|
|
129
|
+
artifact: {
|
|
130
|
+
type: "diff",
|
|
131
|
+
content: [...filePatches, ""].join("\n"),
|
|
132
|
+
meta: { title: "large changes" },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const setup = await renderReadyApp(<App home={home} sessionId={large.id} />, {
|
|
136
|
+
width: 160,
|
|
137
|
+
height: 20,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await waitForText(setup, "file-0.ts");
|
|
141
|
+
for (const [index, direction] of Array.from({ length: 500 }, () => "down" as const).entries()) {
|
|
142
|
+
// eslint-disable-next-line no-await-in-loop
|
|
143
|
+
await setup.mockMouse.scroll(75, 10, direction);
|
|
144
|
+
if (index % 25 === 0) {
|
|
145
|
+
// eslint-disable-next-line no-await-in-loop
|
|
146
|
+
await setup.waitForVisualIdle();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await setup.waitForVisualIdle();
|
|
150
|
+
expect(setup.captureCharFrame()).toContain("changes");
|
|
151
|
+
setup.renderer.destroy();
|
|
152
|
+
}, 60000);
|
|
153
|
+
|
|
116
154
|
test("pair starts focused on the Project tree beside the zoomed Changes editor", async () => {
|
|
117
155
|
const setup = await renderReadyApp(
|
|
118
156
|
<App home={home} sessionId={session.id} layout={pairLayout()} />,
|
|
@@ -198,6 +236,8 @@ describe("the four-pane workbench", () => {
|
|
|
198
236
|
expect(frame).toContain("cueloop");
|
|
199
237
|
expect(frame).toContain("Changes");
|
|
200
238
|
expect(frame).toContain("store.ts");
|
|
239
|
+
expect(frame).toContain("src/store.ts");
|
|
240
|
+
expect(frame).toContain(NERD.copy);
|
|
201
241
|
// the diff (±) toggle exists in the Project header
|
|
202
242
|
expect(diffToggleColumn(setup)).toBeGreaterThan(0);
|
|
203
243
|
});
|
|
@@ -233,6 +273,43 @@ describe("the four-pane workbench", () => {
|
|
|
233
273
|
expect(frame.split("\n")[HEADER_ROW]!).toContain("README.md");
|
|
234
274
|
});
|
|
235
275
|
|
|
276
|
+
test("project clicks reuse one preview and a double-click keeps the file open once", async () => {
|
|
277
|
+
const setup = await renderApp();
|
|
278
|
+
|
|
279
|
+
await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
|
|
280
|
+
await waitForText(setup, "README.md");
|
|
281
|
+
const readme = locateText(setup, "README.md");
|
|
282
|
+
await setup.mockMouse.click(readme.column, readme.row);
|
|
283
|
+
await waitForState(setup, () =>
|
|
284
|
+
setup.captureCharFrame().split("\n")[HEADER_ROW]!.includes("README.md"),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const folder = locateText(setup, "src");
|
|
288
|
+
await setup.mockMouse.click(folder.column, folder.row);
|
|
289
|
+
await waitForText(setup, "util.ts");
|
|
290
|
+
const util = locateText(setup, "util.ts");
|
|
291
|
+
await setup.mockMouse.click(util.column, util.row);
|
|
292
|
+
await waitForState(setup, () => {
|
|
293
|
+
const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
|
|
294
|
+
|
|
295
|
+
return header.includes("util.ts") && !header.includes("README.md");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await setup.mockMouse.doubleClick(util.column, util.row);
|
|
299
|
+
await setup.mockMouse.click(readme.column, readme.row);
|
|
300
|
+
await waitForState(setup, () => {
|
|
301
|
+
const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
|
|
302
|
+
|
|
303
|
+
return header.includes("util.ts") && header.includes("README.md");
|
|
304
|
+
});
|
|
305
|
+
await setup.mockMouse.click(util.column, util.row);
|
|
306
|
+
const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
|
|
307
|
+
|
|
308
|
+
expect(header.match(/util\.ts/g)).toHaveLength(1);
|
|
309
|
+
expect(header.match(/README\.md/g)).toHaveLength(1);
|
|
310
|
+
setup.renderer.destroy();
|
|
311
|
+
});
|
|
312
|
+
|
|
236
313
|
test("the split control offers four directions and arrow-right makes two groups", async () => {
|
|
237
314
|
const setup = await renderApp();
|
|
238
315
|
|
|
@@ -255,8 +332,15 @@ describe("the four-pane workbench", () => {
|
|
|
255
332
|
|
|
256
333
|
// the arrow keys drive the popover: right splits the group into two
|
|
257
334
|
await pressKey(setup, "ARROW_RIGHT");
|
|
258
|
-
await waitForState(setup, () =>
|
|
259
|
-
|
|
335
|
+
await waitForState(setup, () =>
|
|
336
|
+
/│ Changes\s+│.*│ README\.md/.test(setup.captureCharFrame().split("\n")[HEADER_ROW]!),
|
|
337
|
+
);
|
|
338
|
+
expect(
|
|
339
|
+
setup
|
|
340
|
+
.captureCharFrame()
|
|
341
|
+
.split("\n")
|
|
342
|
+
[HEADER_ROW]!.match(/README\.md/g),
|
|
343
|
+
).toHaveLength(1);
|
|
260
344
|
});
|
|
261
345
|
|
|
262
346
|
test("dismissing the Changes tab collapses the Changes pane but keeps the Project sidebar", async () => {
|
|
@@ -461,6 +545,28 @@ describe("the bare-launch welcome shell", () => {
|
|
|
461
545
|
expect(setup.captureCharFrame()).toContain("README.md");
|
|
462
546
|
});
|
|
463
547
|
|
|
548
|
+
test("Review shows only the skill selected in TOML", async () => {
|
|
549
|
+
writeFileSync(process.env.CUELOOP_CONFIG!, '[review]\nskill = "custom-review"\n');
|
|
550
|
+
const setup = await renderWelcome();
|
|
551
|
+
|
|
552
|
+
await setup.mockMouse.click(1, HEADER_ROW);
|
|
553
|
+
await waitForText(setup, "settings");
|
|
554
|
+
const settings = locateText(setup, "settings");
|
|
555
|
+
|
|
556
|
+
await setup.mockMouse.click(settings.column, settings.row);
|
|
557
|
+
await waitForText(setup, "General");
|
|
558
|
+
const review = locateText(setup, "Review");
|
|
559
|
+
|
|
560
|
+
await setup.mockMouse.click(review.column, review.row);
|
|
561
|
+
await waitForText(setup, "custom-review");
|
|
562
|
+
expect(setup.captureCharFrame()).not.toContain("research");
|
|
563
|
+
const skill = locateText(setup, "custom-review");
|
|
564
|
+
|
|
565
|
+
await setup.mockMouse.click(skill.column, skill.row);
|
|
566
|
+
expect(setup.captureCharFrame()).toContain("custom-review");
|
|
567
|
+
setup.renderer.destroy();
|
|
568
|
+
});
|
|
569
|
+
|
|
464
570
|
test("the first comment in the bare shell creates and persists a per-repo workbench thread", async () => {
|
|
465
571
|
const setup = await renderWelcome();
|
|
466
572
|
|
|
@@ -501,6 +607,63 @@ describe("the bare-launch welcome shell", () => {
|
|
|
501
607
|
expect(setup.captureCharFrame()).toContain("README.md");
|
|
502
608
|
});
|
|
503
609
|
|
|
610
|
+
test("a changed file tab keeps copy and expand without a collapse action", async () => {
|
|
611
|
+
const setup = await renderWelcome();
|
|
612
|
+
|
|
613
|
+
await waitForText(setup, "README.md");
|
|
614
|
+
const readme = locateText(setup, "README.md");
|
|
615
|
+
|
|
616
|
+
await setup.mockMouse.click(readme.column, readme.row);
|
|
617
|
+
await waitForText(setup, "edited in the working tree");
|
|
618
|
+
const frame = setup.captureCharFrame();
|
|
619
|
+
|
|
620
|
+
expect(frame).toContain(NERD.copy);
|
|
621
|
+
expect(frame).not.toContain("v README.md");
|
|
622
|
+
expect(frame).toContain("expand");
|
|
623
|
+
setup.renderer.destroy();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("a bare changed-file tab uses Split only when zoomed in", async () => {
|
|
627
|
+
const setup = await renderWelcome();
|
|
628
|
+
|
|
629
|
+
await waitForText(setup, "README.md");
|
|
630
|
+
const readme = locateText(setup, "README.md");
|
|
631
|
+
|
|
632
|
+
await setup.mockMouse.click(readme.column, readme.row);
|
|
633
|
+
await waitForText(setup, "edited in the working tree");
|
|
634
|
+
const pairedLine = (line: string): boolean =>
|
|
635
|
+
line.includes("Workbench Fixture") && line.includes("+ # Fixture");
|
|
636
|
+
|
|
637
|
+
expect(setup.captureCharFrame().split("\n").some(pairedLine)).toBe(false);
|
|
638
|
+
const zoom = locateText(setup, NERD.zoom);
|
|
639
|
+
|
|
640
|
+
await setup.mockMouse.click(zoom.column, zoom.row);
|
|
641
|
+
await waitForState(setup, () => setup.captureCharFrame().split("\n").some(pairedLine));
|
|
642
|
+
expect(setup.captureCharFrame().split("\n").some(pairedLine)).toBe(true);
|
|
643
|
+
setup.renderer.destroy();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("reopening Changes after dismissing Welcome keeps file actions", async () => {
|
|
647
|
+
const setup = await renderWelcome();
|
|
648
|
+
const welcome = locateText(setup, "Welcome");
|
|
649
|
+
|
|
650
|
+
await setup.mockMouse.click(welcome.column + 8, welcome.row);
|
|
651
|
+
await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
|
|
652
|
+
await waitForText(setup, "edited in the working tree");
|
|
653
|
+
const frame = setup.captureCharFrame();
|
|
654
|
+
|
|
655
|
+
expect(frame).toContain("README.md");
|
|
656
|
+
expect(frame).toContain(NERD.copy);
|
|
657
|
+
const title = locateText(setup, "README.md");
|
|
658
|
+
|
|
659
|
+
await setup.mockMouse.click(title.column, title.row);
|
|
660
|
+
await waitForTextGone(setup, "edited in the working tree");
|
|
661
|
+
expect(setup.captureCharFrame()).toContain("README.md");
|
|
662
|
+
await setup.mockMouse.click(title.column, title.row);
|
|
663
|
+
await waitForText(setup, "edited in the working tree");
|
|
664
|
+
setup.renderer.destroy();
|
|
665
|
+
});
|
|
666
|
+
|
|
504
667
|
test("the changes tree clears after the launch repo becomes clean", async () => {
|
|
505
668
|
const setup = await renderWelcome();
|
|
506
669
|
|
|
@@ -766,6 +929,49 @@ describe("the bare-launch welcome shell", () => {
|
|
|
766
929
|
await waitForState(setup, () => diffToggleColumn(setup) === -1);
|
|
767
930
|
});
|
|
768
931
|
|
|
932
|
+
test("the changes sidebar label opens one Changes tab beside Welcome and reactivates it", async () => {
|
|
933
|
+
const setup = await renderWelcome();
|
|
934
|
+
|
|
935
|
+
await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
|
|
936
|
+
await waitForText(setup, "edited in the working tree");
|
|
937
|
+
const firstHeader = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
|
|
938
|
+
|
|
939
|
+
expect(firstHeader).toContain("Welcome");
|
|
940
|
+
expect(firstHeader).toContain("Changes");
|
|
941
|
+
|
|
942
|
+
const welcome = locateText(setup, "Welcome");
|
|
943
|
+
|
|
944
|
+
await setup.mockMouse.click(welcome.column, welcome.row);
|
|
945
|
+
await waitForText(setup, "Getting started");
|
|
946
|
+
await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
|
|
947
|
+
await waitForText(setup, "edited in the working tree");
|
|
948
|
+
expect(
|
|
949
|
+
setup
|
|
950
|
+
.captureCharFrame()
|
|
951
|
+
.split("\n")
|
|
952
|
+
[HEADER_ROW]!.match(/Changes/g),
|
|
953
|
+
).toHaveLength(1);
|
|
954
|
+
setup.renderer.destroy();
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("the changes sidebar label activates Changes beside an open file tab", async () => {
|
|
958
|
+
const setup = await renderWelcome();
|
|
959
|
+
|
|
960
|
+
await waitForText(setup, "README.md");
|
|
961
|
+
const file = locateText(setup, "README.md");
|
|
962
|
+
|
|
963
|
+
await setup.mockMouse.click(file.column, file.row);
|
|
964
|
+
await waitForText(setup, "README.md");
|
|
965
|
+
await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
|
|
966
|
+
await waitForText(setup, "edited in the working tree");
|
|
967
|
+
const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
|
|
968
|
+
|
|
969
|
+
expect(header).toContain("Welcome");
|
|
970
|
+
expect(header).toContain("README.md");
|
|
971
|
+
expect(header).toContain("Changes");
|
|
972
|
+
setup.renderer.destroy();
|
|
973
|
+
});
|
|
974
|
+
|
|
769
975
|
test("a closed region draws its reopen divider in the header only, not full-height", async () => {
|
|
770
976
|
const setup = await renderWelcome();
|
|
771
977
|
|