@cueloop/client 0.1.0-alpha.89 → 0.1.0-alpha.90
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.diff.test.tsx +19 -0
- package/src/App.prototype-markdown.test.tsx +36 -0
- package/src/App.tsx +72 -44
- package/src/App.workbench.test.tsx +73 -0
- package/src/app-screens.tsx +24 -20
- package/src/app-view-model.ts +6 -1
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.tsx +67 -34
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/ThreadView.tsx +57 -5
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +26 -0
- package/src/intent-dispatch.test.ts +1 -1
- package/src/run.ts +10 -0
- package/src/serve.ts +1 -1
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.ts +23 -8
- package/src/use-repo-changes.ts +87 -0
- package/src/view-plan.test.ts +24 -0
- package/src/view-plan.ts +26 -0
- package/src/workbench-snapshot.test.ts +18 -0
- package/src/workbench-snapshot.ts +35 -5
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* dismisses · tab folds · cmd+[ / cmd+] cycle discussions · blur-save on click.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import React, { useEffect, useRef } from "react";
|
|
15
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
16
16
|
import { useTerminalDimensions } from "@opentui/react";
|
|
17
17
|
import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
|
|
18
18
|
import type { Annotation, Thread } from "@cueloop/schema";
|
|
@@ -40,12 +40,30 @@ import { useAnnotationSurface, type LineSource } from "../use-annotation-surface
|
|
|
40
40
|
import { NavModeHint } from "./NavModeHint";
|
|
41
41
|
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
42
42
|
import { useComponentTheme } from "./theme-context";
|
|
43
|
+
import { colorForSyntaxGroup } from "./syntax-highlight";
|
|
44
|
+
import { highlightThreadCodeBlocks, type SyntaxSpan } from "../thread-code-syntax";
|
|
43
45
|
|
|
44
46
|
/** Blocks kept mounted beyond the viewport, so a scroll step or a drag past the edge never shows a gap. */
|
|
45
47
|
const OVERSCAN_BLOCKS = 8;
|
|
46
48
|
|
|
47
49
|
export { lighten } from "../annotation-palette";
|
|
48
50
|
|
|
51
|
+
interface CodeSyntaxState {
|
|
52
|
+
display: DisplayBlock[];
|
|
53
|
+
byBlock: Map<number, SyntaxSpan[]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function syntaxSpansForBlock(
|
|
57
|
+
block: DisplayBlock,
|
|
58
|
+
blockIndex: number,
|
|
59
|
+
display: DisplayBlock[],
|
|
60
|
+
codeSyntax: CodeSyntaxState | null,
|
|
61
|
+
): readonly SyntaxSpan[] {
|
|
62
|
+
if (block.type === "del" || codeSyntax?.display !== display) return [];
|
|
63
|
+
|
|
64
|
+
return codeSyntax.byBlock.get(blockIndex) ?? [];
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
/**
|
|
50
68
|
* How a block's rows are painted: heading weight, muted kinds, and the list or
|
|
51
69
|
* quote marker. A cut block reads dim and struck through; other edits are
|
|
@@ -222,6 +240,24 @@ export function ThreadView({
|
|
|
222
240
|
(left, right) => left === right,
|
|
223
241
|
0,
|
|
224
242
|
);
|
|
243
|
+
const [codeSyntax, setCodeSyntax] = useState<CodeSyntaxState | null>(null);
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
let active = true;
|
|
247
|
+
|
|
248
|
+
void highlightThreadCodeBlocks(display).then(
|
|
249
|
+
(byBlock) => {
|
|
250
|
+
if (active) setCodeSyntax({ display, byBlock });
|
|
251
|
+
},
|
|
252
|
+
(error) => {
|
|
253
|
+
if (active) console.error("thread code syntax highlighting failed", error);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return () => {
|
|
258
|
+
active = false;
|
|
259
|
+
};
|
|
260
|
+
}, [display]);
|
|
225
261
|
|
|
226
262
|
// only the viewport's blocks (plus an overscan) mount; a block is estimated at its wrapped-line
|
|
227
263
|
// count until its box, cards included, is measured. The initial viewport seeds the first render so a
|
|
@@ -257,6 +293,7 @@ export function ThreadView({
|
|
|
257
293
|
marker: string;
|
|
258
294
|
baseFg: string;
|
|
259
295
|
baseAttributes: number;
|
|
296
|
+
syntaxSpans: readonly SyntaxSpan[];
|
|
260
297
|
}
|
|
261
298
|
|
|
262
299
|
const paintedSpan = (
|
|
@@ -267,7 +304,11 @@ export function ThreadView({
|
|
|
267
304
|
) => (
|
|
268
305
|
<span
|
|
269
306
|
key={runIndex}
|
|
270
|
-
fg={
|
|
307
|
+
fg={
|
|
308
|
+
run.role === "plain" && run.syntaxGroup
|
|
309
|
+
? (colorForSyntaxGroup(run.syntaxGroup, tokens) ?? baseFg)
|
|
310
|
+
: roleForeground(run.role, baseFg, tokens)
|
|
311
|
+
}
|
|
271
312
|
bg={run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined}
|
|
272
313
|
attributes={roleAttributes(run.role, baseAttributes) | (run.marked ? UNDERLINE : 0)}
|
|
273
314
|
>
|
|
@@ -276,8 +317,17 @@ export function ThreadView({
|
|
|
276
317
|
);
|
|
277
318
|
|
|
278
319
|
const lineRowFor = (context: LineContext): React.ReactNode => {
|
|
279
|
-
const {
|
|
280
|
-
|
|
320
|
+
const {
|
|
321
|
+
blockIndex,
|
|
322
|
+
roleRuns,
|
|
323
|
+
line,
|
|
324
|
+
lineIndex,
|
|
325
|
+
ranges,
|
|
326
|
+
marker,
|
|
327
|
+
baseFg,
|
|
328
|
+
baseAttributes,
|
|
329
|
+
syntaxSpans,
|
|
330
|
+
} = context;
|
|
281
331
|
|
|
282
332
|
return (
|
|
283
333
|
<box key={`line-${lineIndex}`} style={{ flexDirection: "row" }}>
|
|
@@ -291,7 +341,7 @@ export function ThreadView({
|
|
|
291
341
|
ref={surface.registerLine(blockIndex, lineIndex, line)}
|
|
292
342
|
onMouseDown={surface.onLineMouseDown}
|
|
293
343
|
>
|
|
294
|
-
{styledRunsFor(roleRuns, line, ranges).map((run, runIndex) =>
|
|
344
|
+
{styledRunsFor(roleRuns, line, ranges, syntaxSpans).map((run, runIndex) =>
|
|
295
345
|
paintedSpan(run, runIndex, baseFg, baseAttributes),
|
|
296
346
|
)}
|
|
297
347
|
</text>
|
|
@@ -365,6 +415,7 @@ export function ThreadView({
|
|
|
365
415
|
const roleRuns = renderedStyleRuns(block);
|
|
366
416
|
const ranges = surface.rangesFor(blockIndex);
|
|
367
417
|
const lines = wrapLines(renderedText(block), viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
|
|
418
|
+
const syntaxSpans = syntaxSpansForBlock(block, blockIndex, display, codeSyntax);
|
|
368
419
|
const lineRows: React.ReactNode[] = [];
|
|
369
420
|
|
|
370
421
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
@@ -381,6 +432,7 @@ export function ThreadView({
|
|
|
381
432
|
marker,
|
|
382
433
|
baseFg,
|
|
383
434
|
baseAttributes,
|
|
435
|
+
syntaxSpans,
|
|
384
436
|
}),
|
|
385
437
|
);
|
|
386
438
|
if (isLastLine) lineRows.push(headingRule(block));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ClientExtensionRegistry } from "../client-extension-registry";
|
|
3
|
+
import { DARK } from "../theme";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
import { WorkspacePanels } from "./WorkspacePanels";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Extensions/WorkspacePanels" };
|
|
8
|
+
|
|
9
|
+
const registry = new ClientExtensionRegistry();
|
|
10
|
+
|
|
11
|
+
void registry.load("example", (api) => {
|
|
12
|
+
api.registerView({
|
|
13
|
+
id: "details",
|
|
14
|
+
zone: "workspace.panels",
|
|
15
|
+
title: "Details",
|
|
16
|
+
Component: () => <text>Extension details</text>,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const RegisteredView: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<WorkspacePanels
|
|
23
|
+
width={32}
|
|
24
|
+
projectMode="tree"
|
|
25
|
+
projectPanel={<text>Project files</text>}
|
|
26
|
+
onToggleChanges={() => {}}
|
|
27
|
+
onToggleProject={() => {}}
|
|
28
|
+
onToggleRight={() => {}}
|
|
29
|
+
onFocus={() => {}}
|
|
30
|
+
registry={registry}
|
|
31
|
+
context={{ workspace: "/repo", threadId: "thread-1" }}
|
|
32
|
+
theme={DARK}
|
|
33
|
+
/>
|
|
34
|
+
),
|
|
35
|
+
size: { width: 32, height: 10 },
|
|
36
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
import type { ExtensionUIContext } from "@cueloop/extension-api/client";
|
|
4
|
+
import type { ClientExtensionRegistry } from "../client-extension-registry";
|
|
5
|
+
import type { Theme } from "../theme";
|
|
6
|
+
import type { ProjectPanelMode } from "./AppShell";
|
|
7
|
+
import { ExtensionZone, useClientExtensionSnapshot, visibleContributions } from "./ExtensionZone";
|
|
8
|
+
import { FileTab, PanelColumn } from "./PanelColumn";
|
|
9
|
+
import { IconButton } from "./primitives/IconButton";
|
|
10
|
+
import { NERD } from "./primitives/icons";
|
|
11
|
+
|
|
12
|
+
/** The existing right pane owns built-in modes and selectable extension views. */
|
|
13
|
+
export function WorkspacePanels(props: {
|
|
14
|
+
width: number;
|
|
15
|
+
projectMode: ProjectPanelMode;
|
|
16
|
+
projectPanel: React.ReactNode;
|
|
17
|
+
onToggleChanges: () => void;
|
|
18
|
+
onToggleProject: () => void;
|
|
19
|
+
onToggleRight: () => void;
|
|
20
|
+
onFocus: () => void;
|
|
21
|
+
focused?: boolean;
|
|
22
|
+
registry: ClientExtensionRegistry;
|
|
23
|
+
context: ExtensionUIContext;
|
|
24
|
+
theme: Theme;
|
|
25
|
+
onExtensionError?: (message: string) => void;
|
|
26
|
+
}): React.ReactNode {
|
|
27
|
+
const { registry, context, theme, onExtensionError } = props;
|
|
28
|
+
const snapshot = useClientExtensionSnapshot(registry);
|
|
29
|
+
const { workspace, threadId } = context;
|
|
30
|
+
const visibility = useMemo(
|
|
31
|
+
() => visibleContributions(snapshot.views, { workspace, threadId }),
|
|
32
|
+
[snapshot.views, workspace, threadId],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
for (const error of visibility.errors) onExtensionError?.(error);
|
|
37
|
+
}, [visibility, onExtensionError]);
|
|
38
|
+
|
|
39
|
+
const views = visibility.items;
|
|
40
|
+
const [selectedView, setSelectedView] = useState<(typeof views)[number] | null>(null);
|
|
41
|
+
const selected = views.find(
|
|
42
|
+
({ key, value }) => key === selectedView?.key && value === selectedView.value,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const showBuiltIn = (toggle: () => void): void => {
|
|
46
|
+
setSelectedView(null);
|
|
47
|
+
toggle();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
useKeyboard((key) => {
|
|
51
|
+
if (!props.focused || (key.name !== "[" && key.name !== "]")) return;
|
|
52
|
+
const choices = [null, ...views];
|
|
53
|
+
const current = selected ? views.indexOf(selected) + 1 : 0;
|
|
54
|
+
const step = key.name === "]" ? 1 : -1;
|
|
55
|
+
|
|
56
|
+
setSelectedView(choices[(current + step + choices.length) % choices.length] ?? null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const toggles = (
|
|
60
|
+
<box style={{ flexDirection: "row", flexShrink: 0, alignItems: "center" }}>
|
|
61
|
+
<IconButton
|
|
62
|
+
glyph="changes"
|
|
63
|
+
active={!selected && props.projectMode === "changes"}
|
|
64
|
+
onPress={() => showBuiltIn(props.onToggleChanges)}
|
|
65
|
+
marginRight={2}
|
|
66
|
+
theme={theme}
|
|
67
|
+
/>
|
|
68
|
+
<IconButton
|
|
69
|
+
glyph="project"
|
|
70
|
+
active={!selected && props.projectMode === "tree"}
|
|
71
|
+
onPress={() => showBuiltIn(props.onToggleProject)}
|
|
72
|
+
marginRight={2}
|
|
73
|
+
theme={theme}
|
|
74
|
+
/>
|
|
75
|
+
<IconButton
|
|
76
|
+
glyph={NERD.sidebarRight}
|
|
77
|
+
onPress={props.onToggleRight}
|
|
78
|
+
tip="Toggle Sidebar"
|
|
79
|
+
theme={theme}
|
|
80
|
+
/>
|
|
81
|
+
</box>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<PanelColumn
|
|
86
|
+
width={props.width}
|
|
87
|
+
border="left"
|
|
88
|
+
header={
|
|
89
|
+
views.length > 0 ? (
|
|
90
|
+
<box style={{ flexDirection: "row", minWidth: 0 }}>
|
|
91
|
+
{views.map(({ key, value }) => (
|
|
92
|
+
<box key={key} onMouseUp={() => setSelectedView({ key, value })}>
|
|
93
|
+
<FileTab label={value.title} active={key === selected?.key} theme={theme} />
|
|
94
|
+
</box>
|
|
95
|
+
))}
|
|
96
|
+
</box>
|
|
97
|
+
) : null
|
|
98
|
+
}
|
|
99
|
+
headerRight={toggles}
|
|
100
|
+
onFocus={props.onFocus}
|
|
101
|
+
theme={theme}
|
|
102
|
+
>
|
|
103
|
+
{selected ? (
|
|
104
|
+
<ExtensionZone
|
|
105
|
+
zone="workspace.panels"
|
|
106
|
+
registry={registry}
|
|
107
|
+
context={context}
|
|
108
|
+
selectedViewId={selected.key}
|
|
109
|
+
onError={props.onExtensionError}
|
|
110
|
+
/>
|
|
111
|
+
) : (
|
|
112
|
+
props.projectPanel
|
|
113
|
+
)}
|
|
114
|
+
</PanelColumn>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -591,6 +591,18 @@ exports[`stories catalog Surfaces/EditorGrid/SplitGroups renders, snapshots, and
|
|
|
591
591
|
│
|
|
592
592
|
│
|
|
593
593
|
│
|
|
594
|
+
"
|
|
595
|
+
`;
|
|
596
|
+
|
|
597
|
+
exports[`stories catalog Extensions/ExtensionZone/SidebarSection renders, snapshots, and carries its colors: Extensions/ExtensionZone/SidebarSection 1`] = `
|
|
598
|
+
"Summary
|
|
599
|
+
Two pending Threads
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
|
|
594
606
|
"
|
|
595
607
|
`;
|
|
596
608
|
|
|
@@ -1593,6 +1605,20 @@ exports[`stories catalog Surfaces/WelcomePlayground/Welcome renders, snapshots,
|
|
|
1593
1605
|
17 - docs https://cueloop.dev/docs/
|
|
1594
1606
|
18
|
|
1595
1607
|
type to comment · esc for nav mode
|
|
1608
|
+
"
|
|
1609
|
+
`;
|
|
1610
|
+
|
|
1611
|
+
exports[`stories catalog Extensions/WorkspacePanels/RegisteredView renders, snapshots, and carries its colors: Extensions/WorkspacePanels/RegisteredView 1`] = `
|
|
1612
|
+
"│ Details changes project
|
|
1613
|
+
│‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
|
|
1614
|
+
│Project files
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
|
|
1596
1622
|
"
|
|
1597
1623
|
`;
|
|
1598
1624
|
|
|
@@ -82,7 +82,7 @@ function baseController(): ReviewController {
|
|
|
82
82
|
repoFiles: mock(() => Promise.resolve<string[]>([])),
|
|
83
83
|
repoReadFile: mock(() => Promise.resolve<string | null>(null)),
|
|
84
84
|
repoChanges: mock(() => Promise.resolve([])),
|
|
85
|
-
|
|
85
|
+
refreshDiff: mock(() => Promise.resolve()),
|
|
86
86
|
open: mock(),
|
|
87
87
|
deleteSession: mock(),
|
|
88
88
|
renameSession: mock(),
|
package/src/run.ts
CHANGED
|
@@ -10,6 +10,10 @@ import { loadConfig } from "./config";
|
|
|
10
10
|
import { perfMark } from "./perf/perf-timings";
|
|
11
11
|
import { reportPerfMarks } from "./perf/perf-report";
|
|
12
12
|
import { defaultLayout, type LaunchLayout } from "./launch-layout";
|
|
13
|
+
import {
|
|
14
|
+
ClientExtensionRegistry,
|
|
15
|
+
loadInstalledClientExtensions,
|
|
16
|
+
} from "./client-extension-registry";
|
|
13
17
|
|
|
14
18
|
export interface RunClientOptions {
|
|
15
19
|
sessionId?: string;
|
|
@@ -31,6 +35,11 @@ export async function runClient(options: RunClientOptions): Promise<number> {
|
|
|
31
35
|
options.layout ??
|
|
32
36
|
(options.sessionId === undefined ? (loadConfig().ui.layout ?? defaultLayout()) : undefined);
|
|
33
37
|
const renderer = await createCliRenderer({ enableMouseMovement: true });
|
|
38
|
+
const extensionRegistry = new ClientExtensionRegistry();
|
|
39
|
+
|
|
40
|
+
void loadInstalledClientExtensions(extensionRegistry).catch((error) => {
|
|
41
|
+
extensionRegistry.reportError(`Client extension discovery: ${String(error)}`);
|
|
42
|
+
});
|
|
34
43
|
// a full screen of measured elements each holds a frame listener; lift the default-10 ceiling so a
|
|
35
44
|
// busy view does not trip a false leak warning, while a runaway subscription still would
|
|
36
45
|
renderer.setMaxListeners(64);
|
|
@@ -66,6 +75,7 @@ export async function runClient(options: RunClientOptions): Promise<number> {
|
|
|
66
75
|
sessionId: options.sessionId,
|
|
67
76
|
appearance,
|
|
68
77
|
layout,
|
|
78
|
+
extensionRegistry,
|
|
69
79
|
onExit: shutdown,
|
|
70
80
|
onReady: () => {
|
|
71
81
|
perfMark("firstFrame");
|
package/src/serve.ts
CHANGED
|
@@ -45,7 +45,7 @@ async function captureFrozenArtifact(
|
|
|
45
45
|
|
|
46
46
|
if (thread === undefined) return undefined;
|
|
47
47
|
|
|
48
|
-
const snapshot = await snapshotWorkbench(thread, (root) => probe.repoDiff(root));
|
|
48
|
+
const snapshot = await snapshotWorkbench(thread, (root, vcs) => probe.repoDiff(root, vcs));
|
|
49
49
|
|
|
50
50
|
return snapshot === thread ? undefined : snapshot.artifact;
|
|
51
51
|
} finally {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Tree-sitter colors for fenced code blocks in a Thread, in rendered-text offsets. */
|
|
2
|
+
|
|
3
|
+
import { getTreeSitterClient } from "@opentui/core";
|
|
4
|
+
import type { DisplayBlock } from "./view-plan";
|
|
5
|
+
import { renderedText } from "./view-plan";
|
|
6
|
+
import { spansByLine, type SyntaxSpan } from "./diff-syntax";
|
|
7
|
+
import { filetypeFor } from "./components/syntax-highlight";
|
|
8
|
+
|
|
9
|
+
export type { SyntaxSpan } from "./diff-syntax";
|
|
10
|
+
|
|
11
|
+
/** Highlight whole code blocks so multi-line syntax stays intact. */
|
|
12
|
+
export async function highlightThreadCodeBlocks(
|
|
13
|
+
display: DisplayBlock[],
|
|
14
|
+
): Promise<Map<number, SyntaxSpan[]>> {
|
|
15
|
+
const jobs = display.flatMap((block, blockIndex) => {
|
|
16
|
+
if (block.kind !== "code") return [];
|
|
17
|
+
const filetype = filetypeFor(block.work?.lang ?? block.base?.lang);
|
|
18
|
+
|
|
19
|
+
return filetype ? [{ blockIndex, filetype, source: renderedText(block) }] : [];
|
|
20
|
+
});
|
|
21
|
+
const byBlock = new Map<number, SyntaxSpan[]>();
|
|
22
|
+
|
|
23
|
+
if (jobs.length === 0) return byBlock;
|
|
24
|
+
const client = getTreeSitterClient();
|
|
25
|
+
|
|
26
|
+
await client.initialize();
|
|
27
|
+
for (const job of jobs) {
|
|
28
|
+
const result = await client.highlightOnce(job.source, job.filetype);
|
|
29
|
+
|
|
30
|
+
if (!result.highlights) continue;
|
|
31
|
+
const spans: SyntaxSpan[] = [];
|
|
32
|
+
let offset = 0;
|
|
33
|
+
const lines = job.source.split("\n");
|
|
34
|
+
|
|
35
|
+
spansByLine(job.source, result.highlights).forEach((lineSpans, lineIndex) => {
|
|
36
|
+
for (const span of lineSpans) {
|
|
37
|
+
spans.push({ start: offset + span.start, end: offset + span.end, group: span.group });
|
|
38
|
+
}
|
|
39
|
+
offset += lines[lineIndex]!.length + 1;
|
|
40
|
+
});
|
|
41
|
+
byBlock.set(job.blockIndex, spans);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return byBlock;
|
|
45
|
+
}
|
package/src/thread-controller.ts
CHANGED
|
@@ -311,7 +311,7 @@ export interface ReviewController {
|
|
|
311
311
|
/** The launch repo's working-tree changed files for the no-session welcome Changes tree. */
|
|
312
312
|
repoChanges(): Promise<readonly DiffFileContents[]>;
|
|
313
313
|
/** Explicitly pull a PR diff after its head moved. */
|
|
314
|
-
|
|
314
|
+
refreshDiff(): Promise<void>;
|
|
315
315
|
/** Open a session from the inbox. */
|
|
316
316
|
open(id: string): void;
|
|
317
317
|
/** Delete a session for good (inbox delete); the inbox refreshes on the event. */
|
|
@@ -843,14 +843,29 @@ class Controller implements ReviewController {
|
|
|
843
843
|
if (readsFrozenDiff(session)) return session!.artifact.files ?? [];
|
|
844
844
|
// eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
|
|
845
845
|
if (this.client?.repoDiff !== undefined) {
|
|
846
|
-
const diff = await this.client.repoDiff(
|
|
846
|
+
const diff = await this.client.repoDiff(
|
|
847
|
+
this.sidebarRepoRoot(),
|
|
848
|
+
session?.artifact.meta.workbench ? (session.artifact.meta.vcs ?? "git") : undefined,
|
|
849
|
+
);
|
|
847
850
|
// a thread switch during the request would let this response overwrite the new thread's diff,
|
|
848
851
|
// showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
|
|
849
852
|
// re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
|
|
850
853
|
if (this.snapshot.session?.id !== session?.id) return diff.files;
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
+
const changed =
|
|
855
|
+
this.liveDiff?.patch !== diff.patch ||
|
|
856
|
+
this.liveDiff.files.length !== diff.files.length ||
|
|
857
|
+
this.liveDiff.files.some(
|
|
858
|
+
(file, index) =>
|
|
859
|
+
file.path !== diff.files[index]?.path ||
|
|
860
|
+
file.status !== diff.files[index]?.status ||
|
|
861
|
+
file.oldContents !== diff.files[index]?.oldContents ||
|
|
862
|
+
file.newContents !== diff.files[index]?.newContents,
|
|
863
|
+
);
|
|
864
|
+
if (changed) {
|
|
865
|
+
this.liveDiff = diff;
|
|
866
|
+
// rows() derive from the fresh patch; re-render so an open diff tab repaints
|
|
867
|
+
this.update({});
|
|
868
|
+
}
|
|
854
869
|
|
|
855
870
|
return diff.files;
|
|
856
871
|
}
|
|
@@ -866,10 +881,10 @@ class Controller implements ReviewController {
|
|
|
866
881
|
}));
|
|
867
882
|
}
|
|
868
883
|
|
|
869
|
-
async
|
|
884
|
+
async refreshDiff(): Promise<void> {
|
|
870
885
|
const session = this.snapshot.session;
|
|
871
886
|
|
|
872
|
-
if (
|
|
887
|
+
if (session?.artifact.type !== "diff" || !this.client?.sessionRefreshDiff) return;
|
|
873
888
|
try {
|
|
874
889
|
await this.client.sessionRefreshDiff(session.id);
|
|
875
890
|
} catch (cause) {
|
|
@@ -1701,7 +1716,7 @@ class Controller implements ReviewController {
|
|
|
1701
1716
|
const repoDiff = this.client?.repoDiff;
|
|
1702
1717
|
|
|
1703
1718
|
return repoDiff
|
|
1704
|
-
? snapshotWorkbench(session, (root) => repoDiff.call(this.client, root))
|
|
1719
|
+
? snapshotWorkbench(session, (root, vcs) => repoDiff.call(this.client, root, vcs))
|
|
1705
1720
|
: Promise.resolve(session);
|
|
1706
1721
|
}
|
|
1707
1722
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useEffectEvent, useState } from "react";
|
|
2
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
3
|
+
|
|
4
|
+
const CHANGES_REFRESH_MS = 2000;
|
|
5
|
+
const EMPTY_CHANGED_FILES: readonly DiffFileContents[] = [];
|
|
6
|
+
|
|
7
|
+
interface RepoChangesOptions {
|
|
8
|
+
loadChanges: () => Promise<readonly DiffFileContents[]>;
|
|
9
|
+
visible: boolean;
|
|
10
|
+
diffSourceKey: string;
|
|
11
|
+
refreshAutomatically?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RepoChangesSnapshot {
|
|
15
|
+
diffSourceKey: string;
|
|
16
|
+
files: readonly DiffFileContents[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sameChangedFiles(a: readonly DiffFileContents[], b: readonly DiffFileContents[]): boolean {
|
|
20
|
+
return (
|
|
21
|
+
a.length === b.length &&
|
|
22
|
+
a.every(
|
|
23
|
+
(file, index) =>
|
|
24
|
+
file.path === b[index]?.path &&
|
|
25
|
+
file.status === b[index]?.status &&
|
|
26
|
+
file.oldContents === b[index]?.oldContents &&
|
|
27
|
+
file.newContents === b[index]?.newContents,
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Refresh the visible Changes tree from the working repo; keep captured diff reviews fixed. */
|
|
33
|
+
export function useRepoChanges({
|
|
34
|
+
loadChanges,
|
|
35
|
+
visible,
|
|
36
|
+
diffSourceKey,
|
|
37
|
+
refreshAutomatically = true,
|
|
38
|
+
}: RepoChangesOptions): readonly DiffFileContents[] {
|
|
39
|
+
const [snapshot, setSnapshot] = useState<RepoChangesSnapshot | null>(null);
|
|
40
|
+
const loadLatestChanges = useEffectEvent(loadChanges);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!visible) return;
|
|
44
|
+
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
let requestInFlight = false;
|
|
47
|
+
|
|
48
|
+
const refreshChanges = async (): Promise<void> => {
|
|
49
|
+
if (requestInFlight) return;
|
|
50
|
+
requestInFlight = true;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const files = await loadLatestChanges();
|
|
54
|
+
|
|
55
|
+
if (!cancelled) {
|
|
56
|
+
setSnapshot((previous) =>
|
|
57
|
+
previous?.diffSourceKey === diffSourceKey && sameChangedFiles(previous.files, files)
|
|
58
|
+
? previous
|
|
59
|
+
: { diffSourceKey, files },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
if (!cancelled) {
|
|
64
|
+
setSnapshot((previous) =>
|
|
65
|
+
previous?.diffSourceKey === diffSourceKey && previous.files.length === 0
|
|
66
|
+
? previous
|
|
67
|
+
: { diffSourceKey, files: EMPTY_CHANGED_FILES },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
requestInFlight = false;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
void refreshChanges();
|
|
76
|
+
const timer = refreshAutomatically
|
|
77
|
+
? setInterval(() => void refreshChanges(), CHANGES_REFRESH_MS)
|
|
78
|
+
: null;
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
cancelled = true;
|
|
82
|
+
if (timer !== null) clearInterval(timer);
|
|
83
|
+
};
|
|
84
|
+
}, [visible, diffSourceKey, refreshAutomatically]);
|
|
85
|
+
|
|
86
|
+
return snapshot?.diffSourceKey === diffSourceKey ? snapshot.files : EMPTY_CHANGED_FILES;
|
|
87
|
+
}
|
package/src/view-plan.test.ts
CHANGED
|
@@ -518,4 +518,28 @@ describe("rendered-text projection (markers concealed)", () => {
|
|
|
518
518
|
expect(link.marked).toBe(true);
|
|
519
519
|
expect(runs.find((run) => run.text === "see ")!.marked).toBe(false);
|
|
520
520
|
});
|
|
521
|
+
|
|
522
|
+
test("syntax tokens split painted code runs without moving annotation marks", () => {
|
|
523
|
+
const roles = [{ text: "export value", role: "plain" as const, start: 0 }];
|
|
524
|
+
const runs = styledRunsFor(
|
|
525
|
+
roles,
|
|
526
|
+
{ start: 0, end: 12 },
|
|
527
|
+
[{ start: 5, end: 9 }],
|
|
528
|
+
[{ start: 0, end: 6, group: "keyword" }],
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
expect(runs.map((run) => run.text).join("")).toBe("export value");
|
|
532
|
+
expect(
|
|
533
|
+
runs
|
|
534
|
+
.filter((run) => run.syntaxGroup === "keyword")
|
|
535
|
+
.map((run) => run.text)
|
|
536
|
+
.join(""),
|
|
537
|
+
).toBe("export");
|
|
538
|
+
expect(
|
|
539
|
+
runs
|
|
540
|
+
.filter((run) => run.marked)
|
|
541
|
+
.map((run) => run.text)
|
|
542
|
+
.join(""),
|
|
543
|
+
).toBe("t va");
|
|
544
|
+
});
|
|
521
545
|
});
|
package/src/view-plan.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { wordLevelChanges } from "./diff-intraline";
|
|
24
24
|
import { spanRangeInBlock, type CharRange, type TextSpan } from "./thread-selection";
|
|
25
25
|
import type { MarkRange, VisualLine } from "./mark-runs";
|
|
26
|
+
import type { SyntaxSpan } from "./diff-syntax";
|
|
26
27
|
|
|
27
28
|
// ── display model ─────────────────────────────
|
|
28
29
|
|
|
@@ -618,6 +619,26 @@ export interface StyledRun {
|
|
|
618
619
|
marked: boolean;
|
|
619
620
|
caretOnly: boolean;
|
|
620
621
|
href?: string;
|
|
622
|
+
syntaxGroup?: string;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function addSyntaxBoundaries(
|
|
626
|
+
cuts: Set<number>,
|
|
627
|
+
line: VisualLine,
|
|
628
|
+
syntaxSpans: readonly SyntaxSpan[],
|
|
629
|
+
): void {
|
|
630
|
+
for (const span of syntaxSpans) {
|
|
631
|
+
if (span.start > line.start && span.start < line.end) cuts.add(span.start);
|
|
632
|
+
if (span.end > line.start && span.end < line.end) cuts.add(span.end);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function syntaxGroupForRun(
|
|
637
|
+
syntaxSpans: readonly SyntaxSpan[],
|
|
638
|
+
start: number,
|
|
639
|
+
end: number,
|
|
640
|
+
): string | undefined {
|
|
641
|
+
return syntaxSpans.find((span) => span.start <= start && end <= span.end)?.group;
|
|
621
642
|
}
|
|
622
643
|
|
|
623
644
|
/**
|
|
@@ -630,6 +651,7 @@ export function styledRunsFor(
|
|
|
630
651
|
roleRuns: RenderedRun[],
|
|
631
652
|
line: VisualLine,
|
|
632
653
|
markRanges: MarkRange[],
|
|
654
|
+
syntaxSpans: readonly SyntaxSpan[] = [],
|
|
633
655
|
): StyledRun[] {
|
|
634
656
|
const cuts = new Set<number>([line.start, line.end]);
|
|
635
657
|
|
|
@@ -648,6 +670,7 @@ export function styledRunsFor(
|
|
|
648
670
|
cuts.add(end);
|
|
649
671
|
}
|
|
650
672
|
}
|
|
673
|
+
addSyntaxBoundaries(cuts, line, syntaxSpans);
|
|
651
674
|
const edges = [...cuts].toSorted((left, right) => left - right);
|
|
652
675
|
const runs: StyledRun[] = [];
|
|
653
676
|
|
|
@@ -668,6 +691,9 @@ export function styledRunsFor(
|
|
|
668
691
|
};
|
|
669
692
|
|
|
670
693
|
if (roleRun?.href !== undefined) styled.href = roleRun.href;
|
|
694
|
+
const syntaxGroup = syntaxGroupForRun(syntaxSpans, start, end);
|
|
695
|
+
|
|
696
|
+
if (syntaxGroup) styled.syntaxGroup = syntaxGroup;
|
|
671
697
|
runs.push(styled);
|
|
672
698
|
}
|
|
673
699
|
|
|
@@ -69,6 +69,24 @@ describe("snapshotWorkbench", () => {
|
|
|
69
69
|
expect(viewFollowing(frozen).artifact.content).toBe("FRESH PATCH");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("pins the captured source and revision in a shared workbench", async () => {
|
|
73
|
+
const frozen = await snapshotWorkbench(
|
|
74
|
+
diffThread({ workbench: true, vcs: "jj", vcsRevisionId: "old" }),
|
|
75
|
+
async () => ({
|
|
76
|
+
...LIVE,
|
|
77
|
+
vcs: "jj",
|
|
78
|
+
source: { changeId: "change", revisionId: "new" },
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(frozen.artifact.meta.vcsRevisionId).toBe("new");
|
|
83
|
+
expect(frozen.revisions[0]).toMatchObject({
|
|
84
|
+
content: LIVE.patch,
|
|
85
|
+
files: LIVE.files,
|
|
86
|
+
source: { vcs: "jj", changeId: "change", revisionId: "new" },
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
72
90
|
test("passes a plain diff review through untouched and never queries the tree", async () => {
|
|
73
91
|
const session = diffThread({ title: "PR #7" });
|
|
74
92
|
const repoDiff = mock(async () => LIVE);
|