@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34
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 +27 -26
- package/src/App.autoclose.test.tsx +7 -4
- package/src/App.diff.test.tsx +131 -5
- package/src/App.export.test.tsx +23 -5
- package/src/App.plan-review.test.tsx +133 -32
- package/src/App.readonly.test.tsx +35 -13
- package/src/App.rendering.test.tsx +14 -4
- package/src/App.review-panel.test.tsx +27 -15
- package/src/App.submit-confirm.test.tsx +14 -13
- package/src/App.test.tsx +50 -13
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +505 -101
- package/src/App.walk.test.tsx +31 -11
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +29 -10
- package/src/components/AnnotationCard.tsx +57 -37
- package/src/components/Breadcrumb.tsx +7 -1
- package/src/components/CodeBlock.stories.tsx +19 -2
- package/src/components/CodeBlock.tsx +24 -9
- package/src/components/CompletionOverlay.tsx +13 -3
- package/src/components/ConfirmCard.stories.tsx +22 -3
- package/src/components/ConfirmCard.tsx +23 -5
- package/src/components/ConfirmDialog.tsx +10 -2
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +35 -1
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +213 -101
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +35 -3
- package/src/components/PlanSheet.tsx +160 -100
- package/src/components/PromptDialog.tsx +19 -3
- package/src/components/ReviewPanel.stories.tsx +17 -19
- package/src/components/ReviewPanel.tsx +17 -32
- package/src/components/ReviewRail.stories.tsx +159 -47
- package/src/components/ReviewRail.tsx +206 -62
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +11 -5
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +2 -2
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +12 -1
- package/src/components/primitives/Tabs.tsx +40 -39
- package/src/components/stories-app.tsx +34 -6
- package/src/components/story-fixtures.ts +11 -3
- package/src/components/story.ts +3 -1
- package/src/components/syntax-highlight.ts +47 -12
- package/src/components/theme-context.stories.tsx +6 -1
- package/src/components/theme-context.tsx +7 -1
- package/src/config.test.ts +178 -2
- package/src/config.ts +121 -23
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +13 -4
- package/src/editor.ts +31 -5
- package/src/herdr.ts +8 -2
- package/src/index.ts +9 -1
- package/src/integrations.ts +3 -1
- package/src/intent-dispatch.test.ts +193 -9
- package/src/intent-dispatch.ts +151 -18
- package/src/key-bindings.test.ts +19 -9
- package/src/key-bindings.ts +77 -16
- package/src/keymap.test.ts +171 -26
- package/src/keymap.ts +83 -16
- package/src/review-panel.test.ts +3 -1
- package/src/review-panel.ts +9 -2
- package/src/serve.test.ts +13 -5
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +77 -16
- package/src/session-controller.ts +319 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +97 -23
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- package/src/version.ts +5 -0
- package/src/view-diff.ts +15 -4
- package/src/view-plan.test.ts +40 -6
- package/src/view-plan.ts +71 -28
- package/src/walk.test.ts +7 -2
- package/src/walk.ts +7 -2
- package/src/components/ComposeBar.stories.tsx +0 -18
- package/src/components/ComposeBar.tsx +0 -54
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Keyboard scroll is smooth: cursor-follow never drifts past wrapped cards. */
|
|
2
|
+
|
|
3
|
+
import { expect, test } from "bun:test";
|
|
4
|
+
import React, { useState } from "react";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import { useKeyboard } from "@opentui/react";
|
|
7
|
+
import type { Renderable, ScrollBoxRenderable } from "@opentui/core";
|
|
8
|
+
import type { Annotation } from "@cueloop/schema";
|
|
9
|
+
import { DiffSheet } from "./DiffSheet";
|
|
10
|
+
import { diffRows, type DiffRow } from "../view-diff";
|
|
11
|
+
import { DARK } from "../theme";
|
|
12
|
+
import { press } from "../test-support";
|
|
13
|
+
|
|
14
|
+
/** Find a renderable by id anywhere in the tree (getRenderable is not recursive). */
|
|
15
|
+
function findById(node: Renderable, id: string): Renderable | undefined {
|
|
16
|
+
if (node.id === id) return node;
|
|
17
|
+
for (const child of node.getChildren()) {
|
|
18
|
+
const found = findById(child, id);
|
|
19
|
+
if (found) return found;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hex(color: { toInts(): [number, number, number, number] }): string {
|
|
25
|
+
const [red, green, blue] = color.toInts();
|
|
26
|
+
return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A single-hunk patch of `count` added lines, taller than any test viewport. */
|
|
30
|
+
function tallPatch(count: number): string {
|
|
31
|
+
const adds = Array.from(
|
|
32
|
+
{ length: count },
|
|
33
|
+
(_, index) => `+line ${String(index).padStart(2, "0")}`,
|
|
34
|
+
);
|
|
35
|
+
return [
|
|
36
|
+
"diff --git a/f.ts b/f.ts",
|
|
37
|
+
"--- a/f.ts",
|
|
38
|
+
"+++ b/f.ts",
|
|
39
|
+
`@@ -0,0 +1,${count} @@`,
|
|
40
|
+
...adds,
|
|
41
|
+
"",
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** DiffSheet with j-driven cursor, mirroring the App's down-navigation. */
|
|
46
|
+
function ScrollHarness({
|
|
47
|
+
rows,
|
|
48
|
+
annotations,
|
|
49
|
+
}: {
|
|
50
|
+
rows: DiffRow[];
|
|
51
|
+
annotations: Annotation[];
|
|
52
|
+
}): React.ReactNode {
|
|
53
|
+
const [cursor, setCursor] = useState(0);
|
|
54
|
+
useKeyboard((key) => {
|
|
55
|
+
if (key.name === "j") setCursor((current) => Math.min(rows.length - 1, current + 1));
|
|
56
|
+
});
|
|
57
|
+
return <DiffSheet rows={rows} cursor={cursor} annotations={annotations} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("scrolling down past wrapped annotation cards keeps the cursor pinned and the scroll monotonic", async () => {
|
|
61
|
+
// Arrange - a tall diff whose annotation bodies would wrap at this width; the
|
|
62
|
+
// wrapped card is exactly the layout the cursor-follow model used to mis-measure
|
|
63
|
+
const rows = diffRows(tallPatch(40));
|
|
64
|
+
const longBody =
|
|
65
|
+
"this is a deliberately long annotation body that would wrap across the narrow test viewport";
|
|
66
|
+
const annotations: Annotation[] = [8, 16, 24].map((rowIndex) => ({
|
|
67
|
+
id: `a${rowIndex}`,
|
|
68
|
+
kind: "comment",
|
|
69
|
+
body: longBody,
|
|
70
|
+
anchor: { quote: rows[rowIndex]!.text, prefix: "", suffix: "" },
|
|
71
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
72
|
+
}));
|
|
73
|
+
const setup = await testRender(<ScrollHarness rows={rows} annotations={annotations} />, {
|
|
74
|
+
width: 60,
|
|
75
|
+
height: 12,
|
|
76
|
+
});
|
|
77
|
+
await setup.waitForVisualIdle();
|
|
78
|
+
const scrollbox = findById(setup.renderer.root, "diff-scroll") as ScrollBoxRenderable;
|
|
79
|
+
|
|
80
|
+
// the cursor row is the one painted with the cursor background
|
|
81
|
+
const cursorScreenRow = (): number => {
|
|
82
|
+
const lines = setup.captureSpans().lines;
|
|
83
|
+
for (let index = 0; index < lines.length; index++) {
|
|
84
|
+
if (lines[index]!.spans.some((span) => hex(span.bg) === DARK.cursorBackground)) return index;
|
|
85
|
+
}
|
|
86
|
+
return -1;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Act - walk the cursor down through the whole diff, sampling the scroll each step
|
|
90
|
+
const scrollTops: number[] = [];
|
|
91
|
+
const screenRows: number[] = [];
|
|
92
|
+
for (let step = 0; step < rows.length; step++) {
|
|
93
|
+
scrollTops.push(scrollbox.scrollTop);
|
|
94
|
+
screenRows.push(cursorScreenRow());
|
|
95
|
+
await press(setup, "j");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Assert - scrollTop only ever grows going down (never rebounds/jitters)
|
|
99
|
+
for (let step = 1; step < scrollTops.length; step++) {
|
|
100
|
+
expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Assert - the cursor never scrolls off screen (the pre-fix bug walked it off
|
|
104
|
+
// the viewport as it passed each wrapped card)
|
|
105
|
+
expect(screenRows.every((row) => row >= 0)).toBe(true);
|
|
106
|
+
|
|
107
|
+
// Assert - through the steady scroll region (not the initial fill, not the
|
|
108
|
+
// final clamp at the content end) the cursor holds ONE stable row near the
|
|
109
|
+
// bottom; pre-fix it drifted down a row per wrapped card
|
|
110
|
+
const maxScroll = scrollbox.scrollHeight - scrollbox.height;
|
|
111
|
+
const steadyRows = screenRows.filter(
|
|
112
|
+
(_, step) => scrollTops[step]! > 0 && scrollTops[step]! < maxScroll,
|
|
113
|
+
);
|
|
114
|
+
expect(steadyRows.length).toBeGreaterThan(0);
|
|
115
|
+
expect(new Set(steadyRows).size).toBe(1);
|
|
116
|
+
const pinnedRow = steadyRows[0]!;
|
|
117
|
+
const viewportBottom = scrollbox.height;
|
|
118
|
+
// the cursor sits a couple of rows inside the bottom edge, per the follow rule
|
|
119
|
+
expect(viewportBottom - pinnedRow).toBeGreaterThanOrEqual(2);
|
|
120
|
+
expect(viewportBottom - pinnedRow).toBeLessThanOrEqual(3);
|
|
121
|
+
|
|
122
|
+
setup.renderer.destroy();
|
|
123
|
+
}, 25000);
|
|
@@ -12,12 +12,43 @@ export const SignedRows: Story = {
|
|
|
12
12
|
size: { width: 90, height: 16 },
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// A modified line keeps the diff color on the changed words and dims the
|
|
16
|
+
// unchanged prefix to the dim token, so the intra-line change stands out.
|
|
17
|
+
export const IntralineWordDiff: Story = {
|
|
18
|
+
render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
|
|
19
|
+
expectedColors: [DARK.insertedForeground, DARK.deletedForeground, DARK.textDim],
|
|
20
|
+
size: { width: 90, height: 16 },
|
|
21
|
+
};
|
|
22
|
+
|
|
15
23
|
export const CursorOnAddedLine: Story = {
|
|
16
24
|
render: () => <DiffSheet rows={fixtureDiffRows()} cursor={4} annotations={[]} />,
|
|
17
25
|
expectedColors: [DARK.cursorBackground],
|
|
18
26
|
size: { width: 90, height: 16 },
|
|
19
27
|
};
|
|
20
28
|
|
|
29
|
+
export const ComposingOnLine: Story = {
|
|
30
|
+
render: () => (
|
|
31
|
+
<DiffSheet
|
|
32
|
+
rows={fixtureDiffRows()}
|
|
33
|
+
cursor={4}
|
|
34
|
+
annotations={[]}
|
|
35
|
+
compose={{
|
|
36
|
+
kind: "comment",
|
|
37
|
+
rowIndex: 4,
|
|
38
|
+
quote: " private items = new Map();",
|
|
39
|
+
draft: {
|
|
40
|
+
text: "Map needs an eviction story.",
|
|
41
|
+
onInput: () => {},
|
|
42
|
+
onSave: () => {},
|
|
43
|
+
onCancel: () => {},
|
|
44
|
+
},
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
),
|
|
48
|
+
expectedColors: [DARK.accent],
|
|
49
|
+
size: { width: 90, height: 16 },
|
|
50
|
+
};
|
|
51
|
+
|
|
21
52
|
export const AnnotatedLine: Story = {
|
|
22
53
|
render: () => (
|
|
23
54
|
<DiffSheet
|
|
@@ -29,7 +60,10 @@ export const AnnotatedLine: Story = {
|
|
|
29
60
|
kind: "comment",
|
|
30
61
|
anchor: {
|
|
31
62
|
// diff anchors quote the row text verbatim, trailing newline included
|
|
32
|
-
quote:
|
|
63
|
+
quote:
|
|
64
|
+
FIXTURE_PATCH.split("\n")
|
|
65
|
+
.find((line) => line.startsWith("+ "))!
|
|
66
|
+
.slice(1) + "\n",
|
|
33
67
|
prefix: "",
|
|
34
68
|
suffix: "",
|
|
35
69
|
},
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
3
|
+
import type { RGBA } from "@opentui/core";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { DiffSheet } from "./DiffSheet";
|
|
6
|
+
import { diffRows } from "../view-diff";
|
|
7
|
+
import { DARK } from "../theme";
|
|
8
|
+
import { allowEventLoopUpdates } from "../test-support";
|
|
9
|
+
|
|
10
|
+
const PATCH = `diff --git a/src/x.ts b/src/x.ts
|
|
11
|
+
--- a/src/x.ts
|
|
12
|
+
+++ b/src/x.ts
|
|
13
|
+
@@ -1,3 +1,3 @@
|
|
14
|
+
export class Store {
|
|
15
|
+
- const limit = 100;
|
|
16
|
+
+ const limit = 250;
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
function hex(color: RGBA): string {
|
|
21
|
+
const [red, green, blue] = color.toInts();
|
|
22
|
+
return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => allowEventLoopUpdates());
|
|
26
|
+
afterEach(() => allowEventLoopUpdates());
|
|
27
|
+
|
|
28
|
+
test("renders tree-sitter colors, with the changed word in the diff color on top", async () => {
|
|
29
|
+
// Arrange
|
|
30
|
+
const setup = await testRender(<DiffSheet rows={diffRows(PATCH)} cursor={0} annotations={[]} />, {
|
|
31
|
+
width: 60,
|
|
32
|
+
height: 12,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Act
|
|
36
|
+
// syntax highlighting resolves off the render path; pump until a keyword paints
|
|
37
|
+
let keywordAccent = false;
|
|
38
|
+
for (let attempt = 0; attempt < 60 && !keywordAccent; attempt++) {
|
|
39
|
+
await setup.renderOnce();
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
41
|
+
for (const line of setup.captureSpans().lines) {
|
|
42
|
+
for (const span of line.spans) {
|
|
43
|
+
if (span.text.includes("export") && hex(span.fg) === DARK.accent) keywordAccent = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Assert
|
|
49
|
+
expect(keywordAccent).toBe(true);
|
|
50
|
+
// the intra-line changed number keeps the diff color over its syntax color
|
|
51
|
+
const changedColors = new Set<string>();
|
|
52
|
+
for (const line of setup.captureSpans().lines) {
|
|
53
|
+
for (const span of line.spans) {
|
|
54
|
+
if (span.text.includes("250")) changedColors.add(hex(span.fg));
|
|
55
|
+
if (span.text.includes("100")) changedColors.add(hex(span.fg));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
expect(changedColors.has(DARK.insertedForeground)).toBe(true);
|
|
59
|
+
expect(changedColors.has(DARK.deletedForeground)).toBe(true);
|
|
60
|
+
|
|
61
|
+
setup.renderer.destroy();
|
|
62
|
+
}, 25000);
|
|
@@ -1,75 +1,101 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The diff review sheet:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* The diff review sheet: a thin renderer over diff-sheet-layout. Rows render (no
|
|
3
|
+
* window cap) in a scrollbox with file/hunk headers and contiguous code chunks.
|
|
4
|
+
* A chunk draws through the native line-number gutter - custom numbers per row,
|
|
5
|
+
* the cursor as a gutter sign, annotation markers as line signs - and closes
|
|
6
|
+
* after an annotated row so the annotation body sits under its line. A
|
|
7
|
+
* curated-out row renders struck through.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import React, { useEffect, useMemo, useRef } from "react";
|
|
11
|
-
import
|
|
10
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import {
|
|
12
|
+
createTextAttributes,
|
|
13
|
+
type LineNumberRenderable,
|
|
14
|
+
type LineSign,
|
|
15
|
+
type ScrollBoxRenderable,
|
|
16
|
+
} from "@opentui/core";
|
|
12
17
|
import type { Annotation } from "@cueloop/schema";
|
|
13
18
|
import type { DiffRow } from "../view-diff";
|
|
14
19
|
import type { Theme } from "../theme";
|
|
15
20
|
import { useComponentTheme } from "./theme-context";
|
|
16
21
|
import { truncateToSingleLine } from "./truncate-text";
|
|
22
|
+
import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
|
|
23
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
24
|
+
import { intralineRunsByRow, type IntralineRun } from "../diff-intraline";
|
|
25
|
+
import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
|
|
26
|
+
import {
|
|
27
|
+
annotatedRowsByIndex,
|
|
28
|
+
coloredRowSpans,
|
|
29
|
+
rowContentOffsets,
|
|
30
|
+
rowLine,
|
|
31
|
+
segmentRows,
|
|
32
|
+
type DiffSegment,
|
|
33
|
+
} from "./diff-sheet-layout";
|
|
34
|
+
|
|
35
|
+
/** Shared empty map so an unresolved/stale highlight state is a stable value. */
|
|
36
|
+
const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
|
|
37
|
+
|
|
38
|
+
/** Shared empty set so "nothing rejected" is a stable identity for renders. */
|
|
39
|
+
const EMPTY_REJECTED: Set<number> = new Set();
|
|
40
|
+
|
|
41
|
+
/** A rejected (curated-out) change row renders struck through and dimmed. */
|
|
42
|
+
const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
|
|
43
|
+
|
|
44
|
+
export interface DiffComposeState {
|
|
45
|
+
kind: "comment";
|
|
46
|
+
rowIndex: number;
|
|
47
|
+
quote: string;
|
|
48
|
+
draft: AnnotationDraft;
|
|
49
|
+
}
|
|
17
50
|
|
|
18
51
|
export interface DiffSheetProps {
|
|
19
52
|
rows: DiffRow[];
|
|
20
53
|
cursor: number;
|
|
21
54
|
annotations: Annotation[];
|
|
22
55
|
focusedAnnotationId?: string;
|
|
56
|
+
/** Row indices the owner rejected during curation; drawn struck through. */
|
|
57
|
+
rejectedRows?: Set<number>;
|
|
58
|
+
compose?: DiffComposeState | null;
|
|
23
59
|
theme?: Theme;
|
|
24
60
|
}
|
|
25
61
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (row.kind === "file" || row.kind === "hunk") {
|
|
47
|
-
closeChunk(null);
|
|
48
|
-
segments.push({ kind: "header", rowIndex, row });
|
|
49
|
-
chunkStart = rowIndex + 1;
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (!chunk.length) chunkStart = rowIndex;
|
|
53
|
-
chunk.push(row);
|
|
54
|
-
const annotation = annotatedByRow.get(rowIndex);
|
|
55
|
-
if (annotation) {
|
|
56
|
-
closeChunk(annotation);
|
|
57
|
-
chunkStart = rowIndex + 1;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
closeChunk(null);
|
|
61
|
-
return segments;
|
|
62
|
+
/** The sign, base color, and background a row draws with. */
|
|
63
|
+
function rowStyle(
|
|
64
|
+
row: DiffRow,
|
|
65
|
+
isCursorRow: boolean,
|
|
66
|
+
isAnnotatedRow: boolean,
|
|
67
|
+
tokens: Theme,
|
|
68
|
+
): { sign: string; baseColor: string; background: string | undefined } {
|
|
69
|
+
const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
|
|
70
|
+
const baseColor =
|
|
71
|
+
row.kind === "add"
|
|
72
|
+
? tokens.insertedForeground
|
|
73
|
+
: row.kind === "del"
|
|
74
|
+
? tokens.deletedForeground
|
|
75
|
+
: tokens.textMuted;
|
|
76
|
+
const background = isCursorRow
|
|
77
|
+
? tokens.cursorBackground
|
|
78
|
+
: isAnnotatedRow
|
|
79
|
+
? tokens.markCommentBackground
|
|
80
|
+
: undefined;
|
|
81
|
+
return { sign, baseColor, background };
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
function DiffChunk({
|
|
65
85
|
segment,
|
|
66
86
|
cursor,
|
|
67
87
|
focusedAnnotationId,
|
|
88
|
+
intralineByRow,
|
|
89
|
+
syntaxByRow,
|
|
90
|
+
rejectedRows,
|
|
68
91
|
theme,
|
|
69
92
|
}: {
|
|
70
93
|
segment: Extract<DiffSegment, { kind: "chunk" }>;
|
|
71
94
|
cursor: number;
|
|
72
95
|
focusedAnnotationId?: string;
|
|
96
|
+
intralineByRow: Map<number, IntralineRun[]>;
|
|
97
|
+
syntaxByRow: Map<number, SyntaxSpan[]>;
|
|
98
|
+
rejectedRows: Set<number>;
|
|
73
99
|
theme?: Theme;
|
|
74
100
|
}): React.ReactNode {
|
|
75
101
|
const tokens = useComponentTheme(theme);
|
|
@@ -100,16 +126,21 @@ function DiffChunk({
|
|
|
100
126
|
// the renderable's config parser requires the gutter key to be present
|
|
101
127
|
const colors = new Map<number, { gutter: string | undefined; content: string }>();
|
|
102
128
|
if (segment.annotation) {
|
|
103
|
-
colors.set(segment.rows.length - 1, {
|
|
129
|
+
colors.set(segment.rows.length - 1, {
|
|
130
|
+
gutter: undefined,
|
|
131
|
+
content: tokens.markCommentBackground,
|
|
132
|
+
});
|
|
104
133
|
}
|
|
105
134
|
if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
|
|
106
|
-
colors.set(cursorInChunk, {
|
|
135
|
+
colors.set(cursorInChunk, {
|
|
136
|
+
gutter: tokens.cursorBackground,
|
|
137
|
+
content: tokens.cursorBackground,
|
|
138
|
+
});
|
|
107
139
|
}
|
|
108
140
|
return colors;
|
|
109
141
|
}, [segment, cursorInChunk, tokens]);
|
|
110
142
|
|
|
111
|
-
// gutter
|
|
112
|
-
// no prop setters for these maps, so updates land via effects
|
|
143
|
+
// the gutter renderable has no prop setters for these maps, so they land via an effect
|
|
113
144
|
useEffect(() => {
|
|
114
145
|
const gutter = gutterRef.current;
|
|
115
146
|
if (!gutter) return;
|
|
@@ -132,24 +163,60 @@ function DiffChunk({
|
|
|
132
163
|
>
|
|
133
164
|
<text style={{ wrapMode: "none" }} selectable={false}>
|
|
134
165
|
{segment.rows.map((row, lineIndex) => {
|
|
135
|
-
const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
|
|
136
|
-
const foreground = row.kind === "add" ? tokens.insertedForeground : row.kind === "del" ? tokens.deletedForeground : tokens.textMuted;
|
|
137
166
|
const isCursorRow = lineIndex === cursorInChunk;
|
|
138
|
-
const isAnnotatedRow =
|
|
167
|
+
const isAnnotatedRow =
|
|
168
|
+
segment.annotation !== null && lineIndex === segment.rows.length - 1;
|
|
169
|
+
const { sign, baseColor, background } = rowStyle(
|
|
170
|
+
row,
|
|
171
|
+
isCursorRow,
|
|
172
|
+
isAnnotatedRow,
|
|
173
|
+
tokens,
|
|
174
|
+
);
|
|
175
|
+
const prefix = (lineIndex > 0 ? "\n" : "") + sign;
|
|
176
|
+
const absoluteRowIndex = segment.firstRowIndex + lineIndex;
|
|
177
|
+
|
|
178
|
+
// A rejected change collapses to one struck-through, dimmed span, so
|
|
179
|
+
// it reads as excluded regardless of its syntax or intra-line colors.
|
|
180
|
+
if (rejectedRows.has(absoluteRowIndex)) {
|
|
181
|
+
return (
|
|
182
|
+
<span
|
|
183
|
+
key={lineIndex}
|
|
184
|
+
fg={tokens.textDim}
|
|
185
|
+
bg={background}
|
|
186
|
+
attributes={REJECTED_ATTRIBUTES}
|
|
187
|
+
>
|
|
188
|
+
{prefix + rowLine(row)}
|
|
189
|
+
</span>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const spans = coloredRowSpans(
|
|
194
|
+
rowLine(row),
|
|
195
|
+
intralineByRow.get(absoluteRowIndex),
|
|
196
|
+
syntaxByRow.get(absoluteRowIndex),
|
|
197
|
+
baseColor,
|
|
198
|
+
tokens,
|
|
199
|
+
);
|
|
139
200
|
return (
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
201
|
+
<React.Fragment key={lineIndex}>
|
|
202
|
+
<span fg={baseColor} bg={background}>
|
|
203
|
+
{prefix}
|
|
204
|
+
</span>
|
|
205
|
+
{spans.map((span, spanIndex) => (
|
|
206
|
+
<span key={spanIndex} fg={span.foreground} bg={background}>
|
|
207
|
+
{span.text}
|
|
208
|
+
</span>
|
|
209
|
+
))}
|
|
210
|
+
</React.Fragment>
|
|
147
211
|
);
|
|
148
212
|
})}
|
|
149
213
|
</text>
|
|
150
214
|
</line-number>
|
|
215
|
+
|
|
151
216
|
{segment.annotation ? (
|
|
152
|
-
|
|
217
|
+
// one row exactly: wrapMode none keeps the model's 1-line-per-card offset
|
|
218
|
+
// honest, so the cursor-follow scroll never drifts past a wrapped card
|
|
219
|
+
<text style={{ wrapMode: "none" }}>
|
|
153
220
|
<span fg={tokens.textDim}>{" "}</span>
|
|
154
221
|
<span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
|
|
155
222
|
◆ {truncateToSingleLine(segment.annotation.body, 70)}
|
|
@@ -160,41 +227,52 @@ function DiffChunk({
|
|
|
160
227
|
);
|
|
161
228
|
}
|
|
162
229
|
|
|
163
|
-
|
|
230
|
+
/** Async tree-sitter highlights, discarded when they belong to superseded rows. */
|
|
231
|
+
function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
|
|
232
|
+
const [highlighted, setHighlighted] = useState<{
|
|
233
|
+
rows: DiffRow[];
|
|
234
|
+
byRow: Map<number, SyntaxSpan[]>;
|
|
235
|
+
}>({
|
|
236
|
+
rows,
|
|
237
|
+
byRow: EMPTY_SYNTAX,
|
|
238
|
+
});
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
let active = true;
|
|
241
|
+
void highlightDiffRows(rows).then((byRow) => {
|
|
242
|
+
if (active) setHighlighted({ rows, byRow });
|
|
243
|
+
});
|
|
244
|
+
return () => {
|
|
245
|
+
active = false;
|
|
246
|
+
};
|
|
247
|
+
}, [rows]);
|
|
248
|
+
return highlighted.rows === rows ? highlighted.byRow : EMPTY_SYNTAX;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function DiffSheet({
|
|
252
|
+
rows,
|
|
253
|
+
cursor,
|
|
254
|
+
annotations,
|
|
255
|
+
focusedAnnotationId,
|
|
256
|
+
rejectedRows = EMPTY_REJECTED,
|
|
257
|
+
compose,
|
|
258
|
+
theme,
|
|
259
|
+
}: DiffSheetProps): React.ReactNode {
|
|
164
260
|
const tokens = useComponentTheme(theme);
|
|
165
261
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
166
262
|
|
|
167
|
-
const annotatedByRow = useMemo(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const segments = useMemo(() => segmentRows(rows, annotatedByRow), [rows, annotatedByRow]);
|
|
179
|
-
|
|
180
|
-
// content y offset per row index, for cursor-following scroll
|
|
181
|
-
const rowOffsets = useMemo(() => {
|
|
182
|
-
const offsets: number[] = [];
|
|
183
|
-
let contentY = 0;
|
|
184
|
-
for (const segment of segments) {
|
|
185
|
-
if (segment.kind === "header") {
|
|
186
|
-
offsets[segment.rowIndex] = contentY;
|
|
187
|
-
contentY += 1;
|
|
188
|
-
} else {
|
|
189
|
-
segment.rows.forEach((_, lineIndex) => {
|
|
190
|
-
offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
|
|
191
|
-
});
|
|
192
|
-
contentY += segment.rows.length + (segment.annotation ? 1 : 0);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return offsets;
|
|
196
|
-
}, [segments]);
|
|
263
|
+
const annotatedByRow = useMemo(
|
|
264
|
+
() => annotatedRowsByIndex(rows, annotations),
|
|
265
|
+
[rows, annotations],
|
|
266
|
+
);
|
|
267
|
+
const segments = useMemo(
|
|
268
|
+
() => segmentRows(rows, annotatedByRow, compose?.rowIndex),
|
|
269
|
+
[rows, annotatedByRow, compose?.rowIndex],
|
|
270
|
+
);
|
|
271
|
+
const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
|
|
272
|
+
const syntaxByRow = useSyntaxHighlights(rows);
|
|
273
|
+
const rowOffsets = useMemo(() => rowContentOffsets(segments), [segments]);
|
|
197
274
|
|
|
275
|
+
// follow the cursor: keep it a couple of rows inside the viewport
|
|
198
276
|
useEffect(() => {
|
|
199
277
|
const scrollbox = scrollRef.current;
|
|
200
278
|
const cursorOffset = rowOffsets[cursor];
|
|
@@ -208,33 +286,67 @@ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, them
|
|
|
208
286
|
}, [cursor, rowOffsets]);
|
|
209
287
|
|
|
210
288
|
return (
|
|
211
|
-
<box
|
|
212
|
-
|
|
289
|
+
<box
|
|
290
|
+
style={{
|
|
291
|
+
flexGrow: 1,
|
|
292
|
+
flexDirection: "column",
|
|
293
|
+
border: true,
|
|
294
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
295
|
+
borderColor: tokens.text,
|
|
296
|
+
paddingLeft: 1,
|
|
297
|
+
paddingTop: 0,
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
300
|
+
<scrollbox id="diff-scroll" ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
213
301
|
{segments.map((segment, segmentIndex) => {
|
|
214
302
|
if (segment.kind === "header") {
|
|
215
303
|
const isCursor = segment.rowIndex === cursor;
|
|
216
304
|
if (segment.row.kind === "file") {
|
|
217
305
|
return (
|
|
218
|
-
<text
|
|
306
|
+
<text
|
|
307
|
+
key={segmentIndex}
|
|
308
|
+
fg={tokens.text}
|
|
309
|
+
bg={isCursor ? tokens.cursorBackground : tokens.panel}
|
|
310
|
+
style={{ wrapMode: "none" }}
|
|
311
|
+
>
|
|
219
312
|
{isCursor ? "▎" : " "}■ {rowLine(segment.row)}
|
|
220
313
|
</text>
|
|
221
314
|
);
|
|
222
315
|
}
|
|
223
316
|
return (
|
|
224
|
-
<text
|
|
317
|
+
<text
|
|
318
|
+
key={segmentIndex}
|
|
319
|
+
fg={tokens.blue}
|
|
320
|
+
bg={isCursor ? tokens.cursorBackground : undefined}
|
|
321
|
+
style={{ wrapMode: "none" }}
|
|
322
|
+
>
|
|
225
323
|
{isCursor ? "▎" : " "}
|
|
226
324
|
{rowLine(segment.row)}
|
|
227
325
|
</text>
|
|
228
326
|
);
|
|
229
327
|
}
|
|
328
|
+
|
|
329
|
+
const lastRowIndex = segment.firstRowIndex + segment.rows.length - 1;
|
|
230
330
|
return (
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
331
|
+
<React.Fragment key={segmentIndex}>
|
|
332
|
+
<DiffChunk
|
|
333
|
+
segment={segment}
|
|
334
|
+
cursor={cursor}
|
|
335
|
+
focusedAnnotationId={focusedAnnotationId}
|
|
336
|
+
intralineByRow={intralineByRow}
|
|
337
|
+
syntaxByRow={syntaxByRow}
|
|
338
|
+
rejectedRows={rejectedRows}
|
|
339
|
+
theme={theme}
|
|
340
|
+
/>
|
|
341
|
+
{compose && compose.rowIndex === lastRowIndex ? (
|
|
342
|
+
<AnnotationCard
|
|
343
|
+
kind={compose.kind}
|
|
344
|
+
quote={compose.quote}
|
|
345
|
+
draft={compose.draft}
|
|
346
|
+
theme={theme}
|
|
347
|
+
/>
|
|
348
|
+
) : null}
|
|
349
|
+
</React.Fragment>
|
|
238
350
|
);
|
|
239
351
|
})}
|
|
240
352
|
</scrollbox>
|
|
@@ -6,13 +6,26 @@ import { fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
|
|
|
6
6
|
|
|
7
7
|
export const meta: StoryMeta = { title: "InboxList" };
|
|
8
8
|
|
|
9
|
+
/** InboxList is the body under App's header/menu chrome; a frame gives it height. */
|
|
10
|
+
function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
11
|
+
return <box style={{ flexDirection: "column", width: "100%", height: "100%" }}>{children}</box>;
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
export const TwoPending: Story = {
|
|
10
|
-
render: () =>
|
|
11
|
-
|
|
15
|
+
render: () => (
|
|
16
|
+
<BodyFrame>
|
|
17
|
+
<InboxList inbox={[fixturePlanSession(), fixtureDiffSession()]} cursor={0} />
|
|
18
|
+
</BodyFrame>
|
|
19
|
+
),
|
|
20
|
+
expectedColors: [DARK.cursorBackground],
|
|
12
21
|
size: { width: 80, height: 12 },
|
|
13
22
|
};
|
|
14
23
|
|
|
15
24
|
export const Empty: Story = {
|
|
16
|
-
render: () =>
|
|
25
|
+
render: () => (
|
|
26
|
+
<BodyFrame>
|
|
27
|
+
<InboxList inbox={[]} cursor={0} />
|
|
28
|
+
</BodyFrame>
|
|
29
|
+
),
|
|
17
30
|
size: { width: 80, height: 8 },
|
|
18
31
|
};
|