@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.
@@ -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={roleForeground(run.role, baseFg, tokens)}
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 { blockIndex, roleRuns, line, lineIndex, ranges, marker, baseFg, baseAttributes } =
280
- context;
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
- refreshPullRequest: mock(() => Promise.resolve()),
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
+ }
@@ -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
- refreshPullRequest(): Promise<void>;
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(this.sidebarRepoRoot());
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
- this.liveDiff = diff;
852
- // rows() derive from the fresh patch; re-render so an open diff tab repaints
853
- this.update({});
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 refreshPullRequest(): Promise<void> {
884
+ async refreshDiff(): Promise<void> {
870
885
  const session = this.snapshot.session;
871
886
 
872
- if (!session?.artifact.meta.prRefreshHeadSha || !this.client?.sessionRefreshDiff) return;
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
+ }
@@ -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);