@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1402 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +774 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- 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 +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +28 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +67 -0
- package/src/launch-layout.ts +47 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@opentui/core";
|
|
13
|
+
import type { Annotation, Thread } 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";
|
|
17
|
+
import type { Theme } from "../theme";
|
|
18
|
+
import { useComponentTheme } from "./theme-context";
|
|
19
|
+
import { IconButton } from "./primitives/IconButton";
|
|
20
|
+
import { NERD } from "./primitives/icons";
|
|
21
|
+
import { createIntralineResolver, type IntralineRun } from "../diff-intraline";
|
|
22
|
+
import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
|
|
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 { scrollBoxDragViewport, useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
28
|
+
import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
|
|
29
|
+
import { NavModeHint } from "./NavModeHint";
|
|
30
|
+
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
31
|
+
import { coloredRowSpans } from "./diff-content-view-layout";
|
|
32
|
+
|
|
33
|
+
/** Shared empty map so an unresolved/stale highlight state is a stable value. */
|
|
34
|
+
const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
|
|
35
|
+
|
|
36
|
+
/** Shared empty set so "nothing rejected" is a stable identity for renders. */
|
|
37
|
+
const EMPTY_REJECTED: Set<number> = new Set();
|
|
38
|
+
|
|
39
|
+
/** A rejected (curated-out) change row renders struck through and dimmed. */
|
|
40
|
+
const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
|
|
41
|
+
|
|
42
|
+
/** A file band renders its name in bold between an equal rule above and below. */
|
|
43
|
+
const FILE_HEADER_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
44
|
+
|
|
45
|
+
/** The split-view gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
|
|
46
|
+
const GUTTER_COLUMNS = 8;
|
|
47
|
+
|
|
48
|
+
/** The stacked gutter around its two line-number columns: caret bar, the space between them, a space, the sign, a space. */
|
|
49
|
+
const UNIFIED_GUTTER_CHROME = 5;
|
|
50
|
+
|
|
51
|
+
/** The widest line number in the diff, so the stacked gutter's two columns stay as narrow as the file allows. */
|
|
52
|
+
function lineNumberWidth(rows: DiffRow[]): number {
|
|
53
|
+
let width = 1;
|
|
54
|
+
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
if (row.oldLine !== undefined) width = Math.max(width, String(row.oldLine).length);
|
|
57
|
+
if (row.newLine !== undefined) width = Math.max(width, String(row.newLine).length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return width;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The per-file fold controls the file band renders; absent in read-only story renders. */
|
|
64
|
+
export interface DiffFoldControls {
|
|
65
|
+
isCollapsed: (file: string) => boolean;
|
|
66
|
+
isExpanded: (file: string) => boolean;
|
|
67
|
+
canExpand: (file: string) => boolean;
|
|
68
|
+
onToggleCollapse: (file: string) => void;
|
|
69
|
+
onToggleExpand: (file: string) => void;
|
|
70
|
+
onCopyPath: (file: string) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DiffContentViewProps {
|
|
74
|
+
rows: DiffRow[];
|
|
75
|
+
session: Thread;
|
|
76
|
+
/** Annotations resolved onto rows (marksByRows), with char ranges and spans. */
|
|
77
|
+
marks: Map<number, Mark[]>;
|
|
78
|
+
quickActions: QuickAction[];
|
|
79
|
+
observer: boolean;
|
|
80
|
+
/** Whether comments can be drafted here; false for a non-diff thread's view-only live diff. */
|
|
81
|
+
commentsEnabled?: boolean;
|
|
82
|
+
/** A message is in: no draft may open; the app answers with its read-only status. */
|
|
83
|
+
resolved?: boolean;
|
|
84
|
+
/** True while a menu, dialog, or overlay owns the keyboard. */
|
|
85
|
+
suspended?: boolean;
|
|
86
|
+
/** Reports whether a composer is open, so session chords can yield to typing. */
|
|
87
|
+
onComposingChange?: (composing: boolean) => void;
|
|
88
|
+
/** An observer or a resolved review refused a draft; the app shows why. */
|
|
89
|
+
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
90
|
+
/** Reports the caret's row, so row-level primitives (reject, fold) act where the caret is. */
|
|
91
|
+
onCursorChange?: (rowIndex: number) => void;
|
|
92
|
+
/** The rail's focused card; the discussion holding it takes focus here. */
|
|
93
|
+
focusedAnnotationId?: string;
|
|
94
|
+
/** Reports the focused discussion's root comment, so the rail follows. */
|
|
95
|
+
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
96
|
+
onAnnotate: (span: TextSpan, body: string) => void;
|
|
97
|
+
onReply: (rootAnnotationId: string, body: string) => void;
|
|
98
|
+
onUpdateAnnotation: (id: string, body: string) => void;
|
|
99
|
+
/** The author's display name for a comment's hover tooltip. */
|
|
100
|
+
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
101
|
+
onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
|
|
102
|
+
onExit: () => void;
|
|
103
|
+
/** Row indices the owner rejected during curation; drawn struck through. */
|
|
104
|
+
rejectedRows?: Set<number>;
|
|
105
|
+
/** File-band chevron/copy/unfold actions; when absent the band shows no controls. */
|
|
106
|
+
fold?: DiffFoldControls;
|
|
107
|
+
/** Per-file +/- counts from the base rows, so a collapsed file keeps its badge; else computed here. */
|
|
108
|
+
fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
|
|
109
|
+
/** Render old|new side by side instead of one inline column; the App gates this on zoom. */
|
|
110
|
+
split?: boolean;
|
|
111
|
+
/** A plain-file view (all context rows): one line-number gutter, no +/- sign. */
|
|
112
|
+
fileView?: boolean;
|
|
113
|
+
theme?: Theme;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A row the caret can rest on and a comment can anchor to. */
|
|
117
|
+
function isCodeRow(row: DiffRow | undefined): boolean {
|
|
118
|
+
return row?.kind === "ctx" || row?.kind === "add" || row?.kind === "del";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The green +N red -N badge pinned to the file band's right edge. */
|
|
122
|
+
function FileCountsBadge({
|
|
123
|
+
stats,
|
|
124
|
+
tokens,
|
|
125
|
+
marginRight,
|
|
126
|
+
}: {
|
|
127
|
+
stats: { additions: number; deletions: number };
|
|
128
|
+
tokens: Theme;
|
|
129
|
+
marginRight: number;
|
|
130
|
+
}): React.ReactNode {
|
|
131
|
+
if (stats.additions === 0 && stats.deletions === 0) return null;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<text style={{ flexShrink: 0, wrapMode: "none", marginRight }}>
|
|
135
|
+
{stats.additions > 0 ? (
|
|
136
|
+
<span fg={tokens.insertedForeground}>{`+${stats.additions}`}</span>
|
|
137
|
+
) : null}
|
|
138
|
+
{stats.additions > 0 && stats.deletions > 0 ? <span> </span> : null}
|
|
139
|
+
{stats.deletions > 0 ? (
|
|
140
|
+
<span fg={tokens.deletedForeground}>{`-${stats.deletions}`}</span>
|
|
141
|
+
) : null}
|
|
142
|
+
</text>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The file band's expand-all-lines toggle: the word "expand" stays put, its color carries the state
|
|
147
|
+
* (active white when the file is expanded, inactive gray when collapsed to changes) so nothing shifts. */
|
|
148
|
+
function ExpandToggle({
|
|
149
|
+
expanded,
|
|
150
|
+
onToggle,
|
|
151
|
+
tokens,
|
|
152
|
+
}: {
|
|
153
|
+
expanded: boolean;
|
|
154
|
+
onToggle: () => void;
|
|
155
|
+
tokens: Theme;
|
|
156
|
+
}): React.ReactNode {
|
|
157
|
+
return (
|
|
158
|
+
<box onMouseUp={onToggle} style={{ alignSelf: "center" }}>
|
|
159
|
+
<text fg={expanded ? tokens.text : tokens.textDim} style={{ wrapMode: "none" }}>
|
|
160
|
+
expand
|
|
161
|
+
</text>
|
|
162
|
+
</box>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A file band: chevron, bold name, and the added/removed counts between an equal rule above and
|
|
168
|
+
* below. `fold` absent (story renders) drops the controls.
|
|
169
|
+
*/
|
|
170
|
+
function FileBand({
|
|
171
|
+
row,
|
|
172
|
+
stats,
|
|
173
|
+
fold,
|
|
174
|
+
tokens,
|
|
175
|
+
}: {
|
|
176
|
+
row: DiffRow;
|
|
177
|
+
stats: { additions: number; deletions: number } | undefined;
|
|
178
|
+
fold: DiffFoldControls | undefined;
|
|
179
|
+
tokens: Theme;
|
|
180
|
+
}): React.ReactNode {
|
|
181
|
+
const file = row.file;
|
|
182
|
+
const collapsed = fold?.isCollapsed(file) ?? false;
|
|
183
|
+
const expanded = fold?.isExpanded(file) ?? false;
|
|
184
|
+
const canExpand = fold?.canExpand(file) ?? false;
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<box style={{ borderStyle: "single", border: ["top", "bottom"], borderColor: tokens.border }}>
|
|
188
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
|
189
|
+
<box style={{ flexDirection: "row", flexShrink: 1, minWidth: 0 }}>
|
|
190
|
+
{fold ? (
|
|
191
|
+
<IconButton
|
|
192
|
+
glyph={collapsed ? NERD.chevronRight : NERD.chevronDown}
|
|
193
|
+
onPress={() => fold.onToggleCollapse(file)}
|
|
194
|
+
tip={collapsed ? "Expand file" : "Collapse file"}
|
|
195
|
+
marginRight={1}
|
|
196
|
+
/>
|
|
197
|
+
) : null}
|
|
198
|
+
<text
|
|
199
|
+
fg={tokens.text}
|
|
200
|
+
attributes={FILE_HEADER_ATTRIBUTES}
|
|
201
|
+
style={{ flexShrink: 1, minWidth: 0, wrapMode: "none" }}
|
|
202
|
+
>
|
|
203
|
+
{diffRowText(row)}
|
|
204
|
+
</text>
|
|
205
|
+
</box>
|
|
206
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
207
|
+
{stats ? (
|
|
208
|
+
<FileCountsBadge stats={stats} tokens={tokens} marginRight={fold ? 1 : 0} />
|
|
209
|
+
) : null}
|
|
210
|
+
{fold ? (
|
|
211
|
+
<IconButton
|
|
212
|
+
glyph={NERD.copy}
|
|
213
|
+
onPress={() => fold.onCopyPath(file)}
|
|
214
|
+
tip="Copy path"
|
|
215
|
+
marginRight={canExpand ? 1 : 0}
|
|
216
|
+
/>
|
|
217
|
+
) : null}
|
|
218
|
+
{fold && canExpand ? (
|
|
219
|
+
<ExpandToggle
|
|
220
|
+
expanded={expanded}
|
|
221
|
+
onToggle={() => fold.onToggleExpand(file)}
|
|
222
|
+
tokens={tokens}
|
|
223
|
+
/>
|
|
224
|
+
) : null}
|
|
225
|
+
</box>
|
|
226
|
+
</box>
|
|
227
|
+
</box>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One code row rendered: its visual line rows, and the cards that hang under them. */
|
|
232
|
+
interface CodeRowNodes {
|
|
233
|
+
lines: React.ReactNode[];
|
|
234
|
+
cards: React.ReactNode[];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One side of a split pair rendered: its column of lines with its cards under them, or filler. */
|
|
238
|
+
interface SplitSideNodes {
|
|
239
|
+
node: React.ReactNode;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One item of the sheet's vertical layout: a base row (unified) or a split row, with the
|
|
244
|
+
* visual lines it occupies. Cards under a materialized row add to the real height; the
|
|
245
|
+
* model only has to be right enough for spacers and the caret reveal.
|
|
246
|
+
*/
|
|
247
|
+
interface LayoutItem {
|
|
248
|
+
/** Index into the unified rows, or into the split rows when side by side. */
|
|
249
|
+
index: number;
|
|
250
|
+
height: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface SheetLayout {
|
|
254
|
+
items: LayoutItem[];
|
|
255
|
+
/** Visual-line offset of each item from the top of the content. */
|
|
256
|
+
offsets: number[];
|
|
257
|
+
total: number;
|
|
258
|
+
/** The layout item that shows a given base row, for the caret reveal. */
|
|
259
|
+
itemOfRow: number[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Rows this many visual lines beyond the viewport stay materialized, so a scroll step never shows a gap. */
|
|
263
|
+
const OVERSCAN_LINES = 30;
|
|
264
|
+
|
|
265
|
+
/** A file band is three rows (rule, name, rule); a hunk header one. */
|
|
266
|
+
function headerHeight(row: DiffRow): number {
|
|
267
|
+
return row.kind === "file" ? 3 : 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function codeRowHeight(row: DiffRow, textWidth: number): number {
|
|
271
|
+
return wrapLines(diffRowText(row), textWidth).length;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildLayout(items: LayoutItem[], itemOfRow: number[]): SheetLayout {
|
|
275
|
+
const offsets: number[] = [];
|
|
276
|
+
let total = 0;
|
|
277
|
+
|
|
278
|
+
for (const item of items) {
|
|
279
|
+
offsets.push(total);
|
|
280
|
+
total += item.height;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { items, offsets, total, itemOfRow };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function unifiedLayout(rows: DiffRow[], textWidth: number): SheetLayout {
|
|
287
|
+
const items = rows.map((row, index) => ({
|
|
288
|
+
index,
|
|
289
|
+
height: isCodeRow(row) ? codeRowHeight(row, textWidth) : headerHeight(row),
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
return buildLayout(
|
|
293
|
+
items,
|
|
294
|
+
rows.map((_row, index) => index),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function splitLayout(splitRows: SplitRow[], rows: DiffRow[], textWidth: number): SheetLayout {
|
|
299
|
+
const itemOfRow: number[] = [];
|
|
300
|
+
const items = splitRows.map((pair, index) => {
|
|
301
|
+
if (pair.kind !== "pair") {
|
|
302
|
+
const row = rows[pair.rowIndex ?? -1];
|
|
303
|
+
|
|
304
|
+
if (pair.rowIndex !== undefined) itemOfRow[pair.rowIndex] = index;
|
|
305
|
+
|
|
306
|
+
return { index, height: row ? headerHeight(row) : 1 };
|
|
307
|
+
}
|
|
308
|
+
const sideHeight = (line: SplitLine | undefined): number =>
|
|
309
|
+
line ? codeRowHeight(line.row, textWidth) : 0;
|
|
310
|
+
|
|
311
|
+
if (pair.left) itemOfRow[pair.left.rowIndex] = index;
|
|
312
|
+
if (pair.right) itemOfRow[pair.right.rowIndex] = index;
|
|
313
|
+
|
|
314
|
+
return { index, height: Math.max(1, sideHeight(pair.left), sideHeight(pair.right)) };
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return buildLayout(items, itemOfRow);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** One painted stretch of a code line: its text with foreground, backdrop, and attributes. */
|
|
321
|
+
interface CodeSpan {
|
|
322
|
+
text: string;
|
|
323
|
+
fg: string;
|
|
324
|
+
bg: string | undefined;
|
|
325
|
+
attributes: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The spans of one visual line: per character, the syntax or intra-line color under
|
|
330
|
+
* the mark treatment (backdrop + underline) or the caret cell, coalesced where equal.
|
|
331
|
+
* A rejected row paints one dim struck-through run, so it reads as excluded regardless
|
|
332
|
+
* of its colors; a mark still shows through it.
|
|
333
|
+
*/
|
|
334
|
+
function codeLineSpans(
|
|
335
|
+
lineText: string,
|
|
336
|
+
lineStart: number,
|
|
337
|
+
lineRanges: MarkRange[],
|
|
338
|
+
fgByColumn: string[],
|
|
339
|
+
emphasisBgByColumn: Array<string | undefined>,
|
|
340
|
+
rejected: boolean,
|
|
341
|
+
tokens: Theme,
|
|
342
|
+
palette: AnnotationPalette,
|
|
343
|
+
): CodeSpan[] {
|
|
344
|
+
const spans: CodeSpan[] = [];
|
|
345
|
+
let column = 0;
|
|
346
|
+
|
|
347
|
+
for (const run of runsFor(lineText, lineRanges)) {
|
|
348
|
+
// a caret or a comment mark owns the whole run's backdrop; otherwise the changed-word tint (if any)
|
|
349
|
+
// shows per column and the row box paints the soft band behind the rest
|
|
350
|
+
const runBackground = run.caretOnly
|
|
351
|
+
? palette.caretCell
|
|
352
|
+
: run.marked
|
|
353
|
+
? palette.markBackdrop
|
|
354
|
+
: undefined;
|
|
355
|
+
const attributes = (run.marked ? UNDERLINE : 0) | (rejected ? REJECTED_ATTRIBUTES : 0);
|
|
356
|
+
|
|
357
|
+
for (const character of run.text) {
|
|
358
|
+
const fg = rejected ? tokens.textDim : (fgByColumn[lineStart + column] ?? tokens.textMuted);
|
|
359
|
+
const bg = runBackground ?? emphasisBgByColumn[lineStart + column];
|
|
360
|
+
const previous = spans[spans.length - 1];
|
|
361
|
+
|
|
362
|
+
if (
|
|
363
|
+
previous &&
|
|
364
|
+
previous.fg === fg &&
|
|
365
|
+
previous.bg === bg &&
|
|
366
|
+
previous.attributes === attributes
|
|
367
|
+
) {
|
|
368
|
+
previous.text += character;
|
|
369
|
+
} else spans.push({ text: character, fg, bg, attributes });
|
|
370
|
+
column++;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return spans;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Per-column foreground of a row's text: intra-line change color over syntax over the base. */
|
|
378
|
+
function foregroundColumns(
|
|
379
|
+
text: string,
|
|
380
|
+
baseColor: string,
|
|
381
|
+
intraline: IntralineRun[] | undefined,
|
|
382
|
+
syntax: SyntaxSpan[] | undefined,
|
|
383
|
+
tokens: Theme,
|
|
384
|
+
): string[] {
|
|
385
|
+
const columns: string[] = [];
|
|
386
|
+
|
|
387
|
+
for (const span of coloredRowSpans(text, intraline, syntax, baseColor, tokens)) {
|
|
388
|
+
// one color per code unit, matching the column model that indexes text by .length
|
|
389
|
+
columns.push(...Array.from({ length: span.text.length }, () => span.foreground));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return columns;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function rowSign(row: DiffRow): string {
|
|
396
|
+
return row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function rowBaseColor(row: DiffRow, tokens: Theme): string {
|
|
400
|
+
return row.kind === "add"
|
|
401
|
+
? tokens.insertedForeground
|
|
402
|
+
: row.kind === "del"
|
|
403
|
+
? tokens.deletedForeground
|
|
404
|
+
: tokens.textMuted;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The soft band behind a whole added/removed row; context rows carry no band. */
|
|
408
|
+
function rowBackground(row: DiffRow, tokens: Theme): string | undefined {
|
|
409
|
+
return row.kind === "add"
|
|
410
|
+
? tokens.insertedBackground
|
|
411
|
+
: row.kind === "del"
|
|
412
|
+
? tokens.deletedBackground
|
|
413
|
+
: undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The stronger tint behind an add/del row's gutter chip and its intra-line changed words. */
|
|
417
|
+
function rowEmphasisBackground(row: DiffRow, tokens: Theme): string | undefined {
|
|
418
|
+
return row.kind === "add"
|
|
419
|
+
? tokens.insertedEmphasisBackground
|
|
420
|
+
: row.kind === "del"
|
|
421
|
+
? tokens.deletedEmphasisBackground
|
|
422
|
+
: undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Per-column emphasis backdrop: the changed words of a modified line, else none. */
|
|
426
|
+
function emphasisBackgroundColumns(
|
|
427
|
+
text: string,
|
|
428
|
+
intraline: IntralineRun[] | undefined,
|
|
429
|
+
emphasisBackground: string | undefined,
|
|
430
|
+
): Array<string | undefined> {
|
|
431
|
+
const columns: Array<string | undefined> = Array.from({ length: text.length }, () => undefined);
|
|
432
|
+
|
|
433
|
+
if (!intraline || !emphasisBackground) return columns;
|
|
434
|
+
let offset = 0;
|
|
435
|
+
|
|
436
|
+
for (const run of intraline) {
|
|
437
|
+
if (run.changed)
|
|
438
|
+
for (let index = 0; index < run.text.length && offset + index < text.length; index++)
|
|
439
|
+
columns[offset + index] = emphasisBackground;
|
|
440
|
+
offset += run.text.length;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return columns;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Async tree-sitter highlights, discarded when they belong to superseded rows. */
|
|
447
|
+
function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
|
|
448
|
+
const [highlighted, setHighlighted] = useState<{
|
|
449
|
+
rows: DiffRow[];
|
|
450
|
+
byRow: Map<number, SyntaxSpan[]>;
|
|
451
|
+
}>({
|
|
452
|
+
rows,
|
|
453
|
+
byRow: EMPTY_SYNTAX,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
let active = true;
|
|
458
|
+
|
|
459
|
+
highlightDiffRows(rows).then(
|
|
460
|
+
(byRow) => {
|
|
461
|
+
if (active) setHighlighted({ rows, byRow });
|
|
462
|
+
},
|
|
463
|
+
(error) => {
|
|
464
|
+
// a highlighter torn down mid-init rejects after unmount (active === false): expected, silent.
|
|
465
|
+
// a rejection while still mounted is a real failure - surface it and leave the rows unhighlighted
|
|
466
|
+
if (active) console.error("diff syntax highlighting failed", error);
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
return () => {
|
|
471
|
+
active = false;
|
|
472
|
+
};
|
|
473
|
+
}, [rows]);
|
|
474
|
+
|
|
475
|
+
return highlighted.rows === rows ? highlighted.byRow : EMPTY_SYNTAX;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function DiffContentView({
|
|
479
|
+
rows,
|
|
480
|
+
session,
|
|
481
|
+
marks,
|
|
482
|
+
quickActions,
|
|
483
|
+
observer,
|
|
484
|
+
commentsEnabled = true,
|
|
485
|
+
resolved = false,
|
|
486
|
+
suspended = false,
|
|
487
|
+
onComposingChange,
|
|
488
|
+
onObserverBlocked,
|
|
489
|
+
onCursorChange,
|
|
490
|
+
focusedAnnotationId,
|
|
491
|
+
onFocusAnnotation,
|
|
492
|
+
onAnnotate,
|
|
493
|
+
onReply,
|
|
494
|
+
onUpdateAnnotation,
|
|
495
|
+
resolveAuthorLabel,
|
|
496
|
+
onNavCommand,
|
|
497
|
+
onExit,
|
|
498
|
+
rejectedRows = EMPTY_REJECTED,
|
|
499
|
+
fold,
|
|
500
|
+
fileStats: fileStatsProp,
|
|
501
|
+
split = false,
|
|
502
|
+
fileView = false,
|
|
503
|
+
theme,
|
|
504
|
+
}: DiffContentViewProps): React.ReactNode {
|
|
505
|
+
const tokens = useComponentTheme(theme);
|
|
506
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
507
|
+
const source: LineSource = {
|
|
508
|
+
count: rows.length,
|
|
509
|
+
textAt: (rowIndex) => diffRowText(rows[rowIndex]!),
|
|
510
|
+
annotatable: (rowIndex) => isCodeRow(rows[rowIndex]),
|
|
511
|
+
};
|
|
512
|
+
const outdatedIds = useMemo(
|
|
513
|
+
() =>
|
|
514
|
+
new Set(
|
|
515
|
+
[...marks.values()]
|
|
516
|
+
.flat()
|
|
517
|
+
.flatMap((mark) =>
|
|
518
|
+
mark.outdated && mark.annotationId !== undefined ? [mark.annotationId] : [],
|
|
519
|
+
),
|
|
520
|
+
),
|
|
521
|
+
[marks],
|
|
522
|
+
);
|
|
523
|
+
const displaySession = useMemo(
|
|
524
|
+
() =>
|
|
525
|
+
outdatedIds.size === 0
|
|
526
|
+
? session
|
|
527
|
+
: {
|
|
528
|
+
...session,
|
|
529
|
+
annotations: session.annotations.map((annotation) =>
|
|
530
|
+
outdatedIds.has(annotation.id) ? { ...annotation, orphan: true } : annotation,
|
|
531
|
+
),
|
|
532
|
+
},
|
|
533
|
+
[outdatedIds, session],
|
|
534
|
+
);
|
|
535
|
+
const surface = useAnnotationSurface({
|
|
536
|
+
source,
|
|
537
|
+
session: displaySession,
|
|
538
|
+
marks,
|
|
539
|
+
quickActions,
|
|
540
|
+
tokens,
|
|
541
|
+
observer,
|
|
542
|
+
commentsEnabled,
|
|
543
|
+
resolved,
|
|
544
|
+
suspended,
|
|
545
|
+
onComposingChange,
|
|
546
|
+
onObserverBlocked,
|
|
547
|
+
onCursorChange,
|
|
548
|
+
focusedAnnotationId,
|
|
549
|
+
onFocusAnnotation,
|
|
550
|
+
onAnnotate,
|
|
551
|
+
onReply,
|
|
552
|
+
onUpdateAnnotation,
|
|
553
|
+
dragViewport: () => scrollBoxDragViewport(scrollRef.current),
|
|
554
|
+
resolveAuthorLabel: (annotation) => {
|
|
555
|
+
const label = resolveAuthorLabel?.(annotation);
|
|
556
|
+
|
|
557
|
+
return annotation.orphan ? `outdated${label ? ` - ${label}` : ""}` : label;
|
|
558
|
+
},
|
|
559
|
+
onNavCommand,
|
|
560
|
+
onExit,
|
|
561
|
+
});
|
|
562
|
+
const { palette } = surface;
|
|
563
|
+
const numberWidth = useMemo(() => lineNumberWidth(rows), [rows]);
|
|
564
|
+
const unifiedGutterColumns = numberWidth * 2 + UNIFIED_GUTTER_CHROME;
|
|
565
|
+
const intralineResolver = useMemo(() => createIntralineResolver(rows), [rows]);
|
|
566
|
+
const syntaxByRow = useSyntaxHighlights(rows);
|
|
567
|
+
const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
|
|
568
|
+
const localStats = useMemo(() => fileChangeCounts(rows), [rows]);
|
|
569
|
+
// base-row counts survive a collapse (folded rows drop the file's add/del rows); fall back locally
|
|
570
|
+
const fileStats = fileStatsProp ?? localStats;
|
|
571
|
+
const viewWidth = useFrameMeasure(
|
|
572
|
+
() => scrollRef.current?.content?.width ?? 0,
|
|
573
|
+
(left, right) => left === right,
|
|
574
|
+
0,
|
|
575
|
+
);
|
|
576
|
+
// a file view shows one number; split gives each column its own gutter; stacked shows old and new
|
|
577
|
+
const textWidth = fileView
|
|
578
|
+
? Math.max(0, viewWidth - (numberWidth + 2))
|
|
579
|
+
: split
|
|
580
|
+
? Math.max(0, Math.floor((viewWidth - 1) / 2) - GUTTER_COLUMNS)
|
|
581
|
+
: Math.max(0, viewWidth - unifiedGutterColumns);
|
|
582
|
+
|
|
583
|
+
// Every visual line is its own text renderable (so a drag can hit-test it), and a large
|
|
584
|
+
// diff has tens of thousands of them - more native text buffers than the renderer can
|
|
585
|
+
// hold. So the sheet is virtual: only the viewport plus an overscan is mounted, each row
|
|
586
|
+
// estimated at its wrapped line count until its box (cards included) is measured.
|
|
587
|
+
const layout = useMemo(
|
|
588
|
+
() => (split ? splitLayout(splitRows, rows, textWidth) : unifiedLayout(rows, textWidth)),
|
|
589
|
+
[split, splitRows, rows, textWidth],
|
|
590
|
+
);
|
|
591
|
+
const virtual = useTerminalVirtualizer({
|
|
592
|
+
scrollbox: scrollRef,
|
|
593
|
+
count: layout.items.length,
|
|
594
|
+
estimateSize: (index) => layout.items[index]?.height ?? 1,
|
|
595
|
+
overscan: OVERSCAN_LINES,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// an opening card shifts the layout, so the row it belongs to is revealed again; a
|
|
599
|
+
// discussion focused from the rail is scrolled into view the same way
|
|
600
|
+
const revealItem = layout.itemOfRow[surface.revealBlockIndex];
|
|
601
|
+
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
if (revealItem !== undefined) {
|
|
604
|
+
virtual.scrollToIndex(revealItem, surface.compose ? "end" : "auto");
|
|
605
|
+
}
|
|
606
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
607
|
+
}, [surface.revealBlockIndex, surface.compose]);
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The visual lines of one code row painted with gutter, colors, and marks; cards collected after.
|
|
611
|
+
* `paintMarks` false renders the line plain (no caret, no mark backdrop) - the split view uses it so
|
|
612
|
+
* a context row, shared by both columns, only carries the caret and marks on the change side.
|
|
613
|
+
*/
|
|
614
|
+
const codeRowLines = (
|
|
615
|
+
row: DiffRow,
|
|
616
|
+
rowIndex: number,
|
|
617
|
+
textWidth: number,
|
|
618
|
+
keyPrefix: string,
|
|
619
|
+
paintMarks = true,
|
|
620
|
+
): CodeRowNodes => {
|
|
621
|
+
const text = diffRowText(row);
|
|
622
|
+
const lines = wrapLines(text, textWidth);
|
|
623
|
+
const ranges = paintMarks ? surface.rangesFor(rowIndex) : [];
|
|
624
|
+
const fgByColumn = foregroundColumns(
|
|
625
|
+
text,
|
|
626
|
+
rowBaseColor(row, tokens),
|
|
627
|
+
intralineResolver.runsForRow(rowIndex),
|
|
628
|
+
syntaxByRow.get(rowIndex),
|
|
629
|
+
tokens,
|
|
630
|
+
);
|
|
631
|
+
const emphasisBgByColumn = emphasisBackgroundColumns(
|
|
632
|
+
text,
|
|
633
|
+
intralineResolver.runsForRow(rowIndex),
|
|
634
|
+
rowEmphasisBackground(row, tokens),
|
|
635
|
+
);
|
|
636
|
+
const rowBg = rowBackground(row, tokens);
|
|
637
|
+
const rejected = rejectedRows.has(rowIndex);
|
|
638
|
+
const isCaretRow = paintMarks && surface.head.blockIndex === rowIndex;
|
|
639
|
+
// split view carries one number per side; the stacked view shows the old and new numbers together
|
|
640
|
+
const splitLineNumber = row.kind === "del" ? row.oldLine : row.newLine;
|
|
641
|
+
const splitGutter = `${isCaretRow ? "▎" : " "}${String(splitLineNumber ?? "").padStart(4, " ")} ${rowSign(row)} `;
|
|
642
|
+
const barChar = isCaretRow ? "▎" : " ";
|
|
643
|
+
const barColor = isCaretRow ? tokens.accent : tokens.textDim;
|
|
644
|
+
const oldNumber =
|
|
645
|
+
row.kind === "add"
|
|
646
|
+
? " ".repeat(numberWidth)
|
|
647
|
+
: String(row.oldLine ?? "").padStart(numberWidth);
|
|
648
|
+
const newNumber =
|
|
649
|
+
row.kind === "del"
|
|
650
|
+
? " ".repeat(numberWidth)
|
|
651
|
+
: String(row.newLine ?? "").padStart(numberWidth);
|
|
652
|
+
const gutterFor = (lineIndex: number): React.ReactNode => {
|
|
653
|
+
if (fileView) {
|
|
654
|
+
// a plain file has no old/new sides, so one line number reads like an ordinary file viewer
|
|
655
|
+
return lineIndex === 0 ? (
|
|
656
|
+
<>
|
|
657
|
+
<span fg={barColor}>{barChar}</span>
|
|
658
|
+
<span fg={tokens.textDim}>{String(row.newLine ?? "").padStart(numberWidth)}</span>
|
|
659
|
+
<span> </span>
|
|
660
|
+
</>
|
|
661
|
+
) : (
|
|
662
|
+
" ".repeat(numberWidth + 2)
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
if (split) {
|
|
666
|
+
return lineIndex === 0 ? (
|
|
667
|
+
<>
|
|
668
|
+
<span fg={isCaretRow ? tokens.accent : tokens.textDim}>{splitGutter.slice(0, 6)}</span>
|
|
669
|
+
<span fg={rowBaseColor(row, tokens)}>{splitGutter.slice(6)}</span>
|
|
670
|
+
</>
|
|
671
|
+
) : (
|
|
672
|
+
" ".repeat(GUTTER_COLUMNS)
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
// stacked: caret bar, old new (each tinted red/green on its side), then the change sign;
|
|
676
|
+
// the numbers sit on the row's soft band, only the changed code carries the brighter backdrop
|
|
677
|
+
return lineIndex === 0 ? (
|
|
678
|
+
<>
|
|
679
|
+
<span fg={barColor}>{barChar}</span>
|
|
680
|
+
<span fg={row.kind === "del" ? tokens.deletedForeground : tokens.textDim}>
|
|
681
|
+
{oldNumber}
|
|
682
|
+
</span>
|
|
683
|
+
<span fg={tokens.textDim}> </span>
|
|
684
|
+
<span fg={row.kind === "add" ? tokens.insertedForeground : tokens.textDim}>
|
|
685
|
+
{newNumber}
|
|
686
|
+
</span>
|
|
687
|
+
<span> </span>
|
|
688
|
+
<span fg={rowBaseColor(row, tokens)}>{`${rowSign(row)} `}</span>
|
|
689
|
+
</>
|
|
690
|
+
) : (
|
|
691
|
+
<>
|
|
692
|
+
<span fg={barColor}>{barChar}</span>
|
|
693
|
+
{" ".repeat(unifiedGutterColumns - 1)}
|
|
694
|
+
</>
|
|
695
|
+
);
|
|
696
|
+
};
|
|
697
|
+
const lineNodes: React.ReactNode[] = [];
|
|
698
|
+
const cards: React.ReactNode[] = [];
|
|
699
|
+
|
|
700
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
701
|
+
const line = lines[lineIndex]!;
|
|
702
|
+
const isLastLine = lineIndex === lines.length - 1;
|
|
703
|
+
|
|
704
|
+
lineNodes.push(
|
|
705
|
+
<box
|
|
706
|
+
key={`${keyPrefix}-line-${lineIndex}`}
|
|
707
|
+
style={{ flexDirection: "row", backgroundColor: rowBg }}
|
|
708
|
+
>
|
|
709
|
+
<text
|
|
710
|
+
selectable={false}
|
|
711
|
+
fg={isCaretRow && lineIndex === 0 ? tokens.accent : tokens.textDim}
|
|
712
|
+
style={{ flexShrink: 0, wrapMode: "none" }}
|
|
713
|
+
>
|
|
714
|
+
{gutterFor(lineIndex)}
|
|
715
|
+
</text>
|
|
716
|
+
<text
|
|
717
|
+
selectable={false}
|
|
718
|
+
style={{ flexGrow: 1, flexShrink: 1, wrapMode: "none" }}
|
|
719
|
+
ref={surface.registerLine(rowIndex, lineIndex, line)}
|
|
720
|
+
onMouseDown={surface.onLineMouseDown}
|
|
721
|
+
>
|
|
722
|
+
{codeLineSpans(
|
|
723
|
+
text.slice(line.start, line.end),
|
|
724
|
+
line.start,
|
|
725
|
+
lineMarkRanges(ranges, line),
|
|
726
|
+
fgByColumn,
|
|
727
|
+
emphasisBgByColumn,
|
|
728
|
+
rejected,
|
|
729
|
+
tokens,
|
|
730
|
+
palette,
|
|
731
|
+
).map((span, spanIndex) => (
|
|
732
|
+
<span key={spanIndex} fg={span.fg} bg={span.bg} attributes={span.attributes}>
|
|
733
|
+
{span.text}
|
|
734
|
+
</span>
|
|
735
|
+
))}
|
|
736
|
+
</text>
|
|
737
|
+
</box>,
|
|
738
|
+
);
|
|
739
|
+
// cards render on the side that owns the row, so a shared context row's card is not duplicated
|
|
740
|
+
if (paintMarks) cards.push(...surface.cardsAfterLine(rowIndex, line, isLastLine));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return { lines: lineNodes, cards };
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const headerNode = (row: DiffRow, rowIndex: number): React.ReactNode =>
|
|
747
|
+
row.kind === "file" ? (
|
|
748
|
+
<box key={rowIndex} id={`diff-row-${rowIndex}`}>
|
|
749
|
+
<FileBand row={row} stats={fileStats.get(row.file)} fold={fold} tokens={tokens} />
|
|
750
|
+
</box>
|
|
751
|
+
) : (
|
|
752
|
+
<text
|
|
753
|
+
key={rowIndex}
|
|
754
|
+
id={`diff-row-${rowIndex}`}
|
|
755
|
+
fg={tokens.blue}
|
|
756
|
+
style={{ wrapMode: "none" }}
|
|
757
|
+
>
|
|
758
|
+
{` ${diffRowText(row)}`}
|
|
759
|
+
</text>
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
const unifiedItem = (rowIndex: number): React.ReactNode => {
|
|
763
|
+
const row = rows[rowIndex]!;
|
|
764
|
+
|
|
765
|
+
if (!isCodeRow(row)) return headerNode(row, rowIndex);
|
|
766
|
+
const { lines, cards } = codeRowLines(row, rowIndex, textWidth, `row-${rowIndex}`);
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<box key={rowIndex} id={`diff-row-${rowIndex}`} style={{ flexDirection: "column" }}>
|
|
770
|
+
{lines}
|
|
771
|
+
{cards}
|
|
772
|
+
</box>
|
|
773
|
+
);
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
/** One side of a split pair: the row's visual lines, or blank filler that reads as absent. */
|
|
777
|
+
const splitSide = (
|
|
778
|
+
line: SplitLine | undefined,
|
|
779
|
+
side: "left" | "right",
|
|
780
|
+
textWidth: number,
|
|
781
|
+
keyPrefix: string,
|
|
782
|
+
): SplitSideNodes => {
|
|
783
|
+
if (!line) {
|
|
784
|
+
const filler = (
|
|
785
|
+
<box style={{ flexGrow: 1, flexBasis: 0, minWidth: 0, backgroundColor: tokens.panel }} />
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
return { node: filler };
|
|
789
|
+
}
|
|
790
|
+
// the change side (right: additions and context) owns the caret and marks; the left carries them
|
|
791
|
+
// only on its deletions, so a context row shared by both columns is not marked twice
|
|
792
|
+
const paintMarks = side === "right" || line.kind === "del";
|
|
793
|
+
const { lines, cards } = codeRowLines(
|
|
794
|
+
line.row,
|
|
795
|
+
line.rowIndex,
|
|
796
|
+
textWidth,
|
|
797
|
+
keyPrefix,
|
|
798
|
+
paintMarks,
|
|
799
|
+
);
|
|
800
|
+
// a discussion stays in the pane of the side it annotates, under that side's lines
|
|
801
|
+
const column = (
|
|
802
|
+
<box style={{ flexDirection: "column", flexGrow: 1, flexBasis: 0, minWidth: 0 }}>
|
|
803
|
+
{lines}
|
|
804
|
+
{cards}
|
|
805
|
+
</box>
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
return { node: column };
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const splitItem = (pairIndex: number): React.ReactNode => {
|
|
812
|
+
const pair: SplitRow = splitRows[pairIndex]!;
|
|
813
|
+
|
|
814
|
+
if (pair.kind !== "pair") {
|
|
815
|
+
const rowIndex = pair.rowIndex ?? -1;
|
|
816
|
+
|
|
817
|
+
return headerNode(
|
|
818
|
+
rows[rowIndex] ?? { kind: pair.kind, text: pair.file, file: pair.file },
|
|
819
|
+
rowIndex,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
const left = splitSide(pair.left, "left", textWidth, `pair-${pairIndex}-left`);
|
|
823
|
+
const right = splitSide(pair.right, "right", textWidth, `pair-${pairIndex}-right`);
|
|
824
|
+
// both sides answer to the pair's id, so a reveal of either base row lands here
|
|
825
|
+
const anchorRow = pair.right?.rowIndex ?? pair.left?.rowIndex ?? -1;
|
|
826
|
+
const otherRow = pair.left?.rowIndex;
|
|
827
|
+
|
|
828
|
+
return (
|
|
829
|
+
<box
|
|
830
|
+
key={`pair-${pairIndex}`}
|
|
831
|
+
id={`diff-row-${anchorRow}`}
|
|
832
|
+
style={{ flexDirection: "column" }}
|
|
833
|
+
>
|
|
834
|
+
{/* stretch so the divider box grows to the taller side's wrapped height, leaving no gap */}
|
|
835
|
+
<box
|
|
836
|
+
id={otherRow !== undefined && otherRow !== anchorRow ? `diff-row-${otherRow}` : undefined}
|
|
837
|
+
style={{ flexDirection: "row", alignItems: "stretch" }}
|
|
838
|
+
>
|
|
839
|
+
{left.node}
|
|
840
|
+
<box
|
|
841
|
+
style={{
|
|
842
|
+
flexShrink: 0,
|
|
843
|
+
borderStyle: "single",
|
|
844
|
+
border: ["left"],
|
|
845
|
+
borderColor: tokens.border,
|
|
846
|
+
}}
|
|
847
|
+
/>
|
|
848
|
+
{right.node}
|
|
849
|
+
</box>
|
|
850
|
+
</box>
|
|
851
|
+
);
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const materialized: React.ReactNode[] = [];
|
|
855
|
+
const firstItem = virtual.items[0];
|
|
856
|
+
const lastItem = virtual.items[virtual.items.length - 1];
|
|
857
|
+
|
|
858
|
+
if (firstItem && lastItem) {
|
|
859
|
+
const below = virtual.totalSize - lastItem.end;
|
|
860
|
+
|
|
861
|
+
if (firstItem.start > 0) {
|
|
862
|
+
materialized.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
|
|
863
|
+
}
|
|
864
|
+
for (const item of virtual.items) {
|
|
865
|
+
// the wrapper is what gets measured, so a row's cards count toward its height
|
|
866
|
+
materialized.push(
|
|
867
|
+
<box key={`item-${item.index}`} ref={virtual.measureRef(item.index)}>
|
|
868
|
+
{split ? splitItem(item.index) : unifiedItem(item.index)}
|
|
869
|
+
</box>,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
if (below > 0) materialized.push(<box key="spacer-below" style={{ height: below }} />);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return (
|
|
876
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
|
|
877
|
+
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 0 }}>
|
|
878
|
+
{/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
|
|
879
|
+
<scrollbox
|
|
880
|
+
id="diff-scroll"
|
|
881
|
+
ref={scrollRef}
|
|
882
|
+
style={{ flexGrow: 1 }}
|
|
883
|
+
focused={false}
|
|
884
|
+
verticalScrollbarOptions={{ visible: false }}
|
|
885
|
+
>
|
|
886
|
+
{materialized}
|
|
887
|
+
</scrollbox>
|
|
888
|
+
{suspended ? null : (
|
|
889
|
+
<NavModeHint
|
|
890
|
+
navMode={surface.navMode}
|
|
891
|
+
surface={onNavCommand ? "diff" : "bare"}
|
|
892
|
+
theme={tokens}
|
|
893
|
+
/>
|
|
894
|
+
)}
|
|
895
|
+
</box>
|
|
896
|
+
<DiscussionMarkerRail
|
|
897
|
+
discussions={surface.discussions}
|
|
898
|
+
spanQuote={surface.spanQuote}
|
|
899
|
+
focusedKey={surface.focusedDiscussion}
|
|
900
|
+
onJump={surface.jumpToDiscussion}
|
|
901
|
+
scrollbox={scrollRef}
|
|
902
|
+
theme={theme}
|
|
903
|
+
/>
|
|
904
|
+
</box>
|
|
905
|
+
);
|
|
906
|
+
}
|