1/* oxlint-disable eslint/no-restricted-imports -- This is the single styled adapter around Pierre's raw viewer. */
2import {
3 CodeView,
4 type CodeViewHandle,
5 type CodeViewProps,
6 type ControlledCodeViewProps,
7 type UncontrolledCodeViewProps,
8} from "@pierre/diffs/react";
9/* oxlint-enable eslint/no-restricted-imports */
10import type { Ref } from "react";
11
12import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering";
13import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
14
15/** The app's diff stylesheet, shared by single-file renderers so their separators match. */
16export const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS}
17:is(
18 [data-line],
19 [data-line-annotation],
20 [data-merge-conflict],
21 [data-merge-conflict-actions],
22 [data-no-newline]
23)[data-selected-line] {
24 --diffs-line-bg: light-dark(
25 color-mix(
26 in lab,
27 var(--code-background) 88%,
28 color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base))
29 ),
30 color-mix(
31 in lab,
32 var(--code-background) 80%,
33 color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base))
34 )
35 ) !important;
36}
37
38:is([data-gutter-buffer], [data-column-number])[data-selected-line] {
39 --diffs-line-bg: light-dark(
40 color-mix(
41 in lab,
42 var(--code-background) 91%,
43 color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base))
44 ),
45 color-mix(
46 in lab,
47 var(--code-background) 85%,
48 color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base))
49 )
50 ) !important;
51}
52
53[data-indicators="bars"]
54 :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line] {
55 position: relative;
56}
57
58[data-indicators="bars"]
59 :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line]::before {
60 position: absolute !important;
61 inset-block: 0 !important;
62 inset-inline-start: 0 !important;
63 display: block !important;
64 width: 4px !important;
65 min-width: 4px !important;
66 max-width: 4px !important;
67 height: auto !important;
68 padding: 0 !important;
69 content: "" !important;
70 background-color: var(--diffs-modified-base) !important;
71 background-image: none !important;
72}
73
74[data-file-info] {
75 background-color: var(--code-background) !important;
76 border-block-color: transparent !important;
77 color: var(--code-foreground) !important;
78}
79
80[data-diffs-header] {
81 position: sticky !important;
82 top: 0;
83 z-index: 4;
84 background-color: var(--code-background) !important;
85 border-bottom-color: transparent !important;
86 align-items: center !important;
87 font-family: var(--font-sans) !important;
88 font-size: 12px !important;
89 line-height: 1 !important;
90 min-height: 32px !important;
91 padding-block: 6px !important;
92 padding-inline: 8px 12px !important;
93}
94
95[data-diffs-header]:hover {
96 /* A native scrollbar gutter cannot be painted by descendants. Use an inset edge cue instead
97 of a full-width band that would look accidentally clipped at the gutter. */
98 background-color: var(--code-background) !important;
99 box-shadow: inset 3px 0 color-mix(in srgb, var(--code-foreground) 24%, transparent);
100}
101
102/* Hunk separators are the way to the unchanged lines between hunks. They keep the library's
103 layout, with the expand button in its own cell, but sit one step off the code surface so they
104 read as quiet controls; hover brings them forward. */
105:is([data-separator="line-info"], [data-separator="line-info-basic"]) {
106 /* Exact and margin-free: the virtualizer places rows from hunkSeparatorHeight below. */
107 height: 24px !important;
108 margin-block: 0 !important;
109 --diffs-bg-separator: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground));
110}
111
112:is([data-separator="line-info"], [data-separator="line-info-basic"]) [data-separator-content] {
113 color: color-mix(in srgb, var(--code-foreground) 50%, var(--code-background)) !important;
114 font-family: var(--font-sans) !important;
115 font-size: 11px !important;
116 text-decoration: none !important;
117}
118
119:is([data-separator="line-info"], [data-separator="line-info-basic"]) [data-expand-button] {
120 color: color-mix(in srgb, var(--code-foreground) 50%, var(--code-background)) !important;
121}
122
123:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index]
124 :is([data-separator-content], [data-expand-button]):hover {
125 color: var(--code-foreground) !important;
126 background-color: color-mix(
127 in srgb,
128 var(--code-background) 92%,
129 var(--code-foreground)
130 ) !important;
131}
132
133[data-diffs-header] [data-header-content] {
134 align-items: center !important;
135 line-height: 1 !important;
136}
137
138/* Pierre clips file names with overflow: hidden for its start ellipsis. A 1em line box cuts off
139 descenders. Only the clipped names get room: 16px plus the header's 12px of padding stays
140 inside its 32px min-height, which diffHeaderHeight assumes. */
141[data-diffs-header] :is([data-title], [data-prev-name]) {
142 line-height: 16px !important;
143}
144
145[data-diffs-header] [data-metadata] {
146 align-items: center !important;
147 line-height: 1 !important;
148 font-variant-numeric: tabular-nums;
149}
150
151[data-diffs-header] [data-additions-count],
152[data-diffs-header] [data-deletions-count] {
153 font-family: var(--font-mono) !important;
154 font-size: 11px !important;
155 font-variant-numeric: tabular-nums;
156 line-height: 1 !important;
157}
158
159[data-diffs-header] [data-change-icon],
160[data-diffs-header] [data-rename-icon] {
161 display: block;
162 flex-shrink: 0;
163}
164
165[data-title] {
166 cursor: pointer;
167 transition: color 120ms ease;
168 font-family: var(--font-sans) !important;
169}
170
171[data-title]:hover {
172 color: color-mix(in srgb, var(--code-foreground) 84%, var(--primary)) !important;
173}
174
175/* Expanding a file mounts its body all at once; easing it in matches the 200ms the app's
176 collapsibles take. Appearance only — the viewer owns geometry, so height cannot animate.
177 Departing content cuts, the same one-way rule the pull request chrome fold follows. */
178[data-diff],
179[data-file] {
180 transition: opacity 200ms ease-out;
181}
182
183@starting-style {
184 [data-diff],
185 [data-file] {
186 opacity: 0;
187 }
188}
189
190@media (prefers-reduced-motion: reduce) {
191 [data-diff],
192 [data-file] {
193 transition: none;
194 }
195}
196`;
197
198export type StyledDiffCodeViewOptions<LAnnotation> = Omit<
199 NonNullable<CodeViewProps<LAnnotation, undefined>["options"]>,
200 "unsafeCSS" | "itemMetrics" | "layout"
201>;
202
203type StyledDiffCodeViewProps<LAnnotation> = (
204 | Omit<ControlledCodeViewProps<LAnnotation, undefined>, "options">
205 | Omit<UncontrolledCodeViewProps<LAnnotation, undefined>, "options">
206) & {
207 readonly options?: StyledDiffCodeViewOptions<LAnnotation>;
208 readonly viewerRef?: Ref<CodeViewHandle<LAnnotation, undefined>>;
209 /**
210 * Appended to the shared stylesheet inside the viewer's shadow root, for a surface that has
211 * to restyle chrome the viewer owns — such as replacing its per-file line counts.
212 */
213 readonly unsafeCSSExtra?: string;
214};
215
216/** The shared web CodeView surface: app styling and virtualized geometry stay paired here. */
217export function StyledDiffCodeView<LAnnotation = undefined>({
218 options,
219 viewerRef,
220 className,
221 unsafeCSSExtra,
222 ...props
223}: StyledDiffCodeViewProps<LAnnotation>) {
224 return (
225 <DiffWorkerPoolProvider>
226 <CodeView<LAnnotation>
227 {...props}
228 {...(viewerRef ? { ref: viewerRef } : {})}
229 // The custom element itself is focusable for keyboard scrolling. Its native outline sits
230 // outside the panel clipping boundary; actual controls inside retain their own indicators.
231 className={
232 className
233 ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}`
234 : "diff-render-surface [--code-background:var(--background)] outline-none"
235 }
236 options={{
237 ...options,
238 unsafeCSS: unsafeCSSExtra
239 ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}`
240 : DIFF_VIEW_UNSAFE_CSS,
241 itemMetrics: {
242 diffHeaderHeight: 32,
243 hunkSeparatorHeight: 24,
244 // Pierre uses its general file spacing as a fallback in expanded-file layout paths.
245 // Keep it zero alongside the explicit paddingTop or expanding the first file can
246 // reintroduce the library's default 8px gap above its header.
247 spacing: 0,
248 paddingTop: 0,
249 // Unlike the gap above, the 8px under a file's last line is painted
250 // unconditionally by Pierre's stylesheet (`--diffs-gap-fallback`), so the metric has
251 // to count it: at zero every expanded file's virtual height ran 8px short of its
252 // rendered height, and the end of the list sat past the reachable scroll range —
253 // one clipped file row per expanded file above it.
254 paddingBottom: 8,
255 },
256 layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
257 }}
258 />
259 </DiffWorkerPoolProvider>
260 );
261}