@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.
Files changed (292) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +124 -2
  4. package/TRADEMARKS.md +22 -0
  5. package/dist/_bundled/money/index.d.ts +98 -0
  6. package/dist/_bundled/money/index.js +152 -0
  7. package/dist/_bundled/tokens/index.d.ts +2 -0
  8. package/dist/_bundled/tokens/index.js +2 -0
  9. package/dist/_bundled/tokens/palette.gen.d.ts +51 -0
  10. package/dist/_bundled/tokens/palette.gen.js +206 -0
  11. package/dist/_bundled/tokens/spec.d.ts +291 -0
  12. package/dist/_bundled/tokens/spec.js +221 -0
  13. package/dist/components/accordion.d.ts +27 -0
  14. package/dist/components/accordion.js +66 -0
  15. package/dist/components/alert-dialog.d.ts +59 -0
  16. package/dist/components/alert-dialog.js +82 -0
  17. package/dist/components/alert.d.ts +141 -0
  18. package/dist/components/alert.js +55 -0
  19. package/dist/components/appear.d.ts +21 -0
  20. package/dist/components/appear.js +42 -0
  21. package/dist/components/avatar.d.ts +196 -0
  22. package/dist/components/avatar.js +135 -0
  23. package/dist/components/badge.d.ts +183 -0
  24. package/dist/components/badge.js +68 -0
  25. package/dist/components/block-stack.d.ts +33 -0
  26. package/dist/components/block-stack.js +197 -0
  27. package/dist/components/button.d.ts +327 -0
  28. package/dist/components/button.js +101 -0
  29. package/dist/components/card.d.ts +131 -0
  30. package/dist/components/card.js +100 -0
  31. package/dist/components/checkbox.d.ts +34 -0
  32. package/dist/components/checkbox.js +37 -0
  33. package/dist/components/choice-row.d.ts +104 -0
  34. package/dist/components/choice-row.js +56 -0
  35. package/dist/components/clamped-text.d.ts +17 -0
  36. package/dist/components/clamped-text.js +47 -0
  37. package/dist/components/code-block.d.ts +48 -0
  38. package/dist/components/code-block.js +124 -0
  39. package/dist/components/code-input.d.ts +49 -0
  40. package/dist/components/code-input.js +59 -0
  41. package/dist/components/combobox.d.ts +194 -0
  42. package/dist/components/combobox.js +176 -0
  43. package/dist/components/command-palette.d.ts +57 -0
  44. package/dist/components/command-palette.js +83 -0
  45. package/dist/components/context-menu.d.ts +24 -0
  46. package/dist/components/context-menu.js +30 -0
  47. package/dist/components/copy-button.d.ts +9 -0
  48. package/dist/components/copy-button.js +20 -0
  49. package/dist/components/copy-command.d.ts +18 -0
  50. package/dist/components/copy-command.js +19 -0
  51. package/dist/components/data-table.d.ts +240 -0
  52. package/dist/components/data-table.js +63 -0
  53. package/dist/components/dialog.d.ts +80 -0
  54. package/dist/components/dialog.js +85 -0
  55. package/dist/components/diff-lines.d.ts +20 -0
  56. package/dist/components/diff-lines.js +10 -0
  57. package/dist/components/diff-view.d.ts +128 -0
  58. package/dist/components/diff-view.js +675 -0
  59. package/dist/components/disclosure.d.ts +33 -0
  60. package/dist/components/disclosure.js +51 -0
  61. package/dist/components/drawn-check.d.ts +8 -0
  62. package/dist/components/drawn-check.js +16 -0
  63. package/dist/components/edge-resizer.d.ts +26 -0
  64. package/dist/components/edge-resizer.js +53 -0
  65. package/dist/components/empty-state.d.ts +112 -0
  66. package/dist/components/empty-state.js +56 -0
  67. package/dist/components/expand-toggle.d.ts +19 -0
  68. package/dist/components/expand-toggle.js +10 -0
  69. package/dist/components/field.d.ts +48 -0
  70. package/dist/components/field.js +60 -0
  71. package/dist/components/filter-chip.d.ts +47 -0
  72. package/dist/components/filter-chip.js +33 -0
  73. package/dist/components/group.d.ts +10 -0
  74. package/dist/components/group.js +9 -0
  75. package/dist/components/icon-text.d.ts +11 -0
  76. package/dist/components/icon-text.js +10 -0
  77. package/dist/components/image-sources.d.ts +57 -0
  78. package/dist/components/image-sources.js +58 -0
  79. package/dist/components/image-thumbnail.d.ts +34 -0
  80. package/dist/components/image-thumbnail.js +58 -0
  81. package/dist/components/image-viewer.d.ts +60 -0
  82. package/dist/components/image-viewer.js +411 -0
  83. package/dist/components/inline-edit.d.ts +22 -0
  84. package/dist/components/inline-edit.js +51 -0
  85. package/dist/components/input.d.ts +155 -0
  86. package/dist/components/input.js +109 -0
  87. package/dist/components/item-label.d.ts +11 -0
  88. package/dist/components/item-label.js +5 -0
  89. package/dist/components/item-row.d.ts +23 -0
  90. package/dist/components/item-row.js +19 -0
  91. package/dist/components/kbd.d.ts +55 -0
  92. package/dist/components/kbd.js +86 -0
  93. package/dist/components/link-policy.d.ts +24 -0
  94. package/dist/components/link-policy.js +32 -0
  95. package/dist/components/linked-text.d.ts +15 -0
  96. package/dist/components/linked-text.js +48 -0
  97. package/dist/components/loading-region.d.ts +18 -0
  98. package/dist/components/loading-region.js +17 -0
  99. package/dist/components/markdown.d.ts +34 -0
  100. package/dist/components/markdown.js +307 -0
  101. package/dist/components/menu.d.ts +132 -0
  102. package/dist/components/menu.js +102 -0
  103. package/dist/components/message-bubble.d.ts +57 -0
  104. package/dist/components/message-bubble.js +28 -0
  105. package/dist/components/meta-line.d.ts +28 -0
  106. package/dist/components/meta-line.js +36 -0
  107. package/dist/components/money-input.d.ts +23 -0
  108. package/dist/components/money-input.js +34 -0
  109. package/dist/components/native-select.d.ts +120 -0
  110. package/dist/components/native-select.js +85 -0
  111. package/dist/components/page-trail.d.ts +30 -0
  112. package/dist/components/page-trail.js +43 -0
  113. package/dist/components/page.d.ts +80 -0
  114. package/dist/components/page.js +60 -0
  115. package/dist/components/path-text.d.ts +8 -0
  116. package/dist/components/path-text.js +11 -0
  117. package/dist/components/percent-input.d.ts +20 -0
  118. package/dist/components/percent-input.js +32 -0
  119. package/dist/components/popover.d.ts +52 -0
  120. package/dist/components/popover.js +36 -0
  121. package/dist/components/progress.d.ts +389 -0
  122. package/dist/components/progress.js +142 -0
  123. package/dist/components/radio.d.ts +65 -0
  124. package/dist/components/radio.js +54 -0
  125. package/dist/components/remote-image-gate.d.ts +34 -0
  126. package/dist/components/remote-image-gate.js +60 -0
  127. package/dist/components/resizable.d.ts +23 -0
  128. package/dist/components/resizable.js +30 -0
  129. package/dist/components/rolling-number.d.ts +13 -0
  130. package/dist/components/rolling-number.js +69 -0
  131. package/dist/components/scroll-area.d.ts +17 -0
  132. package/dist/components/scroll-area.js +25 -0
  133. package/dist/components/section-label.d.ts +10 -0
  134. package/dist/components/section-label.js +21 -0
  135. package/dist/components/section-nav.d.ts +22 -0
  136. package/dist/components/section-nav.js +80 -0
  137. package/dist/components/segmented-control.d.ts +125 -0
  138. package/dist/components/segmented-control.js +101 -0
  139. package/dist/components/select.d.ts +120 -0
  140. package/dist/components/select.js +89 -0
  141. package/dist/components/separator.d.ts +10 -0
  142. package/dist/components/separator.js +12 -0
  143. package/dist/components/sheet.d.ts +101 -0
  144. package/dist/components/sheet.js +129 -0
  145. package/dist/components/shortcut-recorder.d.ts +31 -0
  146. package/dist/components/shortcut-recorder.js +67 -0
  147. package/dist/components/skeleton.d.ts +31 -0
  148. package/dist/components/skeleton.js +33 -0
  149. package/dist/components/slider.d.ts +138 -0
  150. package/dist/components/slider.js +98 -0
  151. package/dist/components/spinner.d.ts +37 -0
  152. package/dist/components/spinner.js +16 -0
  153. package/dist/components/switch.d.ts +59 -0
  154. package/dist/components/switch.js +56 -0
  155. package/dist/components/tab-strip.d.ts +29 -0
  156. package/dist/components/tab-strip.js +42 -0
  157. package/dist/components/tabs.d.ts +13 -0
  158. package/dist/components/tabs.js +68 -0
  159. package/dist/components/text-link.d.ts +54 -0
  160. package/dist/components/text-link.js +38 -0
  161. package/dist/components/textarea.d.ts +75 -0
  162. package/dist/components/textarea.js +90 -0
  163. package/dist/components/toast.d.ts +74 -0
  164. package/dist/components/toast.js +128 -0
  165. package/dist/components/tooltip.d.ts +50 -0
  166. package/dist/components/tooltip.js +46 -0
  167. package/dist/components/truncated-text.d.ts +15 -0
  168. package/dist/components/truncated-text.js +19 -0
  169. package/dist/i18n/defaults.d.ts +7 -0
  170. package/dist/i18n/defaults.js +7 -0
  171. package/dist/i18n/format.d.ts +84 -0
  172. package/dist/i18n/format.js +169 -0
  173. package/dist/i18n/index.d.ts +6 -0
  174. package/dist/i18n/index.js +8 -0
  175. package/dist/i18n/locales/ru-formatters.d.ts +3 -0
  176. package/dist/i18n/locales/ru-formatters.js +22 -0
  177. package/dist/i18n/locales/ru.d.ts +5 -0
  178. package/dist/i18n/locales/ru.js +185 -0
  179. package/dist/i18n/plural.d.ts +20 -0
  180. package/dist/i18n/plural.js +26 -0
  181. package/dist/i18n/provider.d.ts +36 -0
  182. package/dist/i18n/provider.js +39 -0
  183. package/dist/i18n/rich.d.ts +14 -0
  184. package/dist/i18n/rich.js +44 -0
  185. package/dist/i18n/ru-plural.d.ts +3 -0
  186. package/dist/i18n/ru-plural.js +4 -0
  187. package/dist/i18n/strings/alert-dialog.d.ts +5 -0
  188. package/dist/i18n/strings/alert-dialog.js +5 -0
  189. package/dist/i18n/strings/block-stack.d.ts +17 -0
  190. package/dist/i18n/strings/block-stack.js +11 -0
  191. package/dist/i18n/strings/code-block.d.ts +18 -0
  192. package/dist/i18n/strings/code-block.js +13 -0
  193. package/dist/i18n/strings/combobox.d.ts +12 -0
  194. package/dist/i18n/strings/combobox.js +10 -0
  195. package/dist/i18n/strings/command-palette.d.ts +17 -0
  196. package/dist/i18n/strings/command-palette.js +11 -0
  197. package/dist/i18n/strings/diff-view.d.ts +92 -0
  198. package/dist/i18n/strings/diff-view.js +68 -0
  199. package/dist/i18n/strings/image-viewer.d.ts +43 -0
  200. package/dist/i18n/strings/image-viewer.js +32 -0
  201. package/dist/i18n/strings/index.d.ts +314 -0
  202. package/dist/i18n/strings/index.js +43 -0
  203. package/dist/i18n/strings/kbd.d.ts +42 -0
  204. package/dist/i18n/strings/kbd.js +39 -0
  205. package/dist/i18n/strings/markdown.d.ts +23 -0
  206. package/dist/i18n/strings/markdown.js +18 -0
  207. package/dist/i18n/strings/money.d.ts +7 -0
  208. package/dist/i18n/strings/money.js +5 -0
  209. package/dist/i18n/strings/primitives.d.ts +25 -0
  210. package/dist/i18n/strings/primitives.js +18 -0
  211. package/dist/i18n/strings/progress.d.ts +4 -0
  212. package/dist/i18n/strings/progress.js +4 -0
  213. package/dist/i18n/strings/shortcut-recorder.d.ts +15 -0
  214. package/dist/i18n/strings/shortcut-recorder.js +11 -0
  215. package/dist/i18n/strings/time.d.ts +41 -0
  216. package/dist/i18n/strings/time.js +20 -0
  217. package/dist/i18n/strings/toast.d.ts +6 -0
  218. package/dist/i18n/strings/toast.js +6 -0
  219. package/dist/i18n/time-zones.d.ts +20 -0
  220. package/dist/i18n/time-zones.js +64 -0
  221. package/dist/index.d.ts +84 -0
  222. package/dist/index.js +88 -0
  223. package/dist/lib/ansi.d.ts +55 -0
  224. package/dist/lib/ansi.js +438 -0
  225. package/dist/lib/clock.d.ts +32 -0
  226. package/dist/lib/clock.js +106 -0
  227. package/dist/lib/cn.d.ts +12 -0
  228. package/dist/lib/cn.js +28 -0
  229. package/dist/lib/control-props.d.ts +21 -0
  230. package/dist/lib/control-props.js +14 -0
  231. package/dist/lib/direction.d.ts +13 -0
  232. package/dist/lib/direction.js +25 -0
  233. package/dist/lib/image-size.d.ts +12 -0
  234. package/dist/lib/image-size.js +66 -0
  235. package/dist/lib/keycaps.d.ts +21 -0
  236. package/dist/lib/keycaps.js +62 -0
  237. package/dist/lib/keyed.d.ts +9 -0
  238. package/dist/lib/keyed.js +14 -0
  239. package/dist/lib/keys.d.ts +49 -0
  240. package/dist/lib/keys.js +159 -0
  241. package/dist/lib/kit-motion.d.ts +9 -0
  242. package/dist/lib/kit-motion.js +12 -0
  243. package/dist/lib/link-check-dialog.d.ts +11 -0
  244. package/dist/lib/link-check-dialog.js +18 -0
  245. package/dist/lib/motion-features.d.ts +1 -0
  246. package/dist/lib/motion-features.js +4 -0
  247. package/dist/lib/motion-provider.d.ts +5 -0
  248. package/dist/lib/motion-provider.js +8 -0
  249. package/dist/lib/motion.d.ts +39 -0
  250. package/dist/lib/motion.js +49 -0
  251. package/dist/lib/number-format.d.ts +114 -0
  252. package/dist/lib/number-format.js +313 -0
  253. package/dist/lib/patch.d.ts +72 -0
  254. package/dist/lib/patch.js +344 -0
  255. package/dist/lib/path.d.ts +34 -0
  256. package/dist/lib/path.js +85 -0
  257. package/dist/lib/search.d.ts +10 -0
  258. package/dist/lib/search.js +20 -0
  259. package/dist/lib/shiki-themes.d.ts +7 -0
  260. package/dist/lib/shiki-themes.js +115 -0
  261. package/dist/lib/shiki.d.ts +101 -0
  262. package/dist/lib/shiki.js +353 -0
  263. package/dist/lib/shortcut-context.d.ts +23 -0
  264. package/dist/lib/shortcut-context.js +29 -0
  265. package/dist/lib/text.d.ts +56 -0
  266. package/dist/lib/text.js +149 -0
  267. package/dist/lib/theme-scope.d.ts +18 -0
  268. package/dist/lib/theme-scope.js +24 -0
  269. package/dist/lib/theme.d.ts +40 -0
  270. package/dist/lib/theme.js +96 -0
  271. package/dist/lib/tree-keys.d.ts +72 -0
  272. package/dist/lib/tree-keys.js +126 -0
  273. package/dist/lib/url.d.ts +17 -0
  274. package/dist/lib/url.js +38 -0
  275. package/dist/lib/use-copy-to-clipboard.d.ts +13 -0
  276. package/dist/lib/use-copy-to-clipboard.js +40 -0
  277. package/dist/lib/use-delayed-loading.d.ts +11 -0
  278. package/dist/lib/use-delayed-loading.js +31 -0
  279. package/dist/lib/use-file-picker.d.ts +26 -0
  280. package/dist/lib/use-file-picker.js +43 -0
  281. package/dist/lib/use-hydrated.d.ts +6 -0
  282. package/dist/lib/use-hydrated.js +11 -0
  283. package/dist/lib/use-latest.d.ts +12 -0
  284. package/dist/lib/use-latest.js +22 -0
  285. package/dist/lib/use-resolved-theme.d.ts +12 -0
  286. package/dist/lib/use-resolved-theme.js +25 -0
  287. package/dist/styles/base.css +393 -0
  288. package/dist/styles/code.css +35 -0
  289. package/dist/styles/fonts.css +12 -0
  290. package/dist/styles/index.css +13 -0
  291. package/dist/styles/tokens.gen.css +494 -0
  292. 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
+ }