@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68
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 +6 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +71 -39
- package/src/App.plan-review.test.tsx +20 -21
- package/src/App.readonly.test.tsx +7 -3
- package/src/App.test.tsx +73 -14
- package/src/App.tsx +375 -129
- package/src/App.walk.test.tsx +14 -15
- package/src/App.workbench.test.tsx +390 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +61 -0
- package/src/app-screens.tsx +176 -45
- package/src/app-view-model.ts +7 -65
- package/src/components/AnnotationCard.tsx +1 -1
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +47 -42
- package/src/components/AppShell.tsx +212 -100
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/ChangesColumn.tsx +49 -26
- package/src/components/DiffSheet.scroll.test.tsx +38 -55
- package/src/components/DiffSheet.split.test.tsx +140 -0
- package/src/components/DiffSheet.stories.tsx +55 -53
- package/src/components/DiffSheet.syntax.test.tsx +24 -7
- package/src/components/DiffSheet.tsx +607 -276
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +105 -0
- package/src/components/EditorGrid.tsx +338 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +78 -0
- package/src/components/InboxList.tsx +159 -30
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +82 -0
- package/src/components/PromptDialog.tsx +8 -1
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/SettingsDialog.tsx +2 -1
- package/src/components/SurfaceScrollbar.stories.tsx +34 -0
- package/src/components/SurfaceScrollbar.test.tsx +111 -0
- package/src/components/SurfaceScrollbar.tsx +93 -0
- package/src/components/ThreadFooter.stories.tsx +2 -2
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +12 -7
- package/src/components/ThreadView.tsx +62 -1440
- package/src/components/ThreadsSidebar.tsx +14 -1
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/WelcomeSurface.stories.tsx +12 -0
- package/src/components/WelcomeSurface.tsx +52 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
- package/src/components/diff-sheet-layout.test.ts +125 -55
- package/src/components/diff-sheet-layout.ts +3 -103
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +15 -0
- package/src/components/file-tree.ts +59 -4
- package/src/components/pages/Workspace.stories.tsx +2 -2
- package/src/components/primitives/IconButton.tsx +21 -2
- package/src/components/primitives/Tree.tsx +13 -4
- package/src/components/primitives/icons.ts +27 -4
- package/src/components/session-tree.test.ts +20 -0
- package/src/components/session-tree.ts +16 -2
- package/src/components/stories-app.tsx +2 -2
- package/src/config.test.ts +20 -49
- package/src/config.ts +26 -28
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-syntax.test.ts +16 -0
- package/src/diff-syntax.ts +6 -3
- package/src/discussions.ts +79 -0
- package/src/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- package/src/intent-dispatch.test.ts +31 -11
- package/src/intent-dispatch.ts +93 -48
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +3 -25
- package/src/keymap.ts +17 -18
- package/src/mark-runs.ts +120 -0
- package/src/pty.ts +10 -2
- package/src/run.ts +17 -7
- package/src/serve.test.ts +4 -8
- package/src/session-controller.curate.test.ts +75 -0
- package/src/session-controller.share.test.ts +1 -0
- package/src/session-controller.tree.test.ts +26 -0
- package/src/session-controller.ts +153 -29
- package/src/slash-palette.ts +83 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +15 -3
- package/src/test-support.ts +66 -15
- package/src/thread-chords.test.ts +25 -9
- package/src/thread-chords.ts +33 -8
- package/src/use-annotation-surface.tsx +837 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +14 -24
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/view-diff.ts +114 -0
- package/src/components/ReviewPanel.stories.tsx +0 -100
- package/src/components/ReviewPanel.tsx +0 -103
- package/src/components/ReviewRail.stories.tsx +0 -232
- package/src/components/ReviewRail.tsx +0 -327
- package/src/review-panel.test.ts +0 -113
- package/src/review-panel.ts +0 -83
|
@@ -1,36 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The diff review sheet:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
7
|
-
* curated-out
|
|
2
|
+
* The diff review sheet: the code surface where discussions live inline under
|
|
3
|
+
* the lines they mark, exactly as the plan thread view does for prose. The caret,
|
|
4
|
+
* click/drag marks, type-to-comment, the "/" palette, and the discussion cards
|
|
5
|
+
* come from the shared annotation surface; this sheet paints the diff's own rows -
|
|
6
|
+
* file bands, hunk headers, a line-number gutter with the change sign, syntax and
|
|
7
|
+
* intra-line colors, curated-out lines struck through - one text per visual line
|
|
8
|
+
* so a drag can hit-test any row, unified or side by side.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "@opentui/core";
|
|
17
|
-
import type { Annotation } from "@cueloop/schema";
|
|
18
|
-
import type { DiffRow } from "../view-diff";
|
|
12
|
+
import { createTextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
13
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
14
|
+
import { diffRowText, fileChangeCounts, type DiffRow, type Mark } from "../view-diff";
|
|
15
|
+
import type { TextSpan } from "../thread-selection";
|
|
16
|
+
import type { QuickAction } from "../config";
|
|
19
17
|
import type { Theme } from "../theme";
|
|
20
18
|
import { useComponentTheme } from "./theme-context";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
19
|
+
import { IconButton } from "./primitives/IconButton";
|
|
20
|
+
import { NERD } from "./primitives/icons";
|
|
24
21
|
import { intralineRunsByRow, type IntralineRun } from "../diff-intraline";
|
|
25
22
|
import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} from "./diff-sheet-layout";
|
|
23
|
+
import { splitDiffRows, type SplitLine, type SplitRow } from "../split-diff";
|
|
24
|
+
import { UNDERLINE, type AnnotationPalette } from "../annotation-palette";
|
|
25
|
+
import { lineMarkRanges, runsFor, wrapLines, type MarkRange } from "../mark-runs";
|
|
26
|
+
import { useFrameMeasure } from "../use-frame-measure";
|
|
27
|
+
import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
28
|
+
import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
|
|
29
|
+
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
30
|
+
import { coloredRowSpans } from "./diff-sheet-layout";
|
|
34
31
|
|
|
35
32
|
/** Shared empty map so an unresolved/stale highlight state is a stable value. */
|
|
36
33
|
const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
|
|
@@ -41,195 +38,318 @@ const EMPTY_REJECTED: Set<number> = new Set();
|
|
|
41
38
|
/** A rejected (curated-out) change row renders struck through and dimmed. */
|
|
42
39
|
const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
/** A file band renders its name in bold between an equal rule above and below. */
|
|
42
|
+
const FILE_HEADER_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
43
|
+
|
|
44
|
+
/** The gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
|
|
45
|
+
const GUTTER_COLUMNS = 8;
|
|
46
|
+
|
|
47
|
+
/** The per-file fold controls the file band renders; absent in read-only story renders. */
|
|
48
|
+
export interface DiffFoldControls {
|
|
49
|
+
isCollapsed: (file: string) => boolean;
|
|
50
|
+
isExpanded: (file: string) => boolean;
|
|
51
|
+
canExpand: (file: string) => boolean;
|
|
52
|
+
onToggleCollapse: (file: string) => void;
|
|
53
|
+
onToggleExpand: (file: string) => void;
|
|
54
|
+
onCopyPath: (file: string) => void;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export interface DiffSheetProps {
|
|
52
58
|
rows: DiffRow[];
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
session: ReviewSession;
|
|
60
|
+
/** Annotations resolved onto rows (marksByRows), with char ranges and spans. */
|
|
61
|
+
marks: Map<number, Mark[]>;
|
|
62
|
+
quickActions: QuickAction[];
|
|
63
|
+
observer: boolean;
|
|
64
|
+
/** A verdict is in: no draft may open; the app answers with its read-only status. */
|
|
65
|
+
resolved?: boolean;
|
|
66
|
+
/** True while a menu, dialog, or overlay owns the keyboard. */
|
|
67
|
+
suspended?: boolean;
|
|
68
|
+
/** Reports whether a composer is open, so session chords can yield to typing. */
|
|
69
|
+
onComposingChange?: (composing: boolean) => void;
|
|
70
|
+
/** An observer or a resolved review refused a draft; the app shows why. */
|
|
71
|
+
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
72
|
+
/** Reports the caret's row, so row-level primitives (reject, fold) act where the caret is. */
|
|
73
|
+
onCursorChange?: (rowIndex: number) => void;
|
|
74
|
+
/** The rail's focused card; the discussion holding it takes focus here. */
|
|
55
75
|
focusedAnnotationId?: string;
|
|
76
|
+
/** Reports the focused discussion's root comment, so the rail follows. */
|
|
77
|
+
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
78
|
+
onAnnotate: (span: TextSpan, body: string) => void;
|
|
79
|
+
onReply: (rootAnnotationId: string, body: string) => void;
|
|
80
|
+
onUpdateAnnotation: (id: string, body: string) => void;
|
|
81
|
+
onExit: () => void;
|
|
56
82
|
/** Row indices the owner rejected during curation; drawn struck through. */
|
|
57
83
|
rejectedRows?: Set<number>;
|
|
58
|
-
|
|
84
|
+
/** File-band chevron/copy/unfold actions; when absent the band shows no controls. */
|
|
85
|
+
fold?: DiffFoldControls;
|
|
86
|
+
/** Per-file +/- counts from the base rows, so a collapsed file keeps its badge; else computed here. */
|
|
87
|
+
fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
|
|
88
|
+
/** Render old|new side by side instead of one inline column; the App gates this on zoom. */
|
|
89
|
+
split?: boolean;
|
|
59
90
|
theme?: Theme;
|
|
60
91
|
}
|
|
61
92
|
|
|
62
|
-
/**
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
const baseColor =
|
|
66
|
-
row.kind === "add"
|
|
67
|
-
? tokens.insertedForeground
|
|
68
|
-
: row.kind === "del"
|
|
69
|
-
? tokens.deletedForeground
|
|
70
|
-
: tokens.textMuted;
|
|
71
|
-
const background = isCursorRow
|
|
72
|
-
? tokens.cursorBackground
|
|
73
|
-
: isAnnotatedRow
|
|
74
|
-
? tokens.markCommentBackground
|
|
75
|
-
: undefined;
|
|
76
|
-
|
|
77
|
-
return { sign, baseColor, background };
|
|
93
|
+
/** A row the caret can rest on and a comment can anchor to. */
|
|
94
|
+
function isCodeRow(row: DiffRow | undefined): boolean {
|
|
95
|
+
return row?.kind === "ctx" || row?.kind === "add" || row?.kind === "del";
|
|
78
96
|
}
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
syntaxByRow,
|
|
86
|
-
rejectedRows,
|
|
87
|
-
theme,
|
|
98
|
+
/** The green +N red -N badge pinned to the file band's right edge. */
|
|
99
|
+
function FileCountsBadge({
|
|
100
|
+
stats,
|
|
101
|
+
tokens,
|
|
102
|
+
marginRight,
|
|
88
103
|
}: {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
intralineByRow: Map<number, IntralineRun[]>;
|
|
93
|
-
syntaxByRow: Map<number, SyntaxSpan[]>;
|
|
94
|
-
rejectedRows: Set<number>;
|
|
95
|
-
theme?: Theme;
|
|
104
|
+
stats: { additions: number; deletions: number };
|
|
105
|
+
tokens: Theme;
|
|
106
|
+
marginRight: number;
|
|
96
107
|
}): React.ReactNode {
|
|
97
|
-
|
|
98
|
-
const gutterRef = useRef<LineNumberRenderable | null>(null);
|
|
99
|
-
const cursorInChunk = cursor - segment.firstRowIndex;
|
|
108
|
+
if (stats.additions === 0 && stats.deletions === 0) return null;
|
|
100
109
|
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
return (
|
|
111
|
+
<text style={{ flexShrink: 0, wrapMode: "none", marginRight }}>
|
|
112
|
+
{stats.additions > 0 ? (
|
|
113
|
+
<span fg={tokens.insertedForeground}>{`+${stats.additions}`}</span>
|
|
114
|
+
) : null}
|
|
115
|
+
{stats.additions > 0 && stats.deletions > 0 ? <span> </span> : null}
|
|
116
|
+
{stats.deletions > 0 ? (
|
|
117
|
+
<span fg={tokens.deletedForeground}>{`-${stats.deletions}`}</span>
|
|
118
|
+
) : null}
|
|
119
|
+
</text>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
103
122
|
|
|
104
|
-
|
|
105
|
-
|
|
123
|
+
/**
|
|
124
|
+
* A file band: chevron, bold name, and the added/removed counts between an equal rule above and
|
|
125
|
+
* below. `fold` absent (story renders) drops the controls.
|
|
126
|
+
*/
|
|
127
|
+
function FileBand({
|
|
128
|
+
row,
|
|
129
|
+
stats,
|
|
130
|
+
fold,
|
|
131
|
+
tokens,
|
|
132
|
+
}: {
|
|
133
|
+
row: DiffRow;
|
|
134
|
+
stats: { additions: number; deletions: number } | undefined;
|
|
135
|
+
fold: DiffFoldControls | undefined;
|
|
136
|
+
tokens: Theme;
|
|
137
|
+
}): React.ReactNode {
|
|
138
|
+
const file = row.file;
|
|
139
|
+
const collapsed = fold?.isCollapsed(file) ?? false;
|
|
140
|
+
const expanded = fold?.isExpanded(file) ?? false;
|
|
141
|
+
const canExpand = fold?.canExpand(file) ?? false;
|
|
106
142
|
|
|
107
|
-
|
|
108
|
-
}
|
|
143
|
+
return (
|
|
144
|
+
<box style={{ borderStyle: "single", border: ["top", "bottom"], borderColor: tokens.border }}>
|
|
145
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
|
146
|
+
<box style={{ flexDirection: "row", flexShrink: 1, minWidth: 0 }}>
|
|
147
|
+
{fold ? (
|
|
148
|
+
<IconButton
|
|
149
|
+
glyph={collapsed ? NERD.chevronRight : NERD.chevronDown}
|
|
150
|
+
onPress={() => fold.onToggleCollapse(file)}
|
|
151
|
+
tip={collapsed ? "Expand file" : "Collapse file"}
|
|
152
|
+
marginRight={1}
|
|
153
|
+
/>
|
|
154
|
+
) : null}
|
|
155
|
+
<text
|
|
156
|
+
fg={tokens.text}
|
|
157
|
+
attributes={FILE_HEADER_ATTRIBUTES}
|
|
158
|
+
style={{ flexShrink: 1, minWidth: 0, wrapMode: "none" }}
|
|
159
|
+
>
|
|
160
|
+
{diffRowText(row)}
|
|
161
|
+
</text>
|
|
162
|
+
</box>
|
|
163
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
164
|
+
{stats ? (
|
|
165
|
+
<FileCountsBadge stats={stats} tokens={tokens} marginRight={fold ? 1 : 0} />
|
|
166
|
+
) : null}
|
|
167
|
+
{fold ? (
|
|
168
|
+
<IconButton
|
|
169
|
+
glyph={NERD.copy}
|
|
170
|
+
onPress={() => fold.onCopyPath(file)}
|
|
171
|
+
tip="Copy path"
|
|
172
|
+
marginRight={canExpand ? 1 : 0}
|
|
173
|
+
/>
|
|
174
|
+
) : null}
|
|
175
|
+
{fold && canExpand ? (
|
|
176
|
+
<IconButton
|
|
177
|
+
glyph={NERD.unfold}
|
|
178
|
+
onPress={() => fold.onToggleExpand(file)}
|
|
179
|
+
tip={expanded ? "Collapse to changes" : "Expand all lines"}
|
|
180
|
+
/>
|
|
181
|
+
) : null}
|
|
182
|
+
</box>
|
|
183
|
+
</box>
|
|
184
|
+
</box>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
109
187
|
|
|
110
|
-
|
|
111
|
-
|
|
188
|
+
/** One code row rendered: its visual line rows, and the cards that hang under them. */
|
|
189
|
+
interface CodeRowNodes {
|
|
190
|
+
lines: React.ReactNode[];
|
|
191
|
+
cards: React.ReactNode[];
|
|
192
|
+
}
|
|
112
193
|
|
|
113
|
-
|
|
114
|
-
|
|
194
|
+
/** One side of a split pair rendered: its column of lines with its cards under them, or filler. */
|
|
195
|
+
interface SplitSideNodes {
|
|
196
|
+
node: React.ReactNode;
|
|
197
|
+
}
|
|
115
198
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
199
|
+
/**
|
|
200
|
+
* One item of the sheet's vertical layout: a base row (unified) or a split row, with the
|
|
201
|
+
* visual lines it occupies. Cards under a materialized row add to the real height; the
|
|
202
|
+
* model only has to be right enough for spacers and the caret reveal.
|
|
203
|
+
*/
|
|
204
|
+
interface LayoutItem {
|
|
205
|
+
/** Index into the unified rows, or into the split rows when side by side. */
|
|
206
|
+
index: number;
|
|
207
|
+
height: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface SheetLayout {
|
|
211
|
+
items: LayoutItem[];
|
|
212
|
+
/** Visual-line offset of each item from the top of the content. */
|
|
213
|
+
offsets: number[];
|
|
214
|
+
total: number;
|
|
215
|
+
/** The layout item that shows a given base row, for the caret reveal. */
|
|
216
|
+
itemOfRow: number[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Rows this many visual lines beyond the viewport stay materialized, so a scroll step never shows a gap. */
|
|
220
|
+
const OVERSCAN_LINES = 30;
|
|
221
|
+
|
|
222
|
+
/** A file band is three rows (rule, name, rule); a hunk header one. */
|
|
223
|
+
function headerHeight(row: DiffRow): number {
|
|
224
|
+
return row.kind === "file" ? 3 : 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function codeRowHeight(row: DiffRow, textWidth: number): number {
|
|
228
|
+
return wrapLines(diffRowText(row), textWidth).length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildLayout(items: LayoutItem[], itemOfRow: number[]): SheetLayout {
|
|
232
|
+
const offsets: number[] = [];
|
|
233
|
+
let total = 0;
|
|
234
|
+
|
|
235
|
+
for (const item of items) {
|
|
236
|
+
offsets.push(total);
|
|
237
|
+
total += item.height;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { items, offsets, total, itemOfRow };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function unifiedLayout(rows: DiffRow[], textWidth: number): SheetLayout {
|
|
244
|
+
const items = rows.map((row, index) => ({
|
|
245
|
+
index,
|
|
246
|
+
height: isCodeRow(row) ? codeRowHeight(row, textWidth) : headerHeight(row),
|
|
247
|
+
}));
|
|
122
248
|
|
|
123
|
-
|
|
124
|
-
|
|
249
|
+
return buildLayout(
|
|
250
|
+
items,
|
|
251
|
+
rows.map((_row, index) => index),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function splitLayout(splitRows: SplitRow[], rows: DiffRow[], textWidth: number): SheetLayout {
|
|
256
|
+
const itemOfRow: number[] = [];
|
|
257
|
+
const items = splitRows.map((pair, index) => {
|
|
258
|
+
if (pair.kind !== "pair") {
|
|
259
|
+
const row = rows[pair.rowIndex ?? -1];
|
|
125
260
|
|
|
126
|
-
|
|
127
|
-
// the renderable's config parser requires the gutter key to be present
|
|
128
|
-
const colors = new Map<number, { gutter: string | undefined; content: string }>();
|
|
261
|
+
if (pair.rowIndex !== undefined) itemOfRow[pair.rowIndex] = index;
|
|
129
262
|
|
|
130
|
-
|
|
131
|
-
colors.set(segment.rows.length - 1, {
|
|
132
|
-
gutter: undefined,
|
|
133
|
-
content: tokens.markCommentBackground,
|
|
134
|
-
});
|
|
263
|
+
return { index, height: row ? headerHeight(row) : 1 };
|
|
135
264
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
265
|
+
const sideHeight = (line: SplitLine | undefined): number =>
|
|
266
|
+
line ? codeRowHeight(line.row, textWidth) : 0;
|
|
267
|
+
|
|
268
|
+
if (pair.left) itemOfRow[pair.left.rowIndex] = index;
|
|
269
|
+
if (pair.right) itemOfRow[pair.right.rowIndex] = index;
|
|
270
|
+
|
|
271
|
+
return { index, height: Math.max(1, sideHeight(pair.left), sideHeight(pair.right)) };
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return buildLayout(items, itemOfRow);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One painted stretch of a code line: its text with foreground, backdrop, and attributes. */
|
|
278
|
+
interface CodeSpan {
|
|
279
|
+
text: string;
|
|
280
|
+
fg: string;
|
|
281
|
+
bg: string | undefined;
|
|
282
|
+
attributes: number;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The spans of one visual line: per character, the syntax or intra-line color under
|
|
287
|
+
* the mark treatment (backdrop + underline) or the caret cell, coalesced where equal.
|
|
288
|
+
* A rejected row paints one dim struck-through run, so it reads as excluded regardless
|
|
289
|
+
* of its colors; a mark still shows through it.
|
|
290
|
+
*/
|
|
291
|
+
function codeLineSpans(
|
|
292
|
+
lineText: string,
|
|
293
|
+
lineStart: number,
|
|
294
|
+
lineRanges: MarkRange[],
|
|
295
|
+
fgByColumn: string[],
|
|
296
|
+
rejected: boolean,
|
|
297
|
+
tokens: Theme,
|
|
298
|
+
palette: AnnotationPalette,
|
|
299
|
+
): CodeSpan[] {
|
|
300
|
+
const spans: CodeSpan[] = [];
|
|
301
|
+
let column = 0;
|
|
302
|
+
|
|
303
|
+
for (const run of runsFor(lineText, lineRanges)) {
|
|
304
|
+
const bg = run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined;
|
|
305
|
+
const attributes = (run.marked ? UNDERLINE : 0) | (rejected ? REJECTED_ATTRIBUTES : 0);
|
|
306
|
+
|
|
307
|
+
for (const character of run.text) {
|
|
308
|
+
const fg = rejected ? tokens.textDim : (fgByColumn[lineStart + column] ?? tokens.textMuted);
|
|
309
|
+
const previous = spans[spans.length - 1];
|
|
310
|
+
|
|
311
|
+
if (
|
|
312
|
+
previous &&
|
|
313
|
+
previous.fg === fg &&
|
|
314
|
+
previous.bg === bg &&
|
|
315
|
+
previous.attributes === attributes
|
|
316
|
+
) {
|
|
317
|
+
previous.text += character;
|
|
318
|
+
} else spans.push({ text: character, fg, bg, attributes });
|
|
319
|
+
column++;
|
|
141
320
|
}
|
|
321
|
+
}
|
|
142
322
|
|
|
143
|
-
|
|
144
|
-
|
|
323
|
+
return spans;
|
|
324
|
+
}
|
|
145
325
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
326
|
+
/** Per-column foreground of a row's text: intra-line change color over syntax over the base. */
|
|
327
|
+
function foregroundColumns(
|
|
328
|
+
text: string,
|
|
329
|
+
baseColor: string,
|
|
330
|
+
intraline: IntralineRun[] | undefined,
|
|
331
|
+
syntax: SyntaxSpan[] | undefined,
|
|
332
|
+
tokens: Theme,
|
|
333
|
+
): string[] {
|
|
334
|
+
const columns: string[] = [];
|
|
335
|
+
|
|
336
|
+
for (const span of coloredRowSpans(text, intraline, syntax, baseColor, tokens)) {
|
|
337
|
+
for (let index = 0; index < span.text.length; index++) columns.push(span.foreground);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return columns;
|
|
341
|
+
}
|
|
149
342
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
gutter.clearAllLineColors();
|
|
154
|
-
for (const [line, colorConfig] of lineColors) gutter.setLineColor(line, colorConfig);
|
|
155
|
-
}, [lineNumbers, lineSigns, lineColors]);
|
|
343
|
+
function rowSign(row: DiffRow): string {
|
|
344
|
+
return row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
|
|
345
|
+
}
|
|
156
346
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
minWidth={4}
|
|
164
|
-
paddingRight={1}
|
|
165
|
-
lineNumbers={lineNumbers}
|
|
166
|
-
lineSigns={lineSigns}
|
|
167
|
-
>
|
|
168
|
-
<text style={{ wrapMode: "none" }} selectable={false}>
|
|
169
|
-
{segment.rows.map((row, lineIndex) => {
|
|
170
|
-
const isCursorRow = lineIndex === cursorInChunk;
|
|
171
|
-
const isAnnotatedRow =
|
|
172
|
-
segment.annotation !== null && lineIndex === segment.rows.length - 1;
|
|
173
|
-
const { sign, baseColor, background } = rowStyle(
|
|
174
|
-
row,
|
|
175
|
-
isCursorRow,
|
|
176
|
-
isAnnotatedRow,
|
|
177
|
-
tokens,
|
|
178
|
-
);
|
|
179
|
-
const prefix = (lineIndex > 0 ? "\n" : "") + sign;
|
|
180
|
-
const absoluteRowIndex = segment.firstRowIndex + lineIndex;
|
|
181
|
-
|
|
182
|
-
// A rejected change collapses to one struck-through, dimmed span, so
|
|
183
|
-
// it reads as excluded regardless of its syntax or intra-line colors.
|
|
184
|
-
if (rejectedRows.has(absoluteRowIndex)) {
|
|
185
|
-
return (
|
|
186
|
-
<span
|
|
187
|
-
key={lineIndex}
|
|
188
|
-
fg={tokens.textDim}
|
|
189
|
-
bg={background}
|
|
190
|
-
attributes={REJECTED_ATTRIBUTES}
|
|
191
|
-
>
|
|
192
|
-
{prefix + rowLine(row)}
|
|
193
|
-
</span>
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const spans = coloredRowSpans(
|
|
198
|
-
rowLine(row),
|
|
199
|
-
intralineByRow.get(absoluteRowIndex),
|
|
200
|
-
syntaxByRow.get(absoluteRowIndex),
|
|
201
|
-
baseColor,
|
|
202
|
-
tokens,
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
return (
|
|
206
|
-
<React.Fragment key={lineIndex}>
|
|
207
|
-
<span fg={baseColor} bg={background}>
|
|
208
|
-
{prefix}
|
|
209
|
-
</span>
|
|
210
|
-
{spans.map((span, spanIndex) => (
|
|
211
|
-
<span key={spanIndex} fg={span.foreground} bg={background}>
|
|
212
|
-
{span.text}
|
|
213
|
-
</span>
|
|
214
|
-
))}
|
|
215
|
-
</React.Fragment>
|
|
216
|
-
);
|
|
217
|
-
})}
|
|
218
|
-
</text>
|
|
219
|
-
</line-number>
|
|
220
|
-
|
|
221
|
-
{segment.annotation ? (
|
|
222
|
-
// one row exactly: wrapMode none keeps the model's 1-line-per-card offset
|
|
223
|
-
// honest, so the cursor-follow scroll never drifts past a wrapped card
|
|
224
|
-
<text style={{ wrapMode: "none" }}>
|
|
225
|
-
<span fg={tokens.textDim}>{" "}</span>
|
|
226
|
-
<span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
|
|
227
|
-
◆ {truncateToSingleLine(segment.annotation.body, 70)}
|
|
228
|
-
</span>
|
|
229
|
-
</text>
|
|
230
|
-
) : null}
|
|
231
|
-
</box>
|
|
232
|
-
);
|
|
347
|
+
function rowBaseColor(row: DiffRow, tokens: Theme): string {
|
|
348
|
+
return row.kind === "add"
|
|
349
|
+
? tokens.insertedForeground
|
|
350
|
+
: row.kind === "del"
|
|
351
|
+
? tokens.deletedForeground
|
|
352
|
+
: tokens.textMuted;
|
|
233
353
|
}
|
|
234
354
|
|
|
235
355
|
/** Async tree-sitter highlights, discarded when they belong to superseded rows. */
|
|
@@ -259,111 +379,322 @@ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
|
|
|
259
379
|
|
|
260
380
|
export function DiffSheet({
|
|
261
381
|
rows,
|
|
262
|
-
|
|
263
|
-
|
|
382
|
+
session,
|
|
383
|
+
marks,
|
|
384
|
+
quickActions,
|
|
385
|
+
observer,
|
|
386
|
+
resolved = false,
|
|
387
|
+
suspended = false,
|
|
388
|
+
onComposingChange,
|
|
389
|
+
onObserverBlocked,
|
|
390
|
+
onCursorChange,
|
|
264
391
|
focusedAnnotationId,
|
|
392
|
+
onFocusAnnotation,
|
|
393
|
+
onAnnotate,
|
|
394
|
+
onReply,
|
|
395
|
+
onUpdateAnnotation,
|
|
396
|
+
onExit,
|
|
265
397
|
rejectedRows = EMPTY_REJECTED,
|
|
266
|
-
|
|
398
|
+
fold,
|
|
399
|
+
fileStats: fileStatsProp,
|
|
400
|
+
split = false,
|
|
267
401
|
theme,
|
|
268
402
|
}: DiffSheetProps): React.ReactNode {
|
|
269
403
|
const tokens = useComponentTheme(theme);
|
|
270
404
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
() =>
|
|
274
|
-
[
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
405
|
+
const source: LineSource = {
|
|
406
|
+
count: rows.length,
|
|
407
|
+
textAt: (rowIndex) => diffRowText(rows[rowIndex]!),
|
|
408
|
+
annotatable: (rowIndex) => isCodeRow(rows[rowIndex]),
|
|
409
|
+
};
|
|
410
|
+
const surface = useAnnotationSurface({
|
|
411
|
+
source,
|
|
412
|
+
session,
|
|
413
|
+
marks,
|
|
414
|
+
quickActions,
|
|
415
|
+
tokens,
|
|
416
|
+
observer,
|
|
417
|
+
resolved,
|
|
418
|
+
suspended,
|
|
419
|
+
onComposingChange,
|
|
420
|
+
onObserverBlocked,
|
|
421
|
+
onCursorChange,
|
|
422
|
+
focusedAnnotationId,
|
|
423
|
+
onFocusAnnotation,
|
|
424
|
+
onAnnotate,
|
|
425
|
+
onReply,
|
|
426
|
+
onUpdateAnnotation,
|
|
427
|
+
onExit,
|
|
428
|
+
});
|
|
429
|
+
const { palette } = surface;
|
|
280
430
|
const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
|
|
281
431
|
const syntaxByRow = useSyntaxHighlights(rows);
|
|
282
|
-
const
|
|
432
|
+
const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
|
|
433
|
+
const localStats = useMemo(() => fileChangeCounts(rows), [rows]);
|
|
434
|
+
// base-row counts survive a collapse (folded rows drop the file's add/del rows); fall back locally
|
|
435
|
+
const fileStats = fileStatsProp ?? localStats;
|
|
436
|
+
const viewWidth = useFrameMeasure(
|
|
437
|
+
() => scrollRef.current?.content?.width ?? 0,
|
|
438
|
+
(left, right) => left === right,
|
|
439
|
+
0,
|
|
440
|
+
);
|
|
441
|
+
// two columns and a one-cell divider share the width; each column keeps its own gutter
|
|
442
|
+
const textWidth = split
|
|
443
|
+
? Math.max(0, Math.floor((viewWidth - 1) / 2) - GUTTER_COLUMNS)
|
|
444
|
+
: Math.max(0, viewWidth - GUTTER_COLUMNS);
|
|
445
|
+
|
|
446
|
+
// Every visual line is its own text renderable (so a drag can hit-test it), and a large
|
|
447
|
+
// diff has tens of thousands of them - more native text buffers than the renderer can
|
|
448
|
+
// hold. So the sheet is virtual: only the viewport plus an overscan is mounted, each row
|
|
449
|
+
// estimated at its wrapped line count until its box (cards included) is measured.
|
|
450
|
+
const layout = useMemo(
|
|
451
|
+
() => (split ? splitLayout(splitRows, rows, textWidth) : unifiedLayout(rows, textWidth)),
|
|
452
|
+
[split, splitRows, rows, textWidth],
|
|
453
|
+
);
|
|
454
|
+
const virtual = useTerminalVirtualizer({
|
|
455
|
+
scrollbox: scrollRef,
|
|
456
|
+
count: layout.items.length,
|
|
457
|
+
estimateSize: (index) => layout.items[index]?.height ?? 1,
|
|
458
|
+
overscan: OVERSCAN_LINES,
|
|
459
|
+
});
|
|
283
460
|
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const cursorOffset = rowOffsets[cursor];
|
|
461
|
+
// an opening card shifts the layout, so the row it belongs to is revealed again; a
|
|
462
|
+
// discussion focused from the rail is scrolled into view the same way
|
|
463
|
+
const revealItem = layout.itemOfRow[surface.revealBlockIndex];
|
|
288
464
|
|
|
289
|
-
|
|
290
|
-
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
if (revealItem !== undefined) virtual.scrollToIndex(revealItem);
|
|
467
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
468
|
+
}, [surface.revealBlockIndex]);
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The visual lines of one code row painted with gutter, colors, and marks; cards collected after.
|
|
472
|
+
* `paintMarks` false renders the line plain (no caret, no mark backdrop) - the split view uses it so
|
|
473
|
+
* a context row, shared by both columns, only carries the caret and marks on the change side.
|
|
474
|
+
*/
|
|
475
|
+
const codeRowLines = (
|
|
476
|
+
row: DiffRow,
|
|
477
|
+
rowIndex: number,
|
|
478
|
+
textWidth: number,
|
|
479
|
+
keyPrefix: string,
|
|
480
|
+
paintMarks = true,
|
|
481
|
+
): CodeRowNodes => {
|
|
482
|
+
const text = diffRowText(row);
|
|
483
|
+
const lines = wrapLines(text, textWidth);
|
|
484
|
+
const ranges = paintMarks ? surface.rangesFor(rowIndex) : [];
|
|
485
|
+
const fgByColumn = foregroundColumns(
|
|
486
|
+
text,
|
|
487
|
+
rowBaseColor(row, tokens),
|
|
488
|
+
intralineByRow.get(rowIndex),
|
|
489
|
+
syntaxByRow.get(rowIndex),
|
|
490
|
+
tokens,
|
|
491
|
+
);
|
|
492
|
+
const rejected = rejectedRows.has(rowIndex);
|
|
493
|
+
const isCaretRow = paintMarks && surface.head.blockIndex === rowIndex;
|
|
494
|
+
const lineNumber = row.kind === "del" ? row.oldLine : row.newLine;
|
|
495
|
+
const gutter = `${isCaretRow ? "▎" : " "}${String(lineNumber ?? "").padStart(4, " ")} ${rowSign(row)} `;
|
|
496
|
+
const lineNodes: React.ReactNode[] = [];
|
|
497
|
+
const cards: React.ReactNode[] = [];
|
|
498
|
+
|
|
499
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
500
|
+
const line = lines[lineIndex]!;
|
|
501
|
+
const isLastLine = lineIndex === lines.length - 1;
|
|
502
|
+
|
|
503
|
+
lineNodes.push(
|
|
504
|
+
<box key={`${keyPrefix}-line-${lineIndex}`} style={{ flexDirection: "row" }}>
|
|
505
|
+
<text
|
|
506
|
+
selectable={false}
|
|
507
|
+
fg={isCaretRow && lineIndex === 0 ? tokens.accent : tokens.textDim}
|
|
508
|
+
style={{ flexShrink: 0, wrapMode: "none" }}
|
|
509
|
+
>
|
|
510
|
+
{lineIndex === 0 ? (
|
|
511
|
+
<>
|
|
512
|
+
<span fg={isCaretRow ? tokens.accent : tokens.textDim}>{gutter.slice(0, 6)}</span>
|
|
513
|
+
<span fg={rowBaseColor(row, tokens)}>{gutter.slice(6)}</span>
|
|
514
|
+
</>
|
|
515
|
+
) : (
|
|
516
|
+
" ".repeat(GUTTER_COLUMNS)
|
|
517
|
+
)}
|
|
518
|
+
</text>
|
|
519
|
+
<text
|
|
520
|
+
selectable={false}
|
|
521
|
+
style={{ flexGrow: 1, flexShrink: 1, wrapMode: "none" }}
|
|
522
|
+
ref={surface.registerLine(rowIndex, lineIndex, line)}
|
|
523
|
+
onMouseDown={surface.onLineMouseDown}
|
|
524
|
+
>
|
|
525
|
+
{codeLineSpans(
|
|
526
|
+
text.slice(line.start, line.end),
|
|
527
|
+
line.start,
|
|
528
|
+
lineMarkRanges(ranges, line),
|
|
529
|
+
fgByColumn,
|
|
530
|
+
rejected,
|
|
531
|
+
tokens,
|
|
532
|
+
palette,
|
|
533
|
+
).map((span, spanIndex) => (
|
|
534
|
+
<span key={spanIndex} fg={span.fg} bg={span.bg} attributes={span.attributes}>
|
|
535
|
+
{span.text}
|
|
536
|
+
</span>
|
|
537
|
+
))}
|
|
538
|
+
</text>
|
|
539
|
+
</box>,
|
|
540
|
+
);
|
|
541
|
+
// cards render on the side that owns the row, so a shared context row's card is not duplicated
|
|
542
|
+
if (paintMarks) cards.push(...surface.cardsAfterLine(rowIndex, line, isLastLine));
|
|
543
|
+
}
|
|
291
544
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
545
|
+
return { lines: lineNodes, cards };
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const headerNode = (row: DiffRow, rowIndex: number): React.ReactNode =>
|
|
549
|
+
row.kind === "file" ? (
|
|
550
|
+
<box key={rowIndex} id={`diff-row-${rowIndex}`}>
|
|
551
|
+
<FileBand row={row} stats={fileStats.get(row.file)} fold={fold} tokens={tokens} />
|
|
552
|
+
</box>
|
|
553
|
+
) : (
|
|
554
|
+
<text
|
|
555
|
+
key={rowIndex}
|
|
556
|
+
id={`diff-row-${rowIndex}`}
|
|
557
|
+
fg={tokens.blue}
|
|
558
|
+
style={{ wrapMode: "none" }}
|
|
559
|
+
>
|
|
560
|
+
{` ${diffRowText(row)}`}
|
|
561
|
+
</text>
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const unifiedItem = (rowIndex: number): React.ReactNode => {
|
|
565
|
+
const row = rows[rowIndex]!;
|
|
566
|
+
|
|
567
|
+
if (!isCodeRow(row)) return headerNode(row, rowIndex);
|
|
568
|
+
const { lines, cards } = codeRowLines(row, rowIndex, textWidth, `row-${rowIndex}`);
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<box key={rowIndex} id={`diff-row-${rowIndex}`} style={{ flexDirection: "column" }}>
|
|
572
|
+
{lines}
|
|
573
|
+
{cards}
|
|
574
|
+
</box>
|
|
575
|
+
);
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
/** One side of a split pair: the row's visual lines, or blank filler that reads as absent. */
|
|
579
|
+
const splitSide = (
|
|
580
|
+
line: SplitLine | undefined,
|
|
581
|
+
side: "left" | "right",
|
|
582
|
+
textWidth: number,
|
|
583
|
+
keyPrefix: string,
|
|
584
|
+
): SplitSideNodes => {
|
|
585
|
+
if (!line) {
|
|
586
|
+
const filler = (
|
|
587
|
+
<box style={{ flexGrow: 1, flexBasis: 0, minWidth: 0, backgroundColor: tokens.panel }} />
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
return { node: filler };
|
|
591
|
+
}
|
|
592
|
+
// the change side (right: additions and context) owns the caret and marks; the left carries them
|
|
593
|
+
// only on its deletions, so a context row shared by both columns is not marked twice
|
|
594
|
+
const paintMarks = side === "right" || line.kind === "del";
|
|
595
|
+
const { lines, cards } = codeRowLines(
|
|
596
|
+
line.row,
|
|
597
|
+
line.rowIndex,
|
|
598
|
+
textWidth,
|
|
599
|
+
keyPrefix,
|
|
600
|
+
paintMarks,
|
|
601
|
+
);
|
|
602
|
+
// a discussion stays in the pane of the side it annotates, under that side's lines
|
|
603
|
+
const column = (
|
|
604
|
+
<box style={{ flexDirection: "column", flexGrow: 1, flexBasis: 0, minWidth: 0 }}>
|
|
605
|
+
{lines}
|
|
606
|
+
{cards}
|
|
607
|
+
</box>
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
return { node: column };
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const splitItem = (pairIndex: number): React.ReactNode => {
|
|
614
|
+
const pair: SplitRow = splitRows[pairIndex]!;
|
|
615
|
+
|
|
616
|
+
if (pair.kind !== "pair") {
|
|
617
|
+
const rowIndex = pair.rowIndex ?? -1;
|
|
618
|
+
|
|
619
|
+
return headerNode(
|
|
620
|
+
rows[rowIndex] ?? { kind: pair.kind, text: pair.file, file: pair.file },
|
|
621
|
+
rowIndex,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
const left = splitSide(pair.left, "left", textWidth, `pair-${pairIndex}-left`);
|
|
625
|
+
const right = splitSide(pair.right, "right", textWidth, `pair-${pairIndex}-right`);
|
|
626
|
+
// both sides answer to the pair's id, so a reveal of either base row lands here
|
|
627
|
+
const anchorRow = pair.right?.rowIndex ?? pair.left?.rowIndex ?? -1;
|
|
628
|
+
const otherRow = pair.left?.rowIndex;
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<box
|
|
632
|
+
key={`pair-${pairIndex}`}
|
|
633
|
+
id={`diff-row-${anchorRow}`}
|
|
634
|
+
style={{ flexDirection: "column" }}
|
|
635
|
+
>
|
|
636
|
+
{/* stretch so the divider box grows to the taller side's wrapped height, leaving no gap */}
|
|
637
|
+
<box
|
|
638
|
+
id={otherRow !== undefined && otherRow !== anchorRow ? `diff-row-${otherRow}` : undefined}
|
|
639
|
+
style={{ flexDirection: "row", alignItems: "stretch" }}
|
|
640
|
+
>
|
|
641
|
+
{left.node}
|
|
642
|
+
<box
|
|
643
|
+
style={{
|
|
644
|
+
flexShrink: 0,
|
|
645
|
+
borderStyle: "single",
|
|
646
|
+
border: ["left"],
|
|
647
|
+
borderColor: tokens.border,
|
|
648
|
+
}}
|
|
649
|
+
/>
|
|
650
|
+
{right.node}
|
|
651
|
+
</box>
|
|
652
|
+
</box>
|
|
653
|
+
);
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const materialized: React.ReactNode[] = [];
|
|
657
|
+
const firstItem = virtual.items[0];
|
|
658
|
+
const lastItem = virtual.items[virtual.items.length - 1];
|
|
659
|
+
|
|
660
|
+
if (firstItem && lastItem) {
|
|
661
|
+
const below = virtual.totalSize - lastItem.end;
|
|
662
|
+
|
|
663
|
+
if (firstItem.start > 0) {
|
|
664
|
+
materialized.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
|
|
665
|
+
}
|
|
666
|
+
for (const item of virtual.items) {
|
|
667
|
+
// the wrapper is what gets measured, so a row's cards count toward its height
|
|
668
|
+
materialized.push(
|
|
669
|
+
<box key={`item-${item.index}`} ref={virtual.measureRef(item.index)}>
|
|
670
|
+
{split ? splitItem(item.index) : unifiedItem(item.index)}
|
|
671
|
+
</box>,
|
|
672
|
+
);
|
|
296
673
|
}
|
|
297
|
-
|
|
674
|
+
if (below > 0) materialized.push(<box key="spacer-below" style={{ height: below }} />);
|
|
675
|
+
}
|
|
298
676
|
|
|
299
677
|
return (
|
|
300
|
-
<box
|
|
301
|
-
style={{
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
fg={tokens.text}
|
|
321
|
-
bg={isCursor ? tokens.cursorBackground : tokens.panel}
|
|
322
|
-
style={{ wrapMode: "none" }}
|
|
323
|
-
>
|
|
324
|
-
{isCursor ? "▎" : " "}■ {rowLine(segment.row)}
|
|
325
|
-
</text>
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return (
|
|
330
|
-
<text
|
|
331
|
-
key={segmentIndex}
|
|
332
|
-
fg={tokens.blue}
|
|
333
|
-
bg={isCursor ? tokens.cursorBackground : undefined}
|
|
334
|
-
style={{ wrapMode: "none" }}
|
|
335
|
-
>
|
|
336
|
-
{isCursor ? "▎" : " "}
|
|
337
|
-
{rowLine(segment.row)}
|
|
338
|
-
</text>
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const lastRowIndex = segment.firstRowIndex + segment.rows.length - 1;
|
|
343
|
-
|
|
344
|
-
return (
|
|
345
|
-
<React.Fragment key={segmentIndex}>
|
|
346
|
-
<DiffChunk
|
|
347
|
-
segment={segment}
|
|
348
|
-
cursor={cursor}
|
|
349
|
-
focusedAnnotationId={focusedAnnotationId}
|
|
350
|
-
intralineByRow={intralineByRow}
|
|
351
|
-
syntaxByRow={syntaxByRow}
|
|
352
|
-
rejectedRows={rejectedRows}
|
|
353
|
-
theme={theme}
|
|
354
|
-
/>
|
|
355
|
-
{compose && compose.rowIndex === lastRowIndex ? (
|
|
356
|
-
<AnnotationCard
|
|
357
|
-
kind={compose.kind}
|
|
358
|
-
quote={compose.quote}
|
|
359
|
-
draft={compose.draft}
|
|
360
|
-
theme={theme}
|
|
361
|
-
/>
|
|
362
|
-
) : null}
|
|
363
|
-
</React.Fragment>
|
|
364
|
-
);
|
|
365
|
-
})}
|
|
366
|
-
</scrollbox>
|
|
678
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
|
|
679
|
+
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 0 }}>
|
|
680
|
+
{/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
|
|
681
|
+
<scrollbox
|
|
682
|
+
id="diff-scroll"
|
|
683
|
+
ref={scrollRef}
|
|
684
|
+
style={{ flexGrow: 1 }}
|
|
685
|
+
focused={false}
|
|
686
|
+
verticalScrollbarOptions={{ visible: false }}
|
|
687
|
+
>
|
|
688
|
+
{materialized}
|
|
689
|
+
</scrollbox>
|
|
690
|
+
</box>
|
|
691
|
+
<DiscussionMarkerRail
|
|
692
|
+
discussions={surface.discussions}
|
|
693
|
+
spanQuote={surface.spanQuote}
|
|
694
|
+
onJump={surface.jumpToDiscussion}
|
|
695
|
+
scrollbox={scrollRef}
|
|
696
|
+
theme={theme}
|
|
697
|
+
/>
|
|
367
698
|
</box>
|
|
368
699
|
);
|
|
369
700
|
}
|