@nibapp/ui 0.0.0-stage → 0.1.0
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +124 -2
- package/TRADEMARKS.md +22 -0
- package/dist/_bundled/money/index.d.ts +98 -0
- package/dist/_bundled/money/index.js +152 -0
- package/dist/_bundled/tokens/index.d.ts +2 -0
- package/dist/_bundled/tokens/index.js +2 -0
- package/dist/_bundled/tokens/palette.gen.d.ts +51 -0
- package/dist/_bundled/tokens/palette.gen.js +206 -0
- package/dist/_bundled/tokens/spec.d.ts +291 -0
- package/dist/_bundled/tokens/spec.js +221 -0
- package/dist/components/accordion.d.ts +27 -0
- package/dist/components/accordion.js +66 -0
- package/dist/components/alert-dialog.d.ts +59 -0
- package/dist/components/alert-dialog.js +82 -0
- package/dist/components/alert.d.ts +141 -0
- package/dist/components/alert.js +55 -0
- package/dist/components/appear.d.ts +21 -0
- package/dist/components/appear.js +42 -0
- package/dist/components/avatar.d.ts +196 -0
- package/dist/components/avatar.js +135 -0
- package/dist/components/badge.d.ts +183 -0
- package/dist/components/badge.js +68 -0
- package/dist/components/block-stack.d.ts +33 -0
- package/dist/components/block-stack.js +197 -0
- package/dist/components/button.d.ts +327 -0
- package/dist/components/button.js +101 -0
- package/dist/components/card.d.ts +131 -0
- package/dist/components/card.js +100 -0
- package/dist/components/checkbox.d.ts +34 -0
- package/dist/components/checkbox.js +37 -0
- package/dist/components/choice-row.d.ts +104 -0
- package/dist/components/choice-row.js +56 -0
- package/dist/components/clamped-text.d.ts +17 -0
- package/dist/components/clamped-text.js +47 -0
- package/dist/components/code-block.d.ts +48 -0
- package/dist/components/code-block.js +124 -0
- package/dist/components/code-input.d.ts +49 -0
- package/dist/components/code-input.js +59 -0
- package/dist/components/combobox.d.ts +194 -0
- package/dist/components/combobox.js +176 -0
- package/dist/components/command-palette.d.ts +57 -0
- package/dist/components/command-palette.js +83 -0
- package/dist/components/context-menu.d.ts +24 -0
- package/dist/components/context-menu.js +30 -0
- package/dist/components/copy-button.d.ts +9 -0
- package/dist/components/copy-button.js +20 -0
- package/dist/components/copy-command.d.ts +18 -0
- package/dist/components/copy-command.js +19 -0
- package/dist/components/data-table.d.ts +240 -0
- package/dist/components/data-table.js +63 -0
- package/dist/components/dialog.d.ts +80 -0
- package/dist/components/dialog.js +85 -0
- package/dist/components/diff-lines.d.ts +20 -0
- package/dist/components/diff-lines.js +10 -0
- package/dist/components/diff-view.d.ts +128 -0
- package/dist/components/diff-view.js +675 -0
- package/dist/components/disclosure.d.ts +33 -0
- package/dist/components/disclosure.js +51 -0
- package/dist/components/drawn-check.d.ts +8 -0
- package/dist/components/drawn-check.js +16 -0
- package/dist/components/edge-resizer.d.ts +26 -0
- package/dist/components/edge-resizer.js +53 -0
- package/dist/components/empty-state.d.ts +112 -0
- package/dist/components/empty-state.js +56 -0
- package/dist/components/expand-toggle.d.ts +19 -0
- package/dist/components/expand-toggle.js +10 -0
- package/dist/components/field.d.ts +48 -0
- package/dist/components/field.js +60 -0
- package/dist/components/filter-chip.d.ts +47 -0
- package/dist/components/filter-chip.js +33 -0
- package/dist/components/group.d.ts +10 -0
- package/dist/components/group.js +9 -0
- package/dist/components/icon-text.d.ts +11 -0
- package/dist/components/icon-text.js +10 -0
- package/dist/components/image-sources.d.ts +57 -0
- package/dist/components/image-sources.js +58 -0
- package/dist/components/image-thumbnail.d.ts +34 -0
- package/dist/components/image-thumbnail.js +58 -0
- package/dist/components/image-viewer.d.ts +60 -0
- package/dist/components/image-viewer.js +411 -0
- package/dist/components/inline-edit.d.ts +22 -0
- package/dist/components/inline-edit.js +51 -0
- package/dist/components/input.d.ts +155 -0
- package/dist/components/input.js +109 -0
- package/dist/components/item-label.d.ts +11 -0
- package/dist/components/item-label.js +5 -0
- package/dist/components/item-row.d.ts +23 -0
- package/dist/components/item-row.js +19 -0
- package/dist/components/kbd.d.ts +55 -0
- package/dist/components/kbd.js +86 -0
- package/dist/components/link-policy.d.ts +24 -0
- package/dist/components/link-policy.js +32 -0
- package/dist/components/linked-text.d.ts +15 -0
- package/dist/components/linked-text.js +48 -0
- package/dist/components/loading-region.d.ts +18 -0
- package/dist/components/loading-region.js +17 -0
- package/dist/components/markdown.d.ts +34 -0
- package/dist/components/markdown.js +307 -0
- package/dist/components/menu.d.ts +132 -0
- package/dist/components/menu.js +102 -0
- package/dist/components/message-bubble.d.ts +57 -0
- package/dist/components/message-bubble.js +28 -0
- package/dist/components/meta-line.d.ts +28 -0
- package/dist/components/meta-line.js +36 -0
- package/dist/components/money-input.d.ts +23 -0
- package/dist/components/money-input.js +34 -0
- package/dist/components/native-select.d.ts +120 -0
- package/dist/components/native-select.js +85 -0
- package/dist/components/page-trail.d.ts +30 -0
- package/dist/components/page-trail.js +43 -0
- package/dist/components/page.d.ts +80 -0
- package/dist/components/page.js +60 -0
- package/dist/components/path-text.d.ts +8 -0
- package/dist/components/path-text.js +11 -0
- package/dist/components/percent-input.d.ts +20 -0
- package/dist/components/percent-input.js +32 -0
- package/dist/components/popover.d.ts +52 -0
- package/dist/components/popover.js +36 -0
- package/dist/components/progress.d.ts +389 -0
- package/dist/components/progress.js +142 -0
- package/dist/components/radio.d.ts +65 -0
- package/dist/components/radio.js +54 -0
- package/dist/components/remote-image-gate.d.ts +34 -0
- package/dist/components/remote-image-gate.js +60 -0
- package/dist/components/resizable.d.ts +23 -0
- package/dist/components/resizable.js +30 -0
- package/dist/components/rolling-number.d.ts +13 -0
- package/dist/components/rolling-number.js +69 -0
- package/dist/components/scroll-area.d.ts +17 -0
- package/dist/components/scroll-area.js +25 -0
- package/dist/components/section-label.d.ts +10 -0
- package/dist/components/section-label.js +21 -0
- package/dist/components/section-nav.d.ts +22 -0
- package/dist/components/section-nav.js +80 -0
- package/dist/components/segmented-control.d.ts +125 -0
- package/dist/components/segmented-control.js +101 -0
- package/dist/components/select.d.ts +120 -0
- package/dist/components/select.js +89 -0
- package/dist/components/separator.d.ts +10 -0
- package/dist/components/separator.js +12 -0
- package/dist/components/sheet.d.ts +101 -0
- package/dist/components/sheet.js +129 -0
- package/dist/components/shortcut-recorder.d.ts +31 -0
- package/dist/components/shortcut-recorder.js +67 -0
- package/dist/components/skeleton.d.ts +31 -0
- package/dist/components/skeleton.js +33 -0
- package/dist/components/slider.d.ts +138 -0
- package/dist/components/slider.js +98 -0
- package/dist/components/spinner.d.ts +37 -0
- package/dist/components/spinner.js +16 -0
- package/dist/components/switch.d.ts +59 -0
- package/dist/components/switch.js +56 -0
- package/dist/components/tab-strip.d.ts +29 -0
- package/dist/components/tab-strip.js +42 -0
- package/dist/components/tabs.d.ts +13 -0
- package/dist/components/tabs.js +68 -0
- package/dist/components/text-link.d.ts +54 -0
- package/dist/components/text-link.js +38 -0
- package/dist/components/textarea.d.ts +75 -0
- package/dist/components/textarea.js +90 -0
- package/dist/components/toast.d.ts +74 -0
- package/dist/components/toast.js +128 -0
- package/dist/components/tooltip.d.ts +50 -0
- package/dist/components/tooltip.js +46 -0
- package/dist/components/truncated-text.d.ts +15 -0
- package/dist/components/truncated-text.js +19 -0
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +7 -0
- package/dist/i18n/format.d.ts +84 -0
- package/dist/i18n/format.js +169 -0
- package/dist/i18n/index.d.ts +6 -0
- package/dist/i18n/index.js +8 -0
- package/dist/i18n/locales/ru-formatters.d.ts +3 -0
- package/dist/i18n/locales/ru-formatters.js +22 -0
- package/dist/i18n/locales/ru.d.ts +5 -0
- package/dist/i18n/locales/ru.js +185 -0
- package/dist/i18n/plural.d.ts +20 -0
- package/dist/i18n/plural.js +26 -0
- package/dist/i18n/provider.d.ts +36 -0
- package/dist/i18n/provider.js +39 -0
- package/dist/i18n/rich.d.ts +14 -0
- package/dist/i18n/rich.js +44 -0
- package/dist/i18n/ru-plural.d.ts +3 -0
- package/dist/i18n/ru-plural.js +4 -0
- package/dist/i18n/strings/alert-dialog.d.ts +5 -0
- package/dist/i18n/strings/alert-dialog.js +5 -0
- package/dist/i18n/strings/block-stack.d.ts +17 -0
- package/dist/i18n/strings/block-stack.js +11 -0
- package/dist/i18n/strings/code-block.d.ts +18 -0
- package/dist/i18n/strings/code-block.js +13 -0
- package/dist/i18n/strings/combobox.d.ts +12 -0
- package/dist/i18n/strings/combobox.js +10 -0
- package/dist/i18n/strings/command-palette.d.ts +17 -0
- package/dist/i18n/strings/command-palette.js +11 -0
- package/dist/i18n/strings/diff-view.d.ts +92 -0
- package/dist/i18n/strings/diff-view.js +68 -0
- package/dist/i18n/strings/image-viewer.d.ts +43 -0
- package/dist/i18n/strings/image-viewer.js +32 -0
- package/dist/i18n/strings/index.d.ts +314 -0
- package/dist/i18n/strings/index.js +43 -0
- package/dist/i18n/strings/kbd.d.ts +42 -0
- package/dist/i18n/strings/kbd.js +39 -0
- package/dist/i18n/strings/markdown.d.ts +23 -0
- package/dist/i18n/strings/markdown.js +18 -0
- package/dist/i18n/strings/money.d.ts +7 -0
- package/dist/i18n/strings/money.js +5 -0
- package/dist/i18n/strings/primitives.d.ts +25 -0
- package/dist/i18n/strings/primitives.js +18 -0
- package/dist/i18n/strings/progress.d.ts +4 -0
- package/dist/i18n/strings/progress.js +4 -0
- package/dist/i18n/strings/shortcut-recorder.d.ts +15 -0
- package/dist/i18n/strings/shortcut-recorder.js +11 -0
- package/dist/i18n/strings/time.d.ts +41 -0
- package/dist/i18n/strings/time.js +20 -0
- package/dist/i18n/strings/toast.d.ts +6 -0
- package/dist/i18n/strings/toast.js +6 -0
- package/dist/i18n/time-zones.d.ts +20 -0
- package/dist/i18n/time-zones.js +64 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +88 -0
- package/dist/lib/ansi.d.ts +55 -0
- package/dist/lib/ansi.js +438 -0
- package/dist/lib/clock.d.ts +32 -0
- package/dist/lib/clock.js +106 -0
- package/dist/lib/cn.d.ts +12 -0
- package/dist/lib/cn.js +28 -0
- package/dist/lib/control-props.d.ts +21 -0
- package/dist/lib/control-props.js +14 -0
- package/dist/lib/direction.d.ts +13 -0
- package/dist/lib/direction.js +25 -0
- package/dist/lib/image-size.d.ts +12 -0
- package/dist/lib/image-size.js +66 -0
- package/dist/lib/keycaps.d.ts +21 -0
- package/dist/lib/keycaps.js +62 -0
- package/dist/lib/keyed.d.ts +9 -0
- package/dist/lib/keyed.js +14 -0
- package/dist/lib/keys.d.ts +49 -0
- package/dist/lib/keys.js +159 -0
- package/dist/lib/kit-motion.d.ts +9 -0
- package/dist/lib/kit-motion.js +12 -0
- package/dist/lib/link-check-dialog.d.ts +11 -0
- package/dist/lib/link-check-dialog.js +18 -0
- package/dist/lib/motion-features.d.ts +1 -0
- package/dist/lib/motion-features.js +4 -0
- package/dist/lib/motion-provider.d.ts +5 -0
- package/dist/lib/motion-provider.js +8 -0
- package/dist/lib/motion.d.ts +39 -0
- package/dist/lib/motion.js +49 -0
- package/dist/lib/number-format.d.ts +114 -0
- package/dist/lib/number-format.js +313 -0
- package/dist/lib/patch.d.ts +72 -0
- package/dist/lib/patch.js +344 -0
- package/dist/lib/path.d.ts +34 -0
- package/dist/lib/path.js +85 -0
- package/dist/lib/search.d.ts +10 -0
- package/dist/lib/search.js +20 -0
- package/dist/lib/shiki-themes.d.ts +7 -0
- package/dist/lib/shiki-themes.js +115 -0
- package/dist/lib/shiki.d.ts +101 -0
- package/dist/lib/shiki.js +353 -0
- package/dist/lib/shortcut-context.d.ts +23 -0
- package/dist/lib/shortcut-context.js +29 -0
- package/dist/lib/text.d.ts +56 -0
- package/dist/lib/text.js +149 -0
- package/dist/lib/theme-scope.d.ts +18 -0
- package/dist/lib/theme-scope.js +24 -0
- package/dist/lib/theme.d.ts +40 -0
- package/dist/lib/theme.js +96 -0
- package/dist/lib/tree-keys.d.ts +72 -0
- package/dist/lib/tree-keys.js +126 -0
- package/dist/lib/url.d.ts +17 -0
- package/dist/lib/url.js +38 -0
- package/dist/lib/use-copy-to-clipboard.d.ts +13 -0
- package/dist/lib/use-copy-to-clipboard.js +40 -0
- package/dist/lib/use-delayed-loading.d.ts +11 -0
- package/dist/lib/use-delayed-loading.js +31 -0
- package/dist/lib/use-file-picker.d.ts +26 -0
- package/dist/lib/use-file-picker.js +43 -0
- package/dist/lib/use-hydrated.d.ts +6 -0
- package/dist/lib/use-hydrated.js +11 -0
- package/dist/lib/use-latest.d.ts +12 -0
- package/dist/lib/use-latest.js +22 -0
- package/dist/lib/use-resolved-theme.d.ts +12 -0
- package/dist/lib/use-resolved-theme.js +25 -0
- package/dist/styles/base.css +393 -0
- package/dist/styles/code.css +35 -0
- package/dist/styles/fonts.css +12 -0
- package/dist/styles/index.css +13 -0
- package/dist/styles/tokens.gen.css +494 -0
- package/package.json +208 -4
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useDirection } from "@base-ui/react/direction-provider";
|
|
4
|
+
import { getFiletypeFromFileName, getSharedHighlighter, getSingularPatch, parseDiffFromFile, } from "@pierre/diffs";
|
|
5
|
+
import { FileDiff, WorkerPoolContext } from "@pierre/diffs/react";
|
|
6
|
+
import { getOrCreateWorkerPoolSingleton, terminateWorkerPoolSingleton, } from "@pierre/diffs/worker";
|
|
7
|
+
import { ArrowRight, Check, Columns2, FileCode, FoldVertical, MessageCircleQuestion, MessageSquarePlus, Rows2, Undo2, UnfoldVertical, WrapText, X, } from "lucide-react";
|
|
8
|
+
import { AnimatePresence } from "motion/react";
|
|
9
|
+
import * as m from "motion/react-m";
|
|
10
|
+
import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
11
|
+
import { normalizeTheme } from "shiki/core";
|
|
12
|
+
import { useFormatters, useUiStrings } from "../i18n/provider.js";
|
|
13
|
+
import { richText } from "../i18n/rich.js";
|
|
14
|
+
import { cn } from "../lib/cn.js";
|
|
15
|
+
import { isComposing, matchesShortcut } from "../lib/keys.js";
|
|
16
|
+
import { KitMotion } from "../lib/kit-motion.js";
|
|
17
|
+
import { fade } from "../lib/motion.js";
|
|
18
|
+
import { getDiffStats, getHunkExcerpt, normalizePatch } from "../lib/patch.js";
|
|
19
|
+
import { hashString } from "../lib/shiki.js";
|
|
20
|
+
import { NIB_SHIKI_THEMES, nibDarkTheme, nibLightTheme } from "../lib/shiki-themes.js";
|
|
21
|
+
import { KIT_SHORTCUTS, useShortcut } from "../lib/shortcut-context.js";
|
|
22
|
+
import { Avatar } from "./avatar.js";
|
|
23
|
+
import { Button, IconButton } from "./button.js";
|
|
24
|
+
import { CodeBlock } from "./code-block.js";
|
|
25
|
+
import { CopyButton } from "./copy-button.js";
|
|
26
|
+
import { DisclosureChevron } from "./disclosure.js";
|
|
27
|
+
import { fieldChrome } from "./input.js";
|
|
28
|
+
import { Shortcut } from "./kbd.js";
|
|
29
|
+
import { PathText } from "./path-text.js";
|
|
30
|
+
import { Textarea } from "./textarea.js";
|
|
31
|
+
import { Tooltip } from "./tooltip.js";
|
|
32
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
33
|
+
/* Theme wiring */
|
|
34
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
35
|
+
/**
|
|
36
|
+
* Seed kit-light / kit-dark into @pierre/diffs' shared highlighter. `registerCustomTheme` would be
|
|
37
|
+
* the documented route, but with the pinned @pierre/theming@1.0.1 its resolver ignores the
|
|
38
|
+
* `fallbackLoader` that custom/bundled themes go through, so they never resolve. Seeding the
|
|
39
|
+
* already-normalized themes into the backend's resolver sidesteps that and needs no network.
|
|
40
|
+
*/
|
|
41
|
+
let themesLoading = null;
|
|
42
|
+
let themesSeeded = false;
|
|
43
|
+
const toDiffsTheme = (theme) => {
|
|
44
|
+
const textmate = normalizeTheme(theme);
|
|
45
|
+
return {
|
|
46
|
+
name: textmate.name,
|
|
47
|
+
type: textmate.type,
|
|
48
|
+
fg: textmate.fg,
|
|
49
|
+
bg: textmate.bg,
|
|
50
|
+
...(textmate.colors && { colors: textmate.colors }),
|
|
51
|
+
textmate,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
async function seedDiffThemes() {
|
|
55
|
+
const highlighter = await getSharedHighlighter({ themes: [], langs: [], preferredHighlighter: "shiki-js" });
|
|
56
|
+
highlighter.themeResolver.seedResolvedThemes([
|
|
57
|
+
[NIB_SHIKI_THEMES.light, toDiffsTheme(nibLightTheme)],
|
|
58
|
+
[NIB_SHIKI_THEMES.dark, toDiffsTheme(nibDarkTheme)],
|
|
59
|
+
]);
|
|
60
|
+
themesSeeded = true;
|
|
61
|
+
}
|
|
62
|
+
function ensureDiffThemes() {
|
|
63
|
+
themesLoading ??= seedDiffThemes().catch((error) => {
|
|
64
|
+
themesLoading = null;
|
|
65
|
+
throw error;
|
|
66
|
+
});
|
|
67
|
+
return themesLoading;
|
|
68
|
+
}
|
|
69
|
+
/** Languages a diff needs: its explicit `lang`, else inferred from the old and new file names. */
|
|
70
|
+
function diffLanguages(diff) {
|
|
71
|
+
if (diff.lang != null)
|
|
72
|
+
return [diff.lang];
|
|
73
|
+
const names = [diff.prevName ?? diff.name, diff.name];
|
|
74
|
+
return [...new Set(names.map((name) => getFiletypeFromFileName(name)))];
|
|
75
|
+
}
|
|
76
|
+
const loadedLanguageKeys = new Set();
|
|
77
|
+
/**
|
|
78
|
+
* False until the diff's grammar is loaded. @pierre/diffs renders plain text when a language is
|
|
79
|
+
* still loading and its async re-highlight can be dropped on first mount, so we mount only once the
|
|
80
|
+
* grammar is in the shared highlighter — the first paint is already highlighted.
|
|
81
|
+
*/
|
|
82
|
+
function useDiffLanguagesReady(diff, themesReady) {
|
|
83
|
+
const langs = diff ? diffLanguages(diff) : [];
|
|
84
|
+
const key = langs.join(",");
|
|
85
|
+
const [readyKey, setReadyKey] = useState(() => (loadedLanguageKeys.has(key) ? key : null));
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!themesReady || diff === null || readyKey === key)
|
|
88
|
+
return undefined;
|
|
89
|
+
if (loadedLanguageKeys.has(key)) {
|
|
90
|
+
setReadyKey(key);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
const load = async () => {
|
|
95
|
+
try {
|
|
96
|
+
await getSharedHighlighter({
|
|
97
|
+
themes: [],
|
|
98
|
+
langs: diffLanguages(diff),
|
|
99
|
+
preferredHighlighter: "shiki-js",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Unknown grammar: render as plain text rather than never.
|
|
104
|
+
}
|
|
105
|
+
loadedLanguageKeys.add(key);
|
|
106
|
+
if (!cancelled)
|
|
107
|
+
setReadyKey(key);
|
|
108
|
+
};
|
|
109
|
+
void load();
|
|
110
|
+
return () => {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
};
|
|
113
|
+
}, [themesReady, diff, key, readyKey]);
|
|
114
|
+
return readyKey === key;
|
|
115
|
+
}
|
|
116
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
117
|
+
/* Off-main-thread highlighting */
|
|
118
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
119
|
+
/**
|
|
120
|
+
* Inside a <DiffHighlighterProvider>: whether its worker pool is still on its way, and how a diff
|
|
121
|
+
* asks for it (the pool starts with the first diff, not with the page).
|
|
122
|
+
*/
|
|
123
|
+
const DiffPoolContext = createContext({
|
|
124
|
+
pending: false,
|
|
125
|
+
request: () => undefined,
|
|
126
|
+
});
|
|
127
|
+
/** Highlight options the pool shares with every <DiffView> (they must match for cache hits). */
|
|
128
|
+
const POOL_HIGHLIGHT_OPTIONS = {
|
|
129
|
+
theme: NIB_SHIKI_THEMES,
|
|
130
|
+
lineDiffType: "word-alt",
|
|
131
|
+
preferredHighlighter: "shiki-js",
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Moves every descendant <DiffView>'s syntax highlighting into @pierre/diffs' worker pool: a diff
|
|
135
|
+
* first paints as plain text (same rows, same height), then swaps in its highlighted lines when
|
|
136
|
+
* the worker answers, and a content-keyed LRU makes remounts (a virtualized row scrolling back
|
|
137
|
+
* into view) highlighted on their first paint. Without this provider diffs highlight on the main
|
|
138
|
+
* thread while mounting. Mount it once, near the root; it owns the pool's lifetime. The workers
|
|
139
|
+
* start when the first diff mounts, so pages without diffs never download the highlighter.
|
|
140
|
+
*/
|
|
141
|
+
export function DiffHighlighterProvider({ workerFactory, children }) {
|
|
142
|
+
const [config] = useState(() => ({ workerFactory }));
|
|
143
|
+
const [pool, setPool] = useState(undefined);
|
|
144
|
+
const [failed, setFailed] = useState(false);
|
|
145
|
+
const [requested, setRequested] = useState(false);
|
|
146
|
+
const request = useCallback(() => setRequested(true), []);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!requested)
|
|
149
|
+
return undefined;
|
|
150
|
+
let cancelled = false;
|
|
151
|
+
const start = async () => {
|
|
152
|
+
try {
|
|
153
|
+
// The pool resolves its themes when it starts; ours must be seeded into the resolver first.
|
|
154
|
+
await ensureDiffThemes();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
if (!cancelled)
|
|
158
|
+
setFailed(true);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (cancelled)
|
|
162
|
+
return;
|
|
163
|
+
setPool(getOrCreateWorkerPoolSingleton({
|
|
164
|
+
poolOptions: {
|
|
165
|
+
workerFactory: config.workerFactory,
|
|
166
|
+
// Two workers; 200 highlighted diffs kept for remounts, least recently used evicted.
|
|
167
|
+
poolSize: 2,
|
|
168
|
+
totalASTLRUCacheSize: 200,
|
|
169
|
+
},
|
|
170
|
+
highlighterOptions: POOL_HIGHLIGHT_OPTIONS,
|
|
171
|
+
}));
|
|
172
|
+
};
|
|
173
|
+
void start();
|
|
174
|
+
return () => {
|
|
175
|
+
cancelled = true;
|
|
176
|
+
setPool(undefined);
|
|
177
|
+
terminateWorkerPoolSingleton();
|
|
178
|
+
};
|
|
179
|
+
}, [config, requested]);
|
|
180
|
+
const pending = pool === undefined && !failed && typeof Worker !== "undefined";
|
|
181
|
+
const value = useMemo(() => ({ pending, request }), [pending, request]);
|
|
182
|
+
// Whether highlights are still on their way: the pool starting, or tasks queued or running. Failed
|
|
183
|
+
// workers send none; those diffs stay plain.
|
|
184
|
+
const [working, setWorking] = useState(false);
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (pool === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
const update = (stats) => setWorking(!stats.workersFailed &&
|
|
189
|
+
(stats.managerState !== "initialized" ||
|
|
190
|
+
stats.busyWorkers + stats.queuedTasks + stats.activeTasks > 0));
|
|
191
|
+
update(pool.getStats());
|
|
192
|
+
return pool.subscribeToStatChanges(update);
|
|
193
|
+
}, [pool]);
|
|
194
|
+
return (_jsx(WorkerPoolContext, { value: pool, children: _jsx(DiffPoolContext, { value: value, children: _jsx("div", { "data-slot": "diff-highlighter", "data-highlighting": (requested && pending) || working || undefined, className: "contents", children: children }) }) }));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Content-derived `cacheKey` (two 53-bit hashes + length) for the pool's highlight cache, so equal
|
|
198
|
+
* diffs share one highlight across mounts and changed content never reuses a stale one.
|
|
199
|
+
*/
|
|
200
|
+
export function diffCacheKey(...parts) {
|
|
201
|
+
const text = parts.map((part) => part ?? "").join("\u0000");
|
|
202
|
+
return `diff:${hashString(text).toString(36)}:${hashString(text, 1).toString(36)}:${text.length}`;
|
|
203
|
+
}
|
|
204
|
+
/** False until the diff highlighter knows our themes; rendering earlier would throw. */
|
|
205
|
+
function useDiffThemesReady() {
|
|
206
|
+
const [ready, setReady] = useState(themesSeeded);
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (ready)
|
|
209
|
+
return undefined;
|
|
210
|
+
let cancelled = false;
|
|
211
|
+
const wait = async () => {
|
|
212
|
+
try {
|
|
213
|
+
await ensureDiffThemes();
|
|
214
|
+
if (!cancelled)
|
|
215
|
+
setReady(true);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Highlighter failed to boot; the body keeps its placeholder.
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
void wait();
|
|
222
|
+
return () => {
|
|
223
|
+
cancelled = true;
|
|
224
|
+
};
|
|
225
|
+
}, [ready]);
|
|
226
|
+
return ready;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* @pierre/diffs renders into a shadow root and is themed through inherited custom properties.
|
|
230
|
+
* Inline host styles outrank the shadow `:host` rules, so this maps the kit's tokens onto its knobs.
|
|
231
|
+
* Line backgrounds are mix *targets* (the library mixes 12% light / 20% dark of them into the
|
|
232
|
+
* surface), so these are pre-scaled to land on `--diff-add-bg` / `--diff-remove-bg`.
|
|
233
|
+
*/
|
|
234
|
+
const HOST_STYLE = {
|
|
235
|
+
// Follow the page's resolved scheme ([data-theme]) instead of the OS preference.
|
|
236
|
+
colorScheme: "inherit",
|
|
237
|
+
// Code reads left to right in every language: the gutters, the split's sides and the scroll.
|
|
238
|
+
direction: "ltr",
|
|
239
|
+
unicodeBidi: "isolate",
|
|
240
|
+
"--diffs-bg": "var(--bg-inset)",
|
|
241
|
+
"--diffs-fg": "var(--fg)",
|
|
242
|
+
"--diffs-font-family": "var(--font-mono)",
|
|
243
|
+
"--diffs-header-font-family": "var(--font-sans)",
|
|
244
|
+
"--diffs-font-size": "12.5px",
|
|
245
|
+
"--diffs-line-height": "20px",
|
|
246
|
+
"--diffs-font-features": '"liga" 0, "calt" 0',
|
|
247
|
+
"--diffs-gap-block": "10px",
|
|
248
|
+
"--diffs-addition-color-override": "var(--green-9)",
|
|
249
|
+
"--diffs-deletion-color-override": "var(--red-9)",
|
|
250
|
+
"--diffs-modified-color-override": "var(--blue-9)",
|
|
251
|
+
"--diffs-bg-addition-override": "light-dark(color-mix(in oklab, var(--green-9) 75%, var(--bg-inset)), color-mix(in oklab, var(--green-9) 55%, var(--bg-inset)))",
|
|
252
|
+
"--diffs-bg-deletion-override": "light-dark(color-mix(in oklab, var(--red-9) 67%, var(--bg-inset)), color-mix(in oklab, var(--red-9) 50%, var(--bg-inset)))",
|
|
253
|
+
"--diffs-bg-addition-emphasis-override": "var(--diff-add-word)",
|
|
254
|
+
"--diffs-bg-deletion-emphasis-override": "var(--diff-remove-word)",
|
|
255
|
+
"--diffs-fg-number-override": "var(--fg-subtle)",
|
|
256
|
+
"--diffs-fg-number-addition-override": "var(--diff-add-fg)",
|
|
257
|
+
"--diffs-fg-number-deletion-override": "var(--diff-remove-fg)",
|
|
258
|
+
"--diffs-bg-separator-override": "var(--bg-muted)",
|
|
259
|
+
"--diffs-bg-buffer-override": "var(--bg-subtle)",
|
|
260
|
+
};
|
|
261
|
+
/**
|
|
262
|
+
* Shadow-root CSS (the library's `unsafe` layer wins over its own). Collapsed regions read
|
|
263
|
+
* "⋯ 42 unmodified lines" and stay calm on hover; the gutter "+" is neutral ink, not accent blue.
|
|
264
|
+
*/
|
|
265
|
+
const UNSAFE_CSS = `
|
|
266
|
+
[data-unmodified-lines]::before { content: "\\22EF"; margin-inline-end: 1ch; }
|
|
267
|
+
[data-separator-content]:hover { text-decoration: none; color: var(--fg-muted); }
|
|
268
|
+
[data-utility-button] { background-color: var(--fg); color: var(--bg); border-radius: 5px; }
|
|
269
|
+
[data-code]:focus-visible, [data-expand-button]:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
270
|
+
`;
|
|
271
|
+
function expandDirection(button) {
|
|
272
|
+
if (button.hasAttribute("data-expand-up"))
|
|
273
|
+
return "up";
|
|
274
|
+
if (button.hasAttribute("data-expand-down"))
|
|
275
|
+
return "down";
|
|
276
|
+
return "both";
|
|
277
|
+
}
|
|
278
|
+
/** Enter and Space press an expand button, as they would a `<button>`. */
|
|
279
|
+
function pressExpandButton(event) {
|
|
280
|
+
if (!(event instanceof globalThis.KeyboardEvent) || (event.key !== "Enter" && event.key !== " "))
|
|
281
|
+
return;
|
|
282
|
+
const { target } = event;
|
|
283
|
+
if (!(target instanceof HTMLElement) || !target.hasAttribute("data-expand-button"))
|
|
284
|
+
return;
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
target.click();
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* @pierre/diffs draws two things a keyboard can't reach (WCAG 2.1.1, 4.1.2): the scroller of long
|
|
290
|
+
* lines (`[data-code]`, out of the tab order) and the expand buttons between hunks (`role=button`
|
|
291
|
+
* divs with no name and no tab stop). After each render the scroller gets a tab stop while lines
|
|
292
|
+
* scroll rather than wrap, as Shiki gives its `<pre>` one, and each expand button a tab stop, a name
|
|
293
|
+
* and Enter/Space (the library listens for clicks).
|
|
294
|
+
*/
|
|
295
|
+
function keyboardReach(container, labels, scrolls) {
|
|
296
|
+
const root = container.shadowRoot ?? container;
|
|
297
|
+
if (scrolls) {
|
|
298
|
+
for (const code of root.querySelectorAll("[data-code]"))
|
|
299
|
+
code.tabIndex = 0;
|
|
300
|
+
}
|
|
301
|
+
for (const button of root.querySelectorAll("[data-expand-button]")) {
|
|
302
|
+
button.tabIndex = 0;
|
|
303
|
+
if (!button.hasAttribute("data-expand-all-button"))
|
|
304
|
+
button.ariaLabel = labels[expandDirection(button)];
|
|
305
|
+
}
|
|
306
|
+
// Listening twice is a no-op: the same function is added once per root.
|
|
307
|
+
root.addEventListener("keydown", pressExpandButton);
|
|
308
|
+
}
|
|
309
|
+
const toContents = (file) => file ? { name: file.name, contents: file.contents, ...(file.lang ? { lang: file.lang } : {}) } : null;
|
|
310
|
+
const toFile = (name, contents, lang) => name === undefined || contents === undefined ? null : { name, contents, ...(lang ? { lang } : {}) };
|
|
311
|
+
function parseSource(source) {
|
|
312
|
+
try {
|
|
313
|
+
if (source.patch !== undefined)
|
|
314
|
+
return getSingularPatch(normalizePatch(source.patch));
|
|
315
|
+
if (!source.oldFile && !source.newFile)
|
|
316
|
+
return null;
|
|
317
|
+
return parseDiffFromFile(toContents(source.oldFile), toContents(source.newFile));
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const toAnnotationSide = (side) => side === "old" ? "deletions" : "additions";
|
|
324
|
+
const toCommentSide = (side) => side === "deletions" ? "old" : "new";
|
|
325
|
+
/**
|
|
326
|
+
* Where a hunk's toolbar sits: under the context line right before its first change (so it heads
|
|
327
|
+
* the change), above the file for a change on line 1, else under the hunk's last line.
|
|
328
|
+
*/
|
|
329
|
+
function hunkAnchor(hunk) {
|
|
330
|
+
const side = hunk.additionCount > 0 ? "additions" : "deletions";
|
|
331
|
+
const start = side === "additions" ? hunk.additionStart : hunk.deletionStart;
|
|
332
|
+
const first = hunk.hunkContent[0];
|
|
333
|
+
const lead = first?.type === "context" ? first.lines : 0;
|
|
334
|
+
if (lead > 0)
|
|
335
|
+
return { side, line: start + lead - 1 };
|
|
336
|
+
if (start <= 1)
|
|
337
|
+
return { side, line: 0 };
|
|
338
|
+
const count = side === "additions" ? hunk.additionCount : hunk.deletionCount;
|
|
339
|
+
return { side, line: start + count - 1 };
|
|
340
|
+
}
|
|
341
|
+
/** Last line of a hunk's first change block: where "Comment" opens the comment field. */
|
|
342
|
+
function hunkCommentLine(hunk) {
|
|
343
|
+
let addition = hunk.additionStart;
|
|
344
|
+
let deletion = hunk.deletionStart;
|
|
345
|
+
for (const block of hunk.hunkContent) {
|
|
346
|
+
if (block.type === "context") {
|
|
347
|
+
addition += block.lines;
|
|
348
|
+
deletion += block.lines;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
return block.additions > 0
|
|
352
|
+
? { side: "additions", line: addition + block.additions - 1 }
|
|
353
|
+
: { side: "deletions", line: deletion + block.deletions - 1 };
|
|
354
|
+
}
|
|
355
|
+
return { side: "additions", line: Math.max(1, hunk.additionStart) };
|
|
356
|
+
}
|
|
357
|
+
/** Unchanged lines hidden between / around hunks (only meaningful for full-file diffs). */
|
|
358
|
+
function hasHiddenLines(diff) {
|
|
359
|
+
if (diff.isPartial)
|
|
360
|
+
return false;
|
|
361
|
+
if (diff.hunks.some((hunk) => hunk.collapsedBefore > 0))
|
|
362
|
+
return true;
|
|
363
|
+
const last = diff.hunks.at(-1);
|
|
364
|
+
if (!last)
|
|
365
|
+
return false;
|
|
366
|
+
const trailing = last.additionCount > 0
|
|
367
|
+
? diff.additionLines.length - (last.additionStart + last.additionCount - 1)
|
|
368
|
+
: diff.deletionLines.length - (last.deletionStart + last.deletionCount - 1);
|
|
369
|
+
return trailing > 0;
|
|
370
|
+
}
|
|
371
|
+
const lineKey = (side, line) => `${side}:${line}`;
|
|
372
|
+
/**
|
|
373
|
+
* Annotations live inside @pierre/diffs' horizontally scrolling <code> (in its shadow root) and
|
|
374
|
+
* would otherwise be as wide as the longest line: focusing a right-aligned control then scrolls
|
|
375
|
+
* the code sideways. Walk out through the slot to that scroller and pin the annotation to its
|
|
376
|
+
* visible width (the library's wrapper is already sticky at left: 0).
|
|
377
|
+
*/
|
|
378
|
+
function findScroller(element) {
|
|
379
|
+
let node = element.assignedSlot ?? element.parentElement;
|
|
380
|
+
while (node !== null) {
|
|
381
|
+
if (node instanceof HTMLElement) {
|
|
382
|
+
const overflowX = getComputedStyle(node).overflowX;
|
|
383
|
+
if (overflowX === "auto" || overflowX === "scroll")
|
|
384
|
+
return node;
|
|
385
|
+
}
|
|
386
|
+
const root = node.getRootNode();
|
|
387
|
+
node = node.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
function PinnedAnnotation({ children }) {
|
|
392
|
+
// The diff is left to right; the review chrome and comments in it read in the person's direction.
|
|
393
|
+
const direction = useDirection();
|
|
394
|
+
const [width, setWidth] = useState(undefined);
|
|
395
|
+
const [element, setElement] = useState(null);
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
if (element === null)
|
|
398
|
+
return undefined;
|
|
399
|
+
// The slot wrapper is the light-DOM parent; the scroller is found through its assigned slot.
|
|
400
|
+
const host = element.parentElement ?? element;
|
|
401
|
+
const scroller = findScroller(host);
|
|
402
|
+
if (scroller === null || typeof ResizeObserver === "undefined")
|
|
403
|
+
return undefined;
|
|
404
|
+
const update = () => setWidth(scroller.clientWidth);
|
|
405
|
+
update();
|
|
406
|
+
const observer = new ResizeObserver(update);
|
|
407
|
+
observer.observe(scroller);
|
|
408
|
+
return () => observer.disconnect();
|
|
409
|
+
}, [element]);
|
|
410
|
+
return (_jsx("div", { ref: setElement, dir: direction, "data-slot": "diff-annotation", className: "flex flex-col whitespace-normal font-sans text-fg", style: width === undefined ? undefined : { width, maxWidth: width }, children: children }));
|
|
411
|
+
}
|
|
412
|
+
/** `true` while the element is narrower than `min` px (0 = never; unmeasured counts as wide). */
|
|
413
|
+
function useNarrow(element, min) {
|
|
414
|
+
const [narrow, setNarrow] = useState(false);
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
if (element === null || min <= 0 || typeof ResizeObserver === "undefined") {
|
|
417
|
+
setNarrow(false);
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
const update = (width) => setNarrow(width > 0 && width < min);
|
|
421
|
+
update(element.getBoundingClientRect().width);
|
|
422
|
+
const observer = new ResizeObserver((entries) => {
|
|
423
|
+
const entry = entries[0];
|
|
424
|
+
if (entry)
|
|
425
|
+
update(entry.contentRect.width);
|
|
426
|
+
});
|
|
427
|
+
observer.observe(element);
|
|
428
|
+
return () => observer.disconnect();
|
|
429
|
+
}, [element, min]);
|
|
430
|
+
return narrow;
|
|
431
|
+
}
|
|
432
|
+
function assignRef(ref, value) {
|
|
433
|
+
if (typeof ref === "function")
|
|
434
|
+
ref(value);
|
|
435
|
+
else if (ref)
|
|
436
|
+
ref.current = value;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Layout mode. An uncontrolled split view falls back to unified while the container is narrower
|
|
440
|
+
* than `splitMinWidth`; an explicit pick (the toggle, or a controlled `mode`) always wins.
|
|
441
|
+
*/
|
|
442
|
+
function useDiffMode({ root, modeProp, defaultMode, onModeChange, splitMinWidth, }) {
|
|
443
|
+
const narrow = useNarrow(root, splitMinWidth);
|
|
444
|
+
const [chosenMode, setChosenMode] = useState(null);
|
|
445
|
+
const preferred = chosenMode ?? defaultMode;
|
|
446
|
+
const autoUnified = modeProp === undefined && chosenMode === null && preferred === "split" && narrow;
|
|
447
|
+
const mode = modeProp ?? (autoUnified ? "unified" : preferred);
|
|
448
|
+
const setMode = (next) => {
|
|
449
|
+
setChosenMode(next);
|
|
450
|
+
onModeChange?.(next);
|
|
451
|
+
};
|
|
452
|
+
return { mode, autoUnified, setMode };
|
|
453
|
+
}
|
|
454
|
+
/** One annotation per line (the library slots by side + line), merging toolbar, comments and draft. */
|
|
455
|
+
function useDiffAnnotations(fileDiff, hunkToolbar, comments, draftSide, draftLine, notes) {
|
|
456
|
+
return useMemo(() => {
|
|
457
|
+
if (fileDiff === null)
|
|
458
|
+
return [];
|
|
459
|
+
const byLine = new Map();
|
|
460
|
+
const at = (side, line) => {
|
|
461
|
+
const existing = byLine.get(lineKey(side, line));
|
|
462
|
+
if (existing)
|
|
463
|
+
return existing.metadata;
|
|
464
|
+
const metadata = { hunk: null, notes: [], comments: [], draft: false };
|
|
465
|
+
byLine.set(lineKey(side, line), { side, lineNumber: line, metadata });
|
|
466
|
+
return metadata;
|
|
467
|
+
};
|
|
468
|
+
if (hunkToolbar) {
|
|
469
|
+
fileDiff.hunks.forEach((hunk, index) => {
|
|
470
|
+
const anchor = hunkAnchor(hunk);
|
|
471
|
+
at(anchor.side, anchor.line).hunk = index;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
for (const note of notes ?? []) {
|
|
475
|
+
at(toAnnotationSide(note.side), note.line).notes.push(note);
|
|
476
|
+
}
|
|
477
|
+
for (const comment of comments ?? []) {
|
|
478
|
+
at(toAnnotationSide(comment.side), comment.line).comments.push(comment);
|
|
479
|
+
}
|
|
480
|
+
if (draftSide !== undefined && draftLine !== undefined)
|
|
481
|
+
at(draftSide, draftLine).draft = true;
|
|
482
|
+
return [...byLine.values()];
|
|
483
|
+
}, [fileDiff, hunkToolbar, comments, draftSide, draftLine, notes]);
|
|
484
|
+
}
|
|
485
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
486
|
+
/* Pieces */
|
|
487
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
488
|
+
/**
|
|
489
|
+
* `+12 −3`: lines added and removed, in the diff's colours with glyphs (never colour alone), the
|
|
490
|
+
* figures in the reader's digits, spoken as words so they are never read as arithmetic.
|
|
491
|
+
*/
|
|
492
|
+
export function DiffStat({ additions, deletions, className }) {
|
|
493
|
+
const strings = useUiStrings().diffView;
|
|
494
|
+
const f = useFormatters();
|
|
495
|
+
return (_jsxs("span", { "data-slot": "diff-stat", className: cn("inline-flex items-center gap-1.5 font-mono text-xs tabular", className), children: [_jsx("span", { className: "sr-only", children: strings.stats({ additions, deletions }) }), _jsxs("span", { "aria-hidden": true, className: "text-diff-add-fg", children: ["+", f.number(additions)] }), _jsxs("span", { "aria-hidden": true, className: "text-diff-remove-fg", children: ["\u2212", f.number(deletions)] })] }));
|
|
496
|
+
}
|
|
497
|
+
/** `a/src/x.ts` / `b/src/x.ts` (git patch sides) → `src/x.ts`. */
|
|
498
|
+
const stripGitPrefix = (name) => name.replace(/^[ab]\//, "");
|
|
499
|
+
const CHANGE_KIND = {
|
|
500
|
+
new: "new",
|
|
501
|
+
deleted: "deleted",
|
|
502
|
+
"rename-pure": "renamed",
|
|
503
|
+
"rename-changed": "renamed",
|
|
504
|
+
};
|
|
505
|
+
export function DiffView(props) {
|
|
506
|
+
const { oldFile, newFile, patch, mode: modeProp, defaultMode = "unified", onModeChange, splitMinWidth = 900, collapsed: collapsedProp, defaultCollapsed = false, onCollapsedChange, filename, wrap: wrapProp, defaultWrap = false, onWrapChange, showWrapToggle = true, maxHeight, showModeToggle = true, actions, onAcceptHunk, onRevertHunk, onAskAboutHunk, hunkStates, comments, onAddComment, onRemoveComment, commentPlaceholder, lineNotes, ref, className, ...rest } = props;
|
|
507
|
+
const strings = useUiStrings().diffView;
|
|
508
|
+
const expandLabels = strings.expandLines;
|
|
509
|
+
const [root, setRoot] = useState(null);
|
|
510
|
+
const rootRef = useCallback((node) => {
|
|
511
|
+
setRoot(node);
|
|
512
|
+
assignRef(ref, node);
|
|
513
|
+
}, [ref]);
|
|
514
|
+
const { mode, autoUnified, setMode } = useDiffMode({
|
|
515
|
+
root,
|
|
516
|
+
modeProp,
|
|
517
|
+
defaultMode,
|
|
518
|
+
onModeChange,
|
|
519
|
+
splitMinWidth,
|
|
520
|
+
});
|
|
521
|
+
const [wrapState, setWrapState] = useState(defaultWrap);
|
|
522
|
+
const wrap = wrapProp ?? wrapState;
|
|
523
|
+
const setWrap = (next) => {
|
|
524
|
+
setWrapState(next);
|
|
525
|
+
onWrapChange?.(next);
|
|
526
|
+
};
|
|
527
|
+
const [expandAll, setExpandAll] = useState(false);
|
|
528
|
+
const [collapsedState, setCollapsedState] = useState(defaultCollapsed);
|
|
529
|
+
const collapsed = collapsedProp ?? collapsedState;
|
|
530
|
+
const toggleCollapsed = () => {
|
|
531
|
+
setCollapsedState(!collapsed);
|
|
532
|
+
onCollapsedChange?.(!collapsed);
|
|
533
|
+
};
|
|
534
|
+
// Key the parse on primitives so inline `{ name, contents }` literals don't re-diff every render.
|
|
535
|
+
const [oldName, oldContents, oldLang] = [oldFile?.name, oldFile?.contents, oldFile?.lang];
|
|
536
|
+
const [newName, newContents, newLang] = [newFile?.name, newFile?.contents, newFile?.lang];
|
|
537
|
+
const fileDiff = useMemo(() => {
|
|
538
|
+
const parsed = patch === undefined
|
|
539
|
+
? parseSource({
|
|
540
|
+
oldFile: toFile(oldName, oldContents, oldLang),
|
|
541
|
+
newFile: toFile(newName, newContents, newLang),
|
|
542
|
+
})
|
|
543
|
+
: parseSource({ patch });
|
|
544
|
+
if (parsed !== null) {
|
|
545
|
+
parsed.cacheKey =
|
|
546
|
+
patch === undefined
|
|
547
|
+
? diffCacheKey(oldName, oldContents, oldLang, newName, newContents, newLang)
|
|
548
|
+
: diffCacheKey(patch);
|
|
549
|
+
}
|
|
550
|
+
return parsed;
|
|
551
|
+
}, [patch, oldName, oldContents, oldLang, newName, newContents, newLang]);
|
|
552
|
+
const commenting = onAddComment !== undefined;
|
|
553
|
+
const hunkToolbar = commenting || onAcceptHunk !== undefined || onRevertHunk !== undefined || onAskAboutHunk !== undefined;
|
|
554
|
+
const [draft, setDraft] = useState(null);
|
|
555
|
+
const openDraft = useCallback((side, line) => {
|
|
556
|
+
setDraft((current) => current?.side === side && current.line === line ? current : { side, line, text: "" });
|
|
557
|
+
}, []);
|
|
558
|
+
const annotations = useDiffAnnotations(fileDiff, hunkToolbar, comments, draft?.side, draft?.line, lineNotes);
|
|
559
|
+
const options = useMemo(() => {
|
|
560
|
+
const interactions = commenting
|
|
561
|
+
? {
|
|
562
|
+
onLineNumberClick: ({ annotationSide, lineNumber }) => openDraft(annotationSide, lineNumber),
|
|
563
|
+
enableGutterUtility: true,
|
|
564
|
+
onGutterUtilityClick: (range) => openDraft(range.endSide ?? range.side ?? "additions", range.end),
|
|
565
|
+
}
|
|
566
|
+
: {};
|
|
567
|
+
return {
|
|
568
|
+
theme: NIB_SHIKI_THEMES,
|
|
569
|
+
themeType: "system",
|
|
570
|
+
diffStyle: mode,
|
|
571
|
+
diffIndicators: "classic",
|
|
572
|
+
overflow: wrap ? "wrap" : "scroll",
|
|
573
|
+
disableFileHeader: true,
|
|
574
|
+
hunkSeparators: "line-info",
|
|
575
|
+
lineDiffType: "word-alt",
|
|
576
|
+
expandUnchanged: expandAll,
|
|
577
|
+
unsafeCSS: UNSAFE_CSS,
|
|
578
|
+
onPostRender: (container) => keyboardReach(container, expandLabels, !wrap),
|
|
579
|
+
...interactions,
|
|
580
|
+
};
|
|
581
|
+
}, [mode, wrap, expandAll, commenting, openDraft, expandLabels]);
|
|
582
|
+
const themesReady = useDiffThemesReady();
|
|
583
|
+
const highlighterReady = useDiffLanguagesReady(fileDiff, themesReady);
|
|
584
|
+
const path = filename ?? newFile?.name ?? oldFile?.name ?? (fileDiff ? stripGitPrefix(fileDiff.name) : "diff");
|
|
585
|
+
const copyValue = patch ?? newFile?.contents ?? oldFile?.contents ?? "";
|
|
586
|
+
const empty = fileDiff !== null && fileDiff.hunks.length === 0;
|
|
587
|
+
const viewable = fileDiff !== null && !empty;
|
|
588
|
+
const expandable = useMemo(() => fileDiff !== null && hasHiddenLines(fileDiff), [fileDiff]);
|
|
589
|
+
const submitDraft = () => {
|
|
590
|
+
if (draft === null || draft.text.trim() === "")
|
|
591
|
+
return;
|
|
592
|
+
onAddComment?.({ side: toCommentSide(draft.side), line: draft.line, text: draft.text.trim() });
|
|
593
|
+
setDraft(null);
|
|
594
|
+
};
|
|
595
|
+
const renderAnnotation = (annotation) => {
|
|
596
|
+
const { hunk: hunkIndex, notes, comments: lineComments, draft: hasDraft } = annotation.metadata;
|
|
597
|
+
const hunk = hunkIndex === null ? undefined : fileDiff?.hunks[hunkIndex];
|
|
598
|
+
return (_jsxs(PinnedAnnotation, { children: [notes.map((note) => (_jsx("div", { "data-slot": "diff-line-note", "data-note-id": note.id, children: note.content }, note.id))), lineComments.map((comment) => (_jsx(CommentCard, { comment: comment, onRemove: onRemoveComment }, comment.id))), hasDraft && draft !== null && (_jsx(CommentComposer, { draft: draft, onTextChange: (text) => setDraft((current) => (current ? { ...current, text } : current)), onCancel: () => setDraft(null), onSubmit: submitDraft, placeholder: commentPlaceholder }, lineKey(draft.side, draft.line))), hunk !== undefined && hunkIndex !== null && fileDiff !== null && (_jsx(HunkToolbar, { index: hunkIndex, total: fileDiff.hunks.length, hunk: hunk, state: hunkStates?.[hunkIndex], onAccept: onAcceptHunk && (() => onAcceptHunk(hunkIndex)), onRevert: onRevertHunk && (() => onRevertHunk(hunkIndex)), onAsk: onAskAboutHunk && (() => onAskAboutHunk(hunkIndex, getHunkExcerpt(fileDiff, hunkIndex))), onComment: commenting
|
|
599
|
+
? () => {
|
|
600
|
+
const target = hunkCommentLine(hunk);
|
|
601
|
+
openDraft(target.side, target.line);
|
|
602
|
+
}
|
|
603
|
+
: undefined }))] }));
|
|
604
|
+
};
|
|
605
|
+
return (_jsxs("div", { ref: rootRef, "data-slot": "diff-view", "data-mode": mode, "data-auto-unified": autoUnified || undefined, "data-collapsed": collapsed || undefined, className: cn("overflow-hidden rounded-xl border border-border-subtle bg-bg-inset text-fg", className), ...rest, children: [_jsx(DiffViewHeader, { path: path, fileDiff: fileDiff, collapsed: collapsed, onToggleCollapsed: toggleCollapsed, mode: mode, onModeChange: showModeToggle && viewable ? setMode : undefined, wrap: wrap, onWrapChange: showWrapToggle && viewable ? setWrap : undefined, expandAll: expandAll, onExpandAllChange: viewable && expandable ? setExpandAll : undefined, copyValue: copyValue, copyLabel: patch === undefined ? strings.copyFile : strings.copyPatch, actions: actions }), _jsx(KitMotion, { children: _jsx(AnimatePresence, { initial: false, children: !collapsed && (_jsx(m.div, { variants: fade, initial: "initial", animate: "animate", exit: "exit", "data-slot": "diff-view-body", className: cn(maxHeight !== undefined && "overflow-auto"), style: maxHeight === undefined ? {} : { maxHeight }, children: fileDiff === null ? (_jsx(UnparsedBody, { patch: patch })) : empty ? (_jsx("p", { className: "px-4 py-3 text-sm text-fg-subtle", children: strings.noChanges })) : (_jsx(DiffBody, { fileDiff: fileDiff, mode: mode, options: options, ready: highlighterReady, annotations: annotations, renderAnnotation: renderAnnotation })) }, "body")) }) })] }));
|
|
606
|
+
}
|
|
607
|
+
/** The diff itself, or a same-height placeholder while the highlighter boots. */
|
|
608
|
+
function DiffBody({ fileDiff, mode, options, ready, annotations, renderAnnotation, }) {
|
|
609
|
+
const { pending: poolPending, request } = use(DiffPoolContext);
|
|
610
|
+
useEffect(() => request(), [request]);
|
|
611
|
+
if (ready && !poolPending)
|
|
612
|
+
return (_jsx(FileDiff, { fileDiff: fileDiff, options: options, style: HOST_STYLE, lineAnnotations: annotations, renderAnnotation: renderAnnotation }));
|
|
613
|
+
const rows = mode === "split" ? fileDiff.splitLineCount : fileDiff.unifiedLineCount;
|
|
614
|
+
return _jsx("div", { "aria-busy": "true", style: { height: rows * 20 + 20 } });
|
|
615
|
+
}
|
|
616
|
+
/** Fallback when the input can't be parsed: show the raw patch (still highlighted) or a note. */
|
|
617
|
+
function UnparsedBody({ patch }) {
|
|
618
|
+
const strings = useUiStrings().diffView;
|
|
619
|
+
if (patch === undefined)
|
|
620
|
+
return _jsx("p", { className: "px-4 py-3 text-sm text-fg-subtle", children: strings.nothingToCompare });
|
|
621
|
+
return _jsx(CodeBlock, { code: patch, lang: "diff", header: false, className: "rounded-none border-0" });
|
|
622
|
+
}
|
|
623
|
+
/** A quiet header above each hunk's change; its actions surface on hover or keyboard focus. */
|
|
624
|
+
function HunkToolbar({ index, total, hunk, state, onAccept, onRevert, onAsk, onComment }) {
|
|
625
|
+
const strings = useUiStrings().diffView.hunk;
|
|
626
|
+
const context = hunk.hunkContext?.trim();
|
|
627
|
+
const n = index + 1;
|
|
628
|
+
return (_jsxs("div", { "data-slot": "diff-hunk-toolbar", "data-state": state, role: "toolbar", "aria-label": strings.label({ index: n, total }), className: "group/hunk flex h-7 items-center gap-2 border-y border-border-subtle bg-bg-subtle pe-1 ps-3 text-xs text-fg-subtle", children: [_jsx("span", { className: "shrink-0 tabular", children: strings.position({ index: n, total }) }), context && _jsx("span", { className: "min-w-0 truncate font-mono text-xs text-fg-subtle", children: context }), state && (_jsxs("span", { className: "inline-flex shrink-0 items-center gap-1 text-fg-muted", children: [state === "accepted" ? (_jsx(Check, { "aria-hidden": true, strokeWidth: 2.5, className: "size-3 text-success-fg" })) : (_jsx(Undo2, { "aria-hidden": true, className: "size-3" })), state === "accepted" ? strings.accepted : strings.reverted] })), _jsxs("div", { className: cn("ms-auto flex shrink-0 items-center gap-0.5 opacity-0", "transition-opacity duration-(--duration-fast) ease-(--ease-standard) motion-reduce:transition-none", "group-hover/hunk:opacity-100 group-focus-within/hunk:opacity-100 [@media(hover:none)]:opacity-100"), children: [onAsk && (_jsx(Button, { size: "xs", variant: "ghost", leading: _jsx(MessageCircleQuestion, {}), "aria-label": strings.askLabel({ index: n }), onClick: onAsk, children: strings.ask })), onComment && (_jsx(Button, { size: "xs", variant: "ghost", leading: _jsx(MessageSquarePlus, {}), "aria-label": strings.commentLabel({ index: n }), onClick: onComment, children: strings.comment })), onRevert && (_jsx(Button, { size: "xs", variant: "ghost", leading: _jsx(Undo2, {}), "aria-label": strings.revertLabel({ index: n }), disabled: state === "reverted", onClick: onRevert, children: strings.revert })), onAccept && (_jsx(Button, { size: "xs", variant: "ghost", leading: _jsx(Check, {}), "aria-label": strings.acceptLabel({ index: n }), disabled: state === "accepted", onClick: onAccept, children: strings.accept }))] })] }));
|
|
629
|
+
}
|
|
630
|
+
function CommentCard({ comment, onRemove, }) {
|
|
631
|
+
const strings = useUiStrings().diffView.comment;
|
|
632
|
+
const author = comment.author ?? strings.you;
|
|
633
|
+
return (_jsxs("div", { "data-slot": "diff-comment", className: "mx-3 my-1.5 flex max-w-2xl items-start gap-2 rounded-lg border border-border-subtle bg-surface px-2.5 py-2 shadow-xs", children: [_jsx(Avatar, { name: author, size: "xs", className: "mt-px" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-xs", children: [_jsx("span", { className: "font-medium text-fg", children: author }), _jsxs("span", { className: "text-fg-subtle", children: [" \u00B7 ", strings.line({ line: comment.line })] })] }), _jsx("p", { dir: "auto", className: "mt-0.5 whitespace-pre-wrap break-words text-sm text-fg", children: comment.text })] }), onRemove && (_jsx(IconButton, { size: "xs", className: "-my-0.5 -me-1 text-fg-subtle", "aria-label": strings.remove({ line: comment.line }), onClick: () => onRemove(comment.id), children: _jsx(X, {}) }))] }));
|
|
634
|
+
}
|
|
635
|
+
/** Inline comment field under a line: ⌘↵ adds the comment, Esc cancels. */
|
|
636
|
+
function CommentComposer({ draft, onTextChange, onCancel, onSubmit, placeholder }) {
|
|
637
|
+
const strings = useUiStrings().diffView.comment;
|
|
638
|
+
const textareaRef = useRef(null);
|
|
639
|
+
const { side, line } = draft;
|
|
640
|
+
// Keyed by line at the call site, so this runs whenever the field opens on a line.
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
// preventScroll: never drag the code sideways to reach the field.
|
|
643
|
+
textareaRef.current?.focus({ preventScroll: true });
|
|
644
|
+
}, []);
|
|
645
|
+
const empty = draft.text.trim() === "";
|
|
646
|
+
// The host's binding for sending, else the kit's own.
|
|
647
|
+
const sendKeys = useShortcut("sendComments") ?? KIT_SHORTCUTS.sendComments;
|
|
648
|
+
const onKeyDown = (event) => {
|
|
649
|
+
if (matchesShortcut(event, sendKeys)) {
|
|
650
|
+
event.preventDefault();
|
|
651
|
+
event.stopPropagation();
|
|
652
|
+
onSubmit();
|
|
653
|
+
}
|
|
654
|
+
else if (event.key === "Escape" && !isComposing(event)) {
|
|
655
|
+
// An input method's Escape closes its candidates, never the comment with its text.
|
|
656
|
+
event.preventDefault();
|
|
657
|
+
event.stopPropagation();
|
|
658
|
+
onCancel();
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
return (_jsxs("div", { "data-slot": "diff-comment-composer", className: cn(fieldChrome, "mx-3 my-1.5 max-w-2xl rounded-lg p-1.5", "has-[textarea:focus]:border-border-strong has-[textarea:focus]:ring-3"), children: [_jsx(Textarea, { ref: textareaRef, variant: "bare", size: "sm", minRows: 2, maxRows: 8, value: draft.text, onValueChange: onTextChange, onKeyDown: onKeyDown, "aria-label": side === "deletions" ? strings.onOldLine({ line }) : strings.onNewLine({ line }), placeholder: placeholder ?? strings.placeholder, className: "px-1 py-0.5" }), _jsxs("div", { className: "mt-1 flex items-center gap-1.5", children: [_jsx("span", { className: "me-auto inline-flex items-center gap-1 ps-1 text-xs text-fg-subtle", children: richText(strings.toAdd, {
|
|
662
|
+
keys: _jsx(Shortcut, { keys: sendKeys, size: "sm", spoken: true }),
|
|
663
|
+
}) }), _jsx(Button, { size: "xs", variant: "ghost", onClick: onCancel, children: strings.cancel }), _jsx(Button, { size: "xs", variant: "primary", disabled: empty, onClick: onSubmit, children: strings.submit })] })] }));
|
|
664
|
+
}
|
|
665
|
+
function DiffViewHeader({ path, fileDiff, collapsed, onToggleCollapsed, mode, onModeChange, wrap, onWrapChange, expandAll, onExpandAllChange, copyValue, copyLabel, actions, }) {
|
|
666
|
+
const strings = useUiStrings().diffView;
|
|
667
|
+
const stats = useMemo(() => (fileDiff ? getDiffStats(fileDiff) : null), [fileDiff]);
|
|
668
|
+
const prev = fileDiff?.prevName === undefined ? null : stripGitPrefix(fileDiff.prevName);
|
|
669
|
+
const prevName = prev !== null && prev !== path ? prev : null;
|
|
670
|
+
const renamed = fileDiff?.type === "rename-pure" || fileDiff?.type === "rename-changed";
|
|
671
|
+
const changeKind = fileDiff && !(renamed && prevName === null) ? CHANGE_KIND[fileDiff.type] : undefined;
|
|
672
|
+
const changeLabel = changeKind === undefined ? undefined : strings.changeKinds[changeKind];
|
|
673
|
+
const nextMode = mode === "split" ? "unified" : "split";
|
|
674
|
+
return (_jsxs("div", { "data-slot": "diff-view-header", className: cn("flex h-10 items-center gap-2 px-1.5", !collapsed && "border-b border-border-subtle"), children: [_jsxs("button", { type: "button", "aria-expanded": !collapsed, "aria-label": collapsed ? strings.expandFile({ path }) : strings.collapseFile({ path }), onClick: onToggleCollapsed, className: cn("flex h-7 min-w-0 items-center gap-1.5 rounded-md pe-2 ps-1 outline-none", "transition-colors duration-(--duration-spring-snappy) ease-(--ease-spring-snappy)", "hover:bg-hover focus-visible:ring-focus"), children: [_jsx(DisclosureChevron, { open: !collapsed, className: "text-fg-subtle" }), _jsx(FileCode, { "aria-hidden": true, className: "size-3.5 shrink-0 text-fg-subtle" }), prevName !== null && (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 truncate font-mono text-xs text-fg-subtle line-through decoration-fg-faint", children: prevName }), _jsx(ArrowRight, { "aria-hidden": true, className: "size-3 shrink-0 text-fg-faint" })] })), _jsx(PathText, { path: path })] }), changeLabel && (_jsx("span", { className: "shrink-0 rounded-sm border border-border-subtle px-1.5 text-xs font-medium text-fg-muted", children: changeLabel })), stats && _jsx(DiffStat, { ...stats, className: "shrink-0" }), _jsxs("div", { className: "ms-auto flex shrink-0 items-center gap-0.5", children: [actions, onExpandAllChange && !collapsed && (_jsx(Tooltip, { content: expandAll ? strings.collapseUnchangedLines : strings.showAllLines, children: _jsx(IconButton, { size: "xs", className: "text-fg-subtle aria-pressed:bg-active aria-pressed:text-fg", "aria-label": strings.showUnchangedLines, "aria-pressed": expandAll, onClick: () => onExpandAllChange(!expandAll), children: expandAll ? _jsx(FoldVertical, {}) : _jsx(UnfoldVertical, {}) }) })), onWrapChange && !collapsed && (_jsx(Tooltip, { content: wrap ? strings.unwrapLines : strings.wrapLines, children: _jsx(IconButton, { size: "xs", className: "text-fg-subtle aria-pressed:bg-active aria-pressed:text-fg", "aria-label": strings.softWrap, "aria-pressed": wrap, onClick: () => onWrapChange(!wrap), children: _jsx(WrapText, {}) }) })), onModeChange && !collapsed && (_jsx(Tooltip, { content: nextMode === "split" ? strings.splitView : strings.unifiedView, children: _jsx(IconButton, { size: "xs", className: "text-fg-subtle", "aria-label": nextMode === "split" ? strings.switchToSplit : strings.switchToUnified, onClick: () => onModeChange(nextMode), children: nextMode === "split" ? _jsx(Columns2, {}) : _jsx(Rows2, {}) }) })), _jsx(CopyButton, { value: copyValue, label: copyLabel })] })] }));
|
|
675
|
+
}
|