@cueloop/client 0.1.0-alpha.33 → 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 +130 -4
- package/src/App.export.test.tsx +22 -4
- package/src/App.plan-review.test.tsx +127 -27
- package/src/App.readonly.test.tsx +31 -8
- package/src/App.rendering.test.tsx +12 -3
- package/src/App.review-panel.test.tsx +11 -3
- package/src/App.submit-confirm.test.tsx +12 -7
- package/src/App.test.tsx +46 -9
- package/src/App.toast.test.tsx +10 -3
- package/src/App.tsx +400 -100
- package/src/App.walk.test.tsx +30 -10
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +28 -9
- package/src/components/AnnotationCard.tsx +53 -38
- 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 +22 -5
- package/src/components/ConfirmDialog.tsx +9 -1
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +18 -2
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +184 -98
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.tsx +19 -3
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +5 -1
- package/src/components/MenuBar.tsx +18 -2
- package/src/components/PlanSheet.stories.tsx +34 -2
- package/src/components/PlanSheet.tsx +141 -32
- package/src/components/PromptDialog.tsx +18 -2
- package/src/components/ReviewPanel.stories.tsx +13 -3
- package/src/components/ReviewPanel.tsx +8 -4
- package/src/components/ReviewRail.stories.tsx +145 -72
- package/src/components/ReviewRail.tsx +177 -38
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +8 -3
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.tsx +17 -2
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +9 -1
- package/src/components/primitives/Tabs.tsx +24 -4
- 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 +36 -14
- 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 +11 -3
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +65 -17
- package/src/session-controller.ts +301 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +16 -6
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- 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
|
@@ -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,6 +12,14 @@ 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],
|
|
@@ -28,7 +36,12 @@ export const ComposingOnLine: Story = {
|
|
|
28
36
|
kind: "comment",
|
|
29
37
|
rowIndex: 4,
|
|
30
38
|
quote: " private items = new Map();",
|
|
31
|
-
draft: {
|
|
39
|
+
draft: {
|
|
40
|
+
text: "Map needs an eviction story.",
|
|
41
|
+
onInput: () => {},
|
|
42
|
+
onSave: () => {},
|
|
43
|
+
onCancel: () => {},
|
|
44
|
+
},
|
|
32
45
|
}}
|
|
33
46
|
/>
|
|
34
47
|
),
|
|
@@ -47,7 +60,10 @@ export const AnnotatedLine: Story = {
|
|
|
47
60
|
kind: "comment",
|
|
48
61
|
anchor: {
|
|
49
62
|
// diff anchors quote the row text verbatim, trailing newline included
|
|
50
|
-
quote:
|
|
63
|
+
quote:
|
|
64
|
+
FIXTURE_PATCH.split("\n")
|
|
65
|
+
.find((line) => line.startsWith("+ "))!
|
|
66
|
+
.slice(1) + "\n",
|
|
51
67
|
prefix: "",
|
|
52
68
|
suffix: "",
|
|
53
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,14 +1,19 @@
|
|
|
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";
|
|
@@ -16,9 +21,28 @@ import { useComponentTheme } from "./theme-context";
|
|
|
16
21
|
import { truncateToSingleLine } from "./truncate-text";
|
|
17
22
|
import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
|
|
18
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 });
|
|
19
43
|
|
|
20
44
|
export interface DiffComposeState {
|
|
21
|
-
kind: "comment"
|
|
45
|
+
kind: "comment";
|
|
22
46
|
rowIndex: number;
|
|
23
47
|
quote: string;
|
|
24
48
|
draft: AnnotationDraft;
|
|
@@ -29,60 +53,49 @@ export interface DiffSheetProps {
|
|
|
29
53
|
cursor: number;
|
|
30
54
|
annotations: Annotation[];
|
|
31
55
|
focusedAnnotationId?: string;
|
|
56
|
+
/** Row indices the owner rejected during curation; drawn struck through. */
|
|
57
|
+
rejectedRows?: Set<number>;
|
|
32
58
|
compose?: DiffComposeState | null;
|
|
33
59
|
theme?: Theme;
|
|
34
60
|
}
|
|
35
61
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (row.kind === "file" || row.kind === "hunk") {
|
|
57
|
-
closeChunk(null);
|
|
58
|
-
segments.push({ kind: "header", rowIndex, row });
|
|
59
|
-
chunkStart = rowIndex + 1;
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
if (!chunk.length) chunkStart = rowIndex;
|
|
63
|
-
chunk.push(row);
|
|
64
|
-
const annotation = annotatedByRow.get(rowIndex);
|
|
65
|
-
if (annotation) {
|
|
66
|
-
closeChunk(annotation);
|
|
67
|
-
chunkStart = rowIndex + 1;
|
|
68
|
-
} else if (rowIndex === composeRowIndex) {
|
|
69
|
-
closeChunk(null);
|
|
70
|
-
chunkStart = rowIndex + 1;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
closeChunk(null);
|
|
74
|
-
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 };
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
function DiffChunk({
|
|
78
85
|
segment,
|
|
79
86
|
cursor,
|
|
80
87
|
focusedAnnotationId,
|
|
88
|
+
intralineByRow,
|
|
89
|
+
syntaxByRow,
|
|
90
|
+
rejectedRows,
|
|
81
91
|
theme,
|
|
82
92
|
}: {
|
|
83
93
|
segment: Extract<DiffSegment, { kind: "chunk" }>;
|
|
84
94
|
cursor: number;
|
|
85
95
|
focusedAnnotationId?: string;
|
|
96
|
+
intralineByRow: Map<number, IntralineRun[]>;
|
|
97
|
+
syntaxByRow: Map<number, SyntaxSpan[]>;
|
|
98
|
+
rejectedRows: Set<number>;
|
|
86
99
|
theme?: Theme;
|
|
87
100
|
}): React.ReactNode {
|
|
88
101
|
const tokens = useComponentTheme(theme);
|
|
@@ -113,16 +126,21 @@ function DiffChunk({
|
|
|
113
126
|
// the renderable's config parser requires the gutter key to be present
|
|
114
127
|
const colors = new Map<number, { gutter: string | undefined; content: string }>();
|
|
115
128
|
if (segment.annotation) {
|
|
116
|
-
colors.set(segment.rows.length - 1, {
|
|
129
|
+
colors.set(segment.rows.length - 1, {
|
|
130
|
+
gutter: undefined,
|
|
131
|
+
content: tokens.markCommentBackground,
|
|
132
|
+
});
|
|
117
133
|
}
|
|
118
134
|
if (cursorInChunk >= 0 && cursorInChunk < segment.rows.length) {
|
|
119
|
-
colors.set(cursorInChunk, {
|
|
135
|
+
colors.set(cursorInChunk, {
|
|
136
|
+
gutter: tokens.cursorBackground,
|
|
137
|
+
content: tokens.cursorBackground,
|
|
138
|
+
});
|
|
120
139
|
}
|
|
121
140
|
return colors;
|
|
122
141
|
}, [segment, cursorInChunk, tokens]);
|
|
123
142
|
|
|
124
|
-
// gutter
|
|
125
|
-
// 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
|
|
126
144
|
useEffect(() => {
|
|
127
145
|
const gutter = gutterRef.current;
|
|
128
146
|
if (!gutter) return;
|
|
@@ -145,24 +163,60 @@ function DiffChunk({
|
|
|
145
163
|
>
|
|
146
164
|
<text style={{ wrapMode: "none" }} selectable={false}>
|
|
147
165
|
{segment.rows.map((row, lineIndex) => {
|
|
148
|
-
const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
|
|
149
|
-
const foreground = row.kind === "add" ? tokens.insertedForeground : row.kind === "del" ? tokens.deletedForeground : tokens.textMuted;
|
|
150
166
|
const isCursorRow = lineIndex === cursorInChunk;
|
|
151
|
-
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
|
+
);
|
|
152
200
|
return (
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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>
|
|
160
211
|
);
|
|
161
212
|
})}
|
|
162
213
|
</text>
|
|
163
214
|
</line-number>
|
|
215
|
+
|
|
164
216
|
{segment.annotation ? (
|
|
165
|
-
|
|
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" }}>
|
|
166
220
|
<span fg={tokens.textDim}>{" "}</span>
|
|
167
221
|
<span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
|
|
168
222
|
◆ {truncateToSingleLine(segment.annotation.body, 70)}
|
|
@@ -173,44 +227,52 @@ function DiffChunk({
|
|
|
173
227
|
);
|
|
174
228
|
}
|
|
175
229
|
|
|
176
|
-
|
|
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 {
|
|
177
260
|
const tokens = useComponentTheme(theme);
|
|
178
261
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
179
262
|
|
|
180
|
-
const annotatedByRow = useMemo(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
(row) => row.text === annotation.anchor.quote && (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
|
|
185
|
-
);
|
|
186
|
-
if (rowIndex !== -1) byRow.set(rowIndex, annotation);
|
|
187
|
-
}
|
|
188
|
-
return byRow;
|
|
189
|
-
}, [rows, annotations]);
|
|
190
|
-
|
|
263
|
+
const annotatedByRow = useMemo(
|
|
264
|
+
() => annotatedRowsByIndex(rows, annotations),
|
|
265
|
+
[rows, annotations],
|
|
266
|
+
);
|
|
191
267
|
const segments = useMemo(
|
|
192
268
|
() => segmentRows(rows, annotatedByRow, compose?.rowIndex),
|
|
193
269
|
[rows, annotatedByRow, compose?.rowIndex],
|
|
194
270
|
);
|
|
271
|
+
const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
|
|
272
|
+
const syntaxByRow = useSyntaxHighlights(rows);
|
|
273
|
+
const rowOffsets = useMemo(() => rowContentOffsets(segments), [segments]);
|
|
195
274
|
|
|
196
|
-
//
|
|
197
|
-
const rowOffsets = useMemo(() => {
|
|
198
|
-
const offsets: number[] = [];
|
|
199
|
-
let contentY = 0;
|
|
200
|
-
for (const segment of segments) {
|
|
201
|
-
if (segment.kind === "header") {
|
|
202
|
-
offsets[segment.rowIndex] = contentY;
|
|
203
|
-
contentY += 1;
|
|
204
|
-
} else {
|
|
205
|
-
segment.rows.forEach((_, lineIndex) => {
|
|
206
|
-
offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
|
|
207
|
-
});
|
|
208
|
-
contentY += segment.rows.length + (segment.annotation ? 1 : 0);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
return offsets;
|
|
212
|
-
}, [segments]);
|
|
213
|
-
|
|
275
|
+
// follow the cursor: keep it a couple of rows inside the viewport
|
|
214
276
|
useEffect(() => {
|
|
215
277
|
const scrollbox = scrollRef.current;
|
|
216
278
|
const cursorOffset = rowOffsets[cursor];
|
|
@@ -235,30 +297,54 @@ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, comp
|
|
|
235
297
|
paddingTop: 0,
|
|
236
298
|
}}
|
|
237
299
|
>
|
|
238
|
-
<scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
300
|
+
<scrollbox id="diff-scroll" ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
239
301
|
{segments.map((segment, segmentIndex) => {
|
|
240
302
|
if (segment.kind === "header") {
|
|
241
303
|
const isCursor = segment.rowIndex === cursor;
|
|
242
304
|
if (segment.row.kind === "file") {
|
|
243
305
|
return (
|
|
244
|
-
<text
|
|
306
|
+
<text
|
|
307
|
+
key={segmentIndex}
|
|
308
|
+
fg={tokens.text}
|
|
309
|
+
bg={isCursor ? tokens.cursorBackground : tokens.panel}
|
|
310
|
+
style={{ wrapMode: "none" }}
|
|
311
|
+
>
|
|
245
312
|
{isCursor ? "▎" : " "}■ {rowLine(segment.row)}
|
|
246
313
|
</text>
|
|
247
314
|
);
|
|
248
315
|
}
|
|
249
316
|
return (
|
|
250
|
-
<text
|
|
317
|
+
<text
|
|
318
|
+
key={segmentIndex}
|
|
319
|
+
fg={tokens.blue}
|
|
320
|
+
bg={isCursor ? tokens.cursorBackground : undefined}
|
|
321
|
+
style={{ wrapMode: "none" }}
|
|
322
|
+
>
|
|
251
323
|
{isCursor ? "▎" : " "}
|
|
252
324
|
{rowLine(segment.row)}
|
|
253
325
|
</text>
|
|
254
326
|
);
|
|
255
327
|
}
|
|
328
|
+
|
|
256
329
|
const lastRowIndex = segment.firstRowIndex + segment.rows.length - 1;
|
|
257
330
|
return (
|
|
258
331
|
<React.Fragment key={segmentIndex}>
|
|
259
|
-
<DiffChunk
|
|
332
|
+
<DiffChunk
|
|
333
|
+
segment={segment}
|
|
334
|
+
cursor={cursor}
|
|
335
|
+
focusedAnnotationId={focusedAnnotationId}
|
|
336
|
+
intralineByRow={intralineByRow}
|
|
337
|
+
syntaxByRow={syntaxByRow}
|
|
338
|
+
rejectedRows={rejectedRows}
|
|
339
|
+
theme={theme}
|
|
340
|
+
/>
|
|
260
341
|
{compose && compose.rowIndex === lastRowIndex ? (
|
|
261
|
-
<AnnotationCard
|
|
342
|
+
<AnnotationCard
|
|
343
|
+
kind={compose.kind}
|
|
344
|
+
quote={compose.quote}
|
|
345
|
+
draft={compose.draft}
|
|
346
|
+
theme={theme}
|
|
347
|
+
/>
|
|
262
348
|
) : null}
|
|
263
349
|
</React.Fragment>
|
|
264
350
|
);
|
|
@@ -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
|
};
|