@cueloop/client 0.1.0-alpha.90 → 0.1.0-alpha.92
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.tsx +7 -3
- package/src/App.workbench.test.tsx +14 -1
- package/src/components/AnnotatableFileView.test.tsx +48 -1
- package/src/diff-syntax.test.ts +15 -2
- package/src/diff-syntax.ts +13 -5
- package/src/index.ts +1 -0
- package/src/launch-layout.test.ts +11 -0
- package/src/launch-layout.ts +15 -0
- package/src/use-changes-workbench.ts +2 -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.92",
|
|
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.92",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.92",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.92",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.92",
|
|
34
34
|
"@opentui/core": "^0.5.11",
|
|
35
35
|
"@opentui/keymap": "0.5.11",
|
|
36
36
|
"@opentui/react": "^0.5.11",
|
package/src/App.tsx
CHANGED
|
@@ -413,10 +413,14 @@ function initialThreadsOpen(
|
|
|
413
413
|
return layout ? layout.threads : sessionId === undefined;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
-
function initialFocusedPane(
|
|
416
|
+
function initialFocusedPane(
|
|
417
|
+
sessionId: string | undefined,
|
|
418
|
+
zoomed: boolean,
|
|
419
|
+
layout?: LaunchLayout,
|
|
420
|
+
): FocusPane {
|
|
417
421
|
if (sessionId === undefined) return "threads";
|
|
418
422
|
|
|
419
|
-
return zoomed ? "changes" : "thread";
|
|
423
|
+
return layout?.focusPane ?? (zoomed ? "changes" : "thread");
|
|
420
424
|
}
|
|
421
425
|
|
|
422
426
|
function bareShellDefersKeys(
|
|
@@ -609,7 +613,7 @@ export function App({
|
|
|
609
613
|
// the Changes + Project right region and its editor grid (tabs, splits, zoom)
|
|
610
614
|
const workbench = useChangesWorkbench({ layout });
|
|
611
615
|
const [focusedPane, setFocusedPane] = useState<FocusPane>(() =>
|
|
612
|
-
initialFocusedPane(sessionId, workbench.zoomed),
|
|
616
|
+
initialFocusedPane(sessionId, workbench.zoomed, layout),
|
|
613
617
|
);
|
|
614
618
|
// only the session view persists from here; the bare shell owns its own (NoThreadShell), so this
|
|
615
619
|
// workbench stays inactive with no session and never clobbers what the shell saved
|
|
@@ -16,7 +16,7 @@ import { DaemonServer } from "@cueloop/daemon";
|
|
|
16
16
|
import type { Thread } from "@cueloop/schema";
|
|
17
17
|
import { App } from "./App";
|
|
18
18
|
import { loadConfig } from "./config";
|
|
19
|
-
import { planLayout, reviewLayout } from "./launch-layout";
|
|
19
|
+
import { pairLayout, planLayout, reviewLayout } from "./launch-layout";
|
|
20
20
|
import {
|
|
21
21
|
dragText,
|
|
22
22
|
isolateUserConfig,
|
|
@@ -113,6 +113,19 @@ 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("pair starts focused on the Project tree beside the zoomed Changes editor", async () => {
|
|
117
|
+
const setup = await renderReadyApp(
|
|
118
|
+
<App home={home} sessionId={session.id} layout={pairLayout()} />,
|
|
119
|
+
{ width: 160, height: 20 },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
await waitForText(setup, "README.md");
|
|
123
|
+
expect(setup.captureCharFrame()).toContain("Changes");
|
|
124
|
+
await pressKey(setup, "RETURN");
|
|
125
|
+
await waitForText(setup, "Workbench Fixture");
|
|
126
|
+
setup.renderer.destroy();
|
|
127
|
+
});
|
|
128
|
+
|
|
116
129
|
test("a live thread's Changes tree follows edits and a clean working tree", async () => {
|
|
117
130
|
const liveSession = server.core.sessionCreate({
|
|
118
131
|
workspace: { repoRoot: repo, branch: "main" },
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import type { RGBA } from "@opentui/core";
|
|
7
8
|
import { testRender } from "@opentui/react/test-utils";
|
|
8
9
|
import React from "react";
|
|
9
10
|
import { SCHEMA_VERSION, type Anchor, type Thread } from "@cueloop/schema";
|
|
10
11
|
import { AnnotatableFileView } from "./AnnotatableFileView";
|
|
11
12
|
import { DARK } from "../theme";
|
|
12
|
-
import { dragText, typeText, pressKey, waitForText } from "../test-support";
|
|
13
|
+
import { allowEventLoopUpdates, dragText, typeText, pressKey, waitForText } from "../test-support";
|
|
13
14
|
|
|
14
15
|
const SAMPLE = "export function add(a: number, b: number) {\n return a + b;\n}\n";
|
|
15
16
|
|
|
@@ -29,7 +30,53 @@ function planSession(): Thread {
|
|
|
29
30
|
|
|
30
31
|
const noop = (): void => {};
|
|
31
32
|
|
|
33
|
+
function colorHex(color: RGBA): string {
|
|
34
|
+
const [red, green, blue] = color.toInts();
|
|
35
|
+
|
|
36
|
+
return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
describe("AnnotatableFileView", () => {
|
|
40
|
+
for (const { path, source, keyword } of [
|
|
41
|
+
{ path: "src/add.ts", source: "export const add = 1;", keyword: "export" },
|
|
42
|
+
{ path: "src/add.js", source: "const add = 1;", keyword: "const" },
|
|
43
|
+
]) {
|
|
44
|
+
test(`highlights ${path} by its detected language`, async () => {
|
|
45
|
+
allowEventLoopUpdates();
|
|
46
|
+
const setup = await testRender(
|
|
47
|
+
<AnnotatableFileView
|
|
48
|
+
path={path}
|
|
49
|
+
loadContents={() => Promise.resolve(source)}
|
|
50
|
+
session={planSession()}
|
|
51
|
+
quickActions={[]}
|
|
52
|
+
observer={false}
|
|
53
|
+
onAddComment={noop}
|
|
54
|
+
onReply={noop}
|
|
55
|
+
onUpdateAnnotation={noop}
|
|
56
|
+
onExit={noop}
|
|
57
|
+
theme={DARK}
|
|
58
|
+
/>,
|
|
59
|
+
{ width: 64, height: 12 },
|
|
60
|
+
);
|
|
61
|
+
let highlighted = false;
|
|
62
|
+
|
|
63
|
+
for (let attempt = 0; attempt < 60 && !highlighted; attempt++) {
|
|
64
|
+
await setup.renderOnce();
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
66
|
+
highlighted = setup.captureSpans().lines.some((line) =>
|
|
67
|
+
line.spans
|
|
68
|
+
.filter((span) => colorHex(span.fg) === DARK.accent)
|
|
69
|
+
.map((span) => span.text)
|
|
70
|
+
.join("")
|
|
71
|
+
.includes(keyword),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
expect(highlighted).toBe(true);
|
|
76
|
+
setup.renderer.destroy();
|
|
77
|
+
}, 25000);
|
|
78
|
+
}
|
|
79
|
+
|
|
33
80
|
test("commenting a file line hands up an anchor that quotes the line", async () => {
|
|
34
81
|
const onAddComment = mock((_anchor: Anchor, _body: string) => {});
|
|
35
82
|
const setup = await testRender(
|
package/src/diff-syntax.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { highlightJobs, spansByLine } from "./diff-syntax";
|
|
3
|
-
import type
|
|
3
|
+
import { fileContentsRows, type DiffRow } from "./view-diff";
|
|
4
4
|
import type { SimpleHighlight } from "@opentui/core";
|
|
5
5
|
|
|
6
6
|
function row(kind: DiffRow["kind"], text: string, file = "src/x.ts"): DiffRow {
|
|
@@ -74,6 +74,19 @@ describe("spansByLine", () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
describe("highlightJobs", () => {
|
|
77
|
+
for (const { path, filetype } of [
|
|
78
|
+
{ path: "src/add.ts", filetype: "typescript" },
|
|
79
|
+
{ path: "src/add.py", filetype: "python" },
|
|
80
|
+
{ path: "config.json", filetype: "json" },
|
|
81
|
+
]) {
|
|
82
|
+
test(`detects ${filetype} in a plain project file without a diff header`, () => {
|
|
83
|
+
const source = "first line\nsecond line";
|
|
84
|
+
const rows = fileContentsRows(path, source);
|
|
85
|
+
|
|
86
|
+
expect(highlightJobs(rows)).toEqual([{ filetype, source, rowIndexByLine: [0, 1] }]);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
test("reconstructs old and new sides of a hunk with row indices", () => {
|
|
78
91
|
// Arrange
|
|
79
92
|
const rows = [
|
|
@@ -100,7 +113,7 @@ describe("highlightJobs", () => {
|
|
|
100
113
|
const rows = [
|
|
101
114
|
row("file", "notes/thing.unknownext", "notes/thing.unknownext"),
|
|
102
115
|
row("hunk", "@@"),
|
|
103
|
-
row("add", "whatever"),
|
|
116
|
+
row("add", "whatever", "notes/thing.unknownext"),
|
|
104
117
|
];
|
|
105
118
|
|
|
106
119
|
// Act
|
package/src/diff-syntax.ts
CHANGED
|
@@ -31,9 +31,8 @@ interface HighlightJob {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Split diff rows into per-hunk highlight jobs, one per side.
|
|
35
|
-
*
|
|
36
|
-
* header rows and files without a known filetype are skipped.
|
|
34
|
+
* Split diff rows into per-hunk highlight jobs, one per side. Plain project
|
|
35
|
+
* files contain only context rows, so detect their language from the row path.
|
|
37
36
|
*/
|
|
38
37
|
export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
39
38
|
const jobs: HighlightJob[] = [];
|
|
@@ -42,10 +41,12 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
|
42
41
|
let oldRowIndexByLine: number[] = [];
|
|
43
42
|
let newRowIndexByLine: number[] = [];
|
|
44
43
|
let filetype: string | undefined;
|
|
44
|
+
let path: string | undefined;
|
|
45
|
+
let hasChange = false;
|
|
45
46
|
|
|
46
47
|
const flush = (): void => {
|
|
47
48
|
if (filetype) {
|
|
48
|
-
if (oldLines.length)
|
|
49
|
+
if (oldLines.length && (hasChange || newLines.length === 0))
|
|
49
50
|
jobs.push({ filetype, source: oldLines.join("\n"), rowIndexByLine: oldRowIndexByLine });
|
|
50
51
|
if (newLines.length)
|
|
51
52
|
jobs.push({ filetype, source: newLines.join("\n"), rowIndexByLine: newRowIndexByLine });
|
|
@@ -54,14 +55,20 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
|
54
55
|
newLines = [];
|
|
55
56
|
oldRowIndexByLine = [];
|
|
56
57
|
newRowIndexByLine = [];
|
|
58
|
+
hasChange = false;
|
|
57
59
|
};
|
|
58
60
|
|
|
59
61
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
60
62
|
const row = rows[rowIndex]!;
|
|
61
63
|
|
|
64
|
+
if (row.file !== path) {
|
|
65
|
+
flush();
|
|
66
|
+
path = row.file;
|
|
67
|
+
filetype = filetypeForPath(path);
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
if (row.kind === "file" || row.kind === "hunk") {
|
|
63
71
|
flush();
|
|
64
|
-
filetype = row.kind === "file" ? filetypeForPath(row.file) : filetype;
|
|
65
72
|
continue;
|
|
66
73
|
}
|
|
67
74
|
const text = stripTrailingNewline(row.text);
|
|
@@ -74,6 +81,7 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
|
|
|
74
81
|
newLines.push(text);
|
|
75
82
|
newRowIndexByLine.push(rowIndex);
|
|
76
83
|
}
|
|
84
|
+
if (row.kind === "add" || row.kind === "del") hasChange = true;
|
|
77
85
|
}
|
|
78
86
|
flush();
|
|
79
87
|
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
defaultLayout,
|
|
4
4
|
layoutFromPanes,
|
|
5
|
+
pairLayout,
|
|
5
6
|
planLayout,
|
|
6
7
|
pullRequestReviewLayout,
|
|
7
8
|
reviewLayout,
|
|
@@ -13,6 +14,16 @@ describe("launch layout factories", () => {
|
|
|
13
14
|
expect(reviewLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
|
|
14
15
|
});
|
|
15
16
|
|
|
17
|
+
test("pair opens the Project tree focused beside a zoomed Changes editor", () => {
|
|
18
|
+
expect(pairLayout()).toEqual({
|
|
19
|
+
threads: true,
|
|
20
|
+
rightSidebar: "changes",
|
|
21
|
+
zoomChanges: true,
|
|
22
|
+
projectMode: "tree",
|
|
23
|
+
focusPane: "project",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
16
27
|
test("pull request review keeps its brief beside the Changes panel", () => {
|
|
17
28
|
// Assert
|
|
18
29
|
expect(pullRequestReviewLayout()).toEqual({
|
package/src/launch-layout.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface LaunchLayout {
|
|
|
12
12
|
rightSidebar: "changes" | "project" | "off";
|
|
13
13
|
/** The Changes editor is zoomed - the Thread pane hides so the diff fills the middle. */
|
|
14
14
|
zoomChanges: boolean;
|
|
15
|
+
/** Which Project sidebar tab opens while the Changes editor stays visible. */
|
|
16
|
+
projectMode?: "changes" | "tree";
|
|
17
|
+
/** Keyboard focus when opening a selected Thread. */
|
|
18
|
+
focusPane?: "threads" | "thread" | "changes" | "project";
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/** `cueloop diff`: the diff front and center. */
|
|
@@ -19,6 +23,17 @@ export function reviewLayout(): LaunchLayout {
|
|
|
19
23
|
return { threads: true, rightSidebar: "changes", zoomChanges: true };
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
/** `cueloop pair`: browse project files beside the zoomed Changes editor. */
|
|
27
|
+
export function pairLayout(): LaunchLayout {
|
|
28
|
+
return {
|
|
29
|
+
threads: true,
|
|
30
|
+
rightSidebar: "changes",
|
|
31
|
+
zoomChanges: true,
|
|
32
|
+
projectMode: "tree",
|
|
33
|
+
focusPane: "project",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
/** `cueloop review <pr>`: PR context beside the Changes panel. */
|
|
23
38
|
export function pullRequestReviewLayout(): LaunchLayout {
|
|
24
39
|
return { threads: true, rightSidebar: "changes", zoomChanges: false };
|
|
@@ -44,7 +44,7 @@ function initialPanes(options?: ChangesWorkbenchOptions): InitialPanes {
|
|
|
44
44
|
return {
|
|
45
45
|
projectOpen: layout.rightSidebar !== "off",
|
|
46
46
|
changesOpen,
|
|
47
|
-
projectMode: layout.rightSidebar === "project" ? "tree" : "changes",
|
|
47
|
+
projectMode: layout.projectMode ?? (layout.rightSidebar === "project" ? "tree" : "changes"),
|
|
48
48
|
// zoom fills the middle with the Changes editor, so a hand-edited layout that zooms without one
|
|
49
49
|
// would strand a blank center with the Thread pane hidden; only honor zoom when Changes is open
|
|
50
50
|
zoomed: layout.zoomChanges && changesOpen,
|
|
@@ -99,7 +99,7 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
|
|
|
99
99
|
seenSession.current = sessionId;
|
|
100
100
|
setProjectOpen(isDiff);
|
|
101
101
|
setChangesOpen(isDiff);
|
|
102
|
-
setProjectMode(isDiff ? "changes" : "tree");
|
|
102
|
+
setProjectMode(isDiff ? (options?.layout?.projectMode ?? "changes") : "tree");
|
|
103
103
|
rememberedChanges.current = isDiff;
|
|
104
104
|
// a non-diff thread has no Changes editor; leaving zoom on would strand the hidden Thread pane
|
|
105
105
|
if (!isDiff) setZoomed(false);
|