diff --git a/apps/demo/src/components/App.tsx b/apps/demo/src/components/App.tsx deleted file mode 100644 index 11edd2810..000000000 --- a/apps/demo/src/components/App.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { - type BundledLanguage, - type DiffsThemeNames, - isHighlighterNull, - preloadHighlighter, -} from '@pierre/diffs'; -import * as React from 'react'; -import { useCallback, useState } from 'react'; - -import { CodeConfigs } from '../mocks/'; -import '../style.css'; -import { createFakeContentStream } from '../utils/createFakeContentStream'; -import { FileStream, type FileStreamProps } from './FileStream'; - -export function App() { - const [codez, setCodez] = useState([]); - - const handleStartStreaming = useCallback(() => { - setCodez( - CodeConfigs.map(({ content, letterByLetter, options }) => ({ - stream: createFakeContentStream(content, letterByLetter), - options, - })) - ); - }, []); - - const handlePreload = useCallback(() => { - if (isHighlighterNull()) { - const langs: BundledLanguage[] = []; - const themes: DiffsThemeNames[] = []; - for (const item of CodeConfigs) { - if ('lang' in item.options) { - langs.push(item.options.lang); - } - if ('themes' in item.options) { - themes.push(item.options.theme.dark); - themes.push(item.options.theme.light); - } - // else if ('theme' in item.options) { - // themes.push(item.options.theme); - // } - } - void preloadHighlighter({ langs, themes }); - } - }, []); - - return ( - <> -
- -
- [RAW / REACT] -
-
-
- {codez.map((props, index) => ( - - ))} -
- - ); -} diff --git a/apps/demo/src/components/FileStream.tsx b/apps/demo/src/components/FileStream.tsx deleted file mode 100644 index bd9048364..000000000 --- a/apps/demo/src/components/FileStream.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { - FileStream as FileStreamClass, - type FileStreamOptions, -} from '@pierre/diffs'; -import { useEffect, useRef, useState } from 'react'; - -export type { FileStreamOptions }; - -export interface FileStreamProps { - stream: ReadableStream; - options: FileStreamOptions; -} - -export function FileStream({ stream, options }: FileStreamProps) { - const [fileStream] = useState(() => new FileStreamClass(options)); - const ref = useRef(null); - useEffect(() => { - if (ref.current == null) return; - void fileStream.setup(stream, ref.current); - return () => fileStream.cleanUp(); - }, [fileStream, stream]); - return
;
-}
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index 6b2166fd7..406b398a1 100644
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -1,5 +1,4 @@
 import {
-  type BundledLanguage,
   DEFAULT_THEMES,
   DIFFS_TAG_NAME,
   type DiffsThemeNames,
@@ -9,11 +8,14 @@ import {
   type FileDiffOptions,
   type FileOptions,
   FileStream,
+  type FileStreamOptions,
   isHighlighterNull,
   parseDiffFromFile,
   type ParsedPatch,
   parsePatchFiles,
   preloadHighlighter,
+  type SupportedLanguages,
+  type ThemesType,
   UnresolvedFile,
   VirtualizedFile,
   VirtualizedFileDiff,
@@ -22,7 +24,6 @@ import {
 import type { WorkerPoolManager } from '@pierre/diffs/worker';
 
 import {
-  CodeConfigs,
   FAKE_DIFF_LINE_ANNOTATIONS,
   FAKE_LINE_ANNOTATIONS,
   FILE_CONFLICT,
@@ -31,13 +32,43 @@ import {
   type LineCommentMetadata,
 } from './mocks/';
 import './style.css';
+import mdContent from './mocks/example_md.txt?raw';
+import tsContent from './mocks/example_ts.txt?raw';
 import { createFakeContentStream } from './utils/createFakeContentStream';
+import { createHighlighterCleanup } from './utils/createHighlighterCleanup';
 import { createWorkerAPI } from './utils/createWorkerAPI';
 import {
   renderAnnotation,
   renderDiffAnnotation,
 } from './utils/renderAnnotation';
 
+const DEMO_THEME: DiffsThemeNames | ThemesType = DEFAULT_THEMES;
+const WORKER_POOL = true;
+const VIRTUALIZE = true;
+const CRAZY_FILE = false;
+const LARGE_CONFLICT_FILE = false;
+
+const FileStreamCodeConfigs: FileStreamCodeConfigsItem[] = [
+  {
+    content: tsContent,
+    letterByLetter: false,
+    options: {
+      lang: 'tsx',
+      theme: DEMO_THEME,
+      ...createHighlighterCleanup(),
+    },
+  },
+  {
+    content: mdContent,
+    letterByLetter: true,
+    options: {
+      lang: 'markdown',
+      theme: DEMO_THEME,
+      ...createHighlighterCleanup(),
+    },
+  },
+];
+
 const diffInstances: (
   | FileDiff
   | VirtualizedFileDiff
@@ -46,6 +77,12 @@ const fileInstances: File[] = [];
 const streamingInstances: FileStream[] = [];
 const conflictInstances: UnresolvedFile[] = [];
 
+interface FileStreamCodeConfigsItem {
+  content: string;
+  letterByLetter: boolean;
+  options: FileStreamOptions;
+}
+
 function cleanupInstances(container: HTMLElement) {
   for (const instances of [
     diffInstances,
@@ -91,13 +128,11 @@ async function loadLargeConflictFile(): Promise {
   return loadingLargeConflict;
 }
 
-const WORKER_POOL = true;
-
 // Create worker API - helper handles worker creation automatically!
 const poolManager: WorkerPoolManager | undefined = WORKER_POOL
   ? (() => {
       const manager = createWorkerAPI({
-        theme: DEFAULT_THEMES,
+        theme: DEMO_THEME,
         langs: ['typescript', 'tsx'],
         preferredHighlighter: 'shiki-wasm',
       });
@@ -111,8 +146,6 @@ const poolManager: WorkerPoolManager | undefined = WORKER_POOL
     })()
   : undefined;
 
-const VIRTUALIZE = true;
-
 const virtualizer: Virtualizer | undefined = (() =>
   VIRTUALIZE ? new Virtualizer() : undefined)();
 
@@ -120,7 +153,7 @@ function startStreaming() {
   const container = document.getElementById('wrapper');
   if (container == null) return;
   cleanupInstances(container);
-  for (const { content, letterByLetter, options } of CodeConfigs) {
+  for (const { content, letterByLetter, options } of FileStreamCodeConfigs) {
     const instance = new FileStream(options);
     void instance.setup(
       createFakeContentStream(content, letterByLetter),
@@ -164,7 +197,8 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) {
         | FileDiff
         | VirtualizedFileDiff;
       const options: FileDiffOptions = {
-        theme: { dark: 'pierre-dark', light: 'pierre-light' },
+        theme: DEMO_THEME,
+        themeType,
         diffStyle: unified ? 'unified' : 'split',
         overflow: wrap ? 'wrap' : 'scroll',
         renderAnnotation: renderDiffAnnotation,
@@ -182,7 +216,6 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) {
             }
           );
         },
-        themeType,
         lineHoverHighlight: 'both',
         expansionLineCount: 10,
         // expandUnchanged: true,
@@ -382,13 +415,17 @@ export function workerRenderDiff(parsedPatches: ParsedPatch[]) {
 
 function handlePreload() {
   if (!isHighlighterNull()) return;
-  const langs: BundledLanguage[] = [];
+  const langs: SupportedLanguages[] = [];
   const themes: DiffsThemeNames[] = [];
-  for (const item of CodeConfigs) {
-    if ('lang' in item.options) {
+  for (const item of FileStreamCodeConfigs) {
+    if (item.options.lang != null) {
       langs.push(item.options.lang);
     }
-    if ('themes' in item.options) {
+    if (item.options.theme == null) {
+      continue;
+    } else if (typeof item.options.theme === 'string') {
+      themes.push(item.options.theme);
+    } else {
       themes.push(item.options.theme.dark);
       themes.push(item.options.theme.light);
     }
@@ -560,27 +597,20 @@ function toggleTheme() {
   document.documentElement.dataset.themeType =
     pageTheme === 'dark' ? 'light' : 'dark';
 
-  for (const instance of diffInstances) {
-    const themeSetting = instance.options.themeType ?? 'system';
-    const currentMode = themeSetting === 'system' ? pageTheme : themeSetting;
-    instance.setThemeType(currentMode === 'light' ? 'dark' : 'light');
-  }
-
-  for (const instance of streamingInstances) {
-    const themeSetting = instance.options.themeType ?? 'system';
-    const currentMode = themeSetting === 'system' ? pageTheme : themeSetting;
-    instance.setThemeType(currentMode === 'light' ? 'dark' : 'light');
-  }
-
-  for (const instance of fileInstances) {
-    const themeSetting = instance.options.themeType ?? 'system';
-    const currentMode = themeSetting === 'system' ? pageTheme : themeSetting;
-    instance.setThemeType(currentMode === 'light' ? 'dark' : 'light');
+  for (const instances of [
+    diffInstances,
+    fileInstances,
+    streamingInstances,
+    conflictInstances,
+  ]) {
+    for (const instance of instances) {
+      const themeSetting = instance.options.themeType ?? 'system';
+      const currentMode = themeSetting === 'system' ? pageTheme : themeSetting;
+      instance.setThemeType(currentMode === 'light' ? 'dark' : 'light');
+    }
   }
 }
 
-const CRAZY_FILE = false;
-
 const fileExample: FileContents | Promise = (() => {
   if (CRAZY_FILE) {
     return new Promise((resolve) => {
@@ -605,8 +635,6 @@ const fileConflict: FileContents = {
   contents: FILE_CONFLICT,
 };
 
-const LARGE_CONFLICT_FILE = false;
-
 const renderFileButton = document.getElementById('render-file');
 if (renderFileButton != null) {
   // oxlint-disable-next-line @typescript-oxlint/no-misused-promises
@@ -625,7 +653,7 @@ if (renderFileButton != null) {
       | VirtualizedFile;
     const options: FileOptions = {
       overflow: wrap ? 'wrap' : 'scroll',
-      theme: { dark: 'pierre-dark', light: 'pierre-light' },
+      theme: DEMO_THEME,
       themeType: getThemeType(),
       renderAnnotation,
       renderCustomMetadata() {
@@ -732,7 +760,7 @@ if (renderFileConflictButton != null) {
     wrapper.appendChild(fileContainer);
     const instance = new UnresolvedFile(
       {
-        theme: DEFAULT_THEMES,
+        theme: DEMO_THEME,
         themeType: getThemeType(),
         overflow: wrap ? 'wrap' : 'scroll',
         renderAnnotation,
diff --git a/apps/demo/src/mocks/index.ts b/apps/demo/src/mocks/index.ts
index a913b34eb..492b87116 100644
--- a/apps/demo/src/mocks/index.ts
+++ b/apps/demo/src/mocks/index.ts
@@ -1,10 +1,5 @@
-import type {
-  DiffLineAnnotation,
-  FileStreamOptions,
-  LineAnnotation,
-} from '@pierre/diffs';
+import { type DiffLineAnnotation, type LineAnnotation } from '@pierre/diffs';
 
-import { createHighlighterCleanup } from '../utils/createHighlighterCleanup';
 import mdContent from './example_md.txt?raw';
 import tsContent from './example_ts.txt?raw';
 import fileAnsi from './fileAnsi.txt?raw';
@@ -14,27 +9,6 @@ import fileOld from './fileOld.txt?raw';
 
 export { mdContent, tsContent };
 
-export const CodeConfigs = [
-  {
-    content: tsContent,
-    letterByLetter: false,
-    options: {
-      lang: 'tsx',
-      theme: { dark: 'pierre-dark', light: 'pierre-light' },
-      ...createHighlighterCleanup(),
-    } satisfies FileStreamOptions,
-  },
-  {
-    content: mdContent,
-    letterByLetter: true,
-    options: {
-      lang: 'markdown',
-      theme: { dark: 'pierre-dark', light: 'pierre-light' },
-      ...createHighlighterCleanup(),
-    } satisfies FileStreamOptions,
-  },
-] as const;
-
 export const FILE_OLD = fileOld;
 export const FILE_NEW = fileNew;
 export const FILE_ANSI = fileAnsi;
diff --git a/apps/docs/app/diff-examples/CustomHeader/CustomHeader.tsx b/apps/docs/app/diff-examples/CustomHeader/CustomHeader.tsx
index c3148a6c9..22953b82e 100644
--- a/apps/docs/app/diff-examples/CustomHeader/CustomHeader.tsx
+++ b/apps/docs/app/diff-examples/CustomHeader/CustomHeader.tsx
@@ -1,21 +1,24 @@
 'use client';
 
 import { MultiFileDiff } from '@pierre/diffs/react';
-import type { PreloadMultiFileDiffResult } from '@pierre/diffs/ssr';
+import type {
+  FileDiffMetadata,
+  PreloadMultiFileDiffResult,
+} from '@pierre/diffs/ssr';
 import { IconCheckboxFill, IconChevronSm, IconSquircleLg } from '@pierre/icons';
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
 
 import { FeatureHeader } from '../FeatureHeader';
+import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group';
 
-// =============================================================================
-// Custom Header Example (renderHeaderMetadata)
-// =============================================================================
+type HeaderMode = 'custom' | 'metadata';
 
 interface CustomHeaderProps {
   prerenderedDiff: PreloadMultiFileDiffResult;
 }
 
 export function CustomHeader({ prerenderedDiff }: CustomHeaderProps) {
+  const [headerMode, setHeaderMode] = useState('metadata');
   const [isViewed, setIsViewed] = useState(false);
   const [collapsed, setCollapsed] = useState(false);
 
@@ -34,15 +37,21 @@ export function CustomHeader({ prerenderedDiff }: CustomHeaderProps) {
   return (
     
- Use renderHeaderPrefix and{' '} - renderHeaderMetadata to inject custom content into the - file header while preserving the built-in layout. + Switch between lightweight header metadata and a fully custom header + rendered inside the built-in data-diffs-header shell. } /> + setHeaderMode(value as HeaderMode)} + > + Metadata + Custom header + { - return ( - - ); - }} - renderHeaderMetadata={() => { - return ( - - ); - }} + renderCustomHeader={ + headerMode === 'custom' + ? (fileDiff: FileDiffMetadata) => { + return ( + + ); + } + : undefined + } + renderHeaderPrefix={ + headerMode === 'metadata' + ? () => { + return ( + + ); + } + : undefined + } + renderHeaderMetadata={ + headerMode === 'metadata' + ? () => { + return ( + + ); + } + : undefined + } />
); } + +interface CustomHeaderComponentProps { + collapsed: boolean; + fileDiff: FileDiffMetadata; + toggleCollapsed(): unknown; +} + +function CustomHeaderComponent({ + collapsed, + fileDiff, + toggleCollapsed, +}: CustomHeaderComponentProps) { + const { additions, deletions } = useMemo(() => { + let additions = 0; + let deletions = 0; + for (const hunk of fileDiff.hunks) { + additions += hunk.additionLines; + deletions += hunk.deletionLines; + } + return { additions, deletions }; + }, [fileDiff]); + + return ( +
+
+ +
+
+ AppConfig.swift +
+
+ Single slot layout + + Custom UI +
+
+
+
+ + {deletions} deletions + + + {additions} additions + +
+
+ ); +} + +interface HeaderPrefixProps { + collapsed: boolean; + toggleCollapsed(): unknown; +} + +function HeaderPrefix({ collapsed, toggleCollapsed }: HeaderPrefixProps) { + return ( + + ); +} + +function ViewedButton({ + isViewed, + onClick, + className, +}: { + isViewed: boolean; + onClick(): void; + className?: string; +}) { + return ( + + ); +} diff --git a/apps/docs/app/diff-examples/CustomHeader/constants.ts b/apps/docs/app/diff-examples/CustomHeader/constants.ts index 9451b9e63..aa7535b1b 100644 --- a/apps/docs/app/diff-examples/CustomHeader/constants.ts +++ b/apps/docs/app/diff-examples/CustomHeader/constants.ts @@ -1,15 +1,11 @@ -import { type FileContents, parseDiffFromFile } from '@pierre/diffs'; -import type { - PreloadFileDiffOptions, - PreloadMultiFileDiffOptions, -} from '@pierre/diffs/ssr'; +import { type FileContents } from '@pierre/diffs'; +import type { PreloadMultiFileDiffOptions } from '@pierre/diffs/ssr'; import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS'; -export const CUSTOM_HEADER_EXAMPLE: PreloadMultiFileDiffOptions = { - oldFile: { - name: 'AppConfig.swift', - contents: `import Foundation +const CUSTOM_HEADER_OLD_FILE: FileContents = { + name: 'AppConfig.swift', + contents: `import Foundation struct AppConfig { static let shared = AppConfig() @@ -32,10 +28,11 @@ struct AppConfig { } } `, - }, - newFile: { - name: 'AppConfig.swift', - contents: `import Foundation +}; + +const CUSTOM_HEADER_NEW_FILE: FileContents = { + name: 'AppConfig.swift', + contents: `import Foundation struct AppConfig { static let shared = AppConfig() @@ -65,97 +62,16 @@ struct AppConfig { } } `, - }, - options: { - theme: { dark: 'pierre-dark', light: 'pierre-light' }, - themeType: 'dark', - diffStyle: 'split', - disableBackground: false, - unsafeCSS: CustomScrollbarCSS, - }, }; -const FULL_CUSTOM_OLD_FILE: FileContents = { - name: 'utils.ts', - contents: `// String utilities -export function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -export function truncate(str: string, maxLength: number): string { - if (str.length <= maxLength) return str; - return str.slice(0, maxLength) + '...'; -} - -// Array utilities -export function unique(array: T[]): T[] { - return [...new Set(array)]; -} - -export function shuffle(array: T[]): T[] { - const result = [...array]; - for (let i = result.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [result[i], result[j]] = [result[j], result[i]]; - } - return result; -} - -// Object utilities -export function pick(obj: T, keys: K[]): Pick { - const result = {} as Pick; - for (const key of keys) { - result[key] = obj[key]; - } - return result; -} - -export function deepClone(obj: T): T { - return JSON.parse(JSON.stringify(obj)); -} -`, -}; - -const FULL_CUSTOM_NEW_FILE: FileContents = { - name: 'utils.ts', - contents: `// String utilities -export function capitalize(str: string): string { - if (!str) return str; - return str.charAt(0).toUpperCase() + str.slice(1); -} - -export function truncate(str: string, max: number, ellipsis = '…'): string { - if (str.length <= max) return str; - return str.slice(0, max) + ellipsis; -} - -// Array utilities -export function unique(array: T[]): T[] { - return [...new Set(array)]; -} - -// Object utilities -export function pick(obj: T, keys: K[]): Pick { - const result = {} as Pick; - for (const key of keys) { - result[key] = obj[key]; - } - return result; -} - -export function deepClone(obj: T): T { - return structuredClone(obj); -} -`, -}; - -export const FULL_CUSTOM_HEADER_EXAMPLE: PreloadFileDiffOptions = { - fileDiff: parseDiffFromFile(FULL_CUSTOM_OLD_FILE, FULL_CUSTOM_NEW_FILE), +export const CUSTOM_HEADER_EXAMPLE: PreloadMultiFileDiffOptions = { + oldFile: CUSTOM_HEADER_OLD_FILE, + newFile: CUSTOM_HEADER_NEW_FILE, options: { theme: { dark: 'pierre-dark', light: 'pierre-light' }, themeType: 'dark', - diffStyle: 'unified', - disableFileHeader: true, + diffStyle: 'split', + disableBackground: false, unsafeCSS: CustomScrollbarCSS, }, }; diff --git a/apps/docs/app/diff-examples/CustomHunkSeparators/constants.ts b/apps/docs/app/diff-examples/CustomHunkSeparators/constants.ts index a0e5a0eda..c688cf9cf 100644 --- a/apps/docs/app/diff-examples/CustomHunkSeparators/constants.ts +++ b/apps/docs/app/diff-examples/CustomHunkSeparators/constants.ts @@ -61,7 +61,7 @@ export const CUSTOM_HUNK_SEPARATORS_EXAMPLE: PreloadMultiFileDiffOptions ( - {fileDiff?.newName} + // renderCustomHeader replaces the built-in header content entirely. + // + // Render custom content on the right side of the built-in header. + // Callback arg: FileDiffMetadata + renderHeaderMetadata={(fileDiff) => ( + {fileDiff.name} )} // ───────────────────────────────────────────────────────────── @@ -748,12 +756,18 @@ interface CommentMetadata { )} // ───────────────────────────────────────────────────────────── - // HEADER METADATA + // HEADER CALLBACKS // ───────────────────────────────────────────────────────────── - // Render custom content on the right side of the file header. - // Props: { file } - renderHeaderMetadata={({ file }) => ( + // File header callbacks receive FileContents directly. + // renderHeaderPrefix renders at the beginning of the built-in header, + // before the filename. + // renderHeaderMetadata renders at the end of the built-in header. + // renderCustomHeader replaces the built-in header content entirely. + // Callback arg: FileContents + // + // Render custom content on the right side of the built-in header. + renderHeaderMetadata={(file) => ( {file.name} )} diff --git a/apps/docs/app/docs/ReactAPI/content.mdx b/apps/docs/app/docs/ReactAPI/content.mdx index ad38220ac..82e457479 100644 --- a/apps/docs/app/docs/ReactAPI/content.mdx +++ b/apps/docs/app/docs/ReactAPI/content.mdx @@ -39,10 +39,16 @@ component has similar props, but uses `LineAnnotation` instead of Header customization and collapsing behavior: -- Use `renderHeaderMetadata` to render custom UI at the end of the file header - (after the diff stats). -- Use `renderHeaderPrefix` to render custom UI at the start of the file header - (before the filename). +- Use `renderHeaderPrefix` to render custom UI at the beginning of the built-in + header, before the filename and icons, while keeping the default header + layout. +- Use `renderHeaderMetadata` to render custom UI at the end of the built-in + header, after the diff stats, while keeping the default header layout. +- Use `renderCustomHeader` when you want to replace the built-in header content + with your own custom designed one. +- For diff components, these header callbacks receive + `fileDiff: FileDiffMetadata`. +- For `File`, the corresponding header callbacks receive `file: FileContents`. - Use `options.collapsed` to hide file body content while keeping the file header visible. diff --git a/apps/docs/app/docs/VanillaAPI/constants.ts b/apps/docs/app/docs/VanillaAPI/constants.ts index 718e95287..9c2a500a8 100644 --- a/apps/docs/app/docs/VanillaAPI/constants.ts +++ b/apps/docs/app/docs/VanillaAPI/constants.ts @@ -326,10 +326,19 @@ const instance = new FileDiff({ // RENDER CALLBACKS // ───────────────────────────────────────────────────────────── - // Render custom content in the file header (after +/- stats) - renderHeaderMetadata({ oldFile, newFile, fileDiff }) { + // Diff header render callbacks receive FileDiffMetadata directly. + // This includes renderCustomHeader, renderHeaderPrefix, and + // renderHeaderMetadata. + // renderHeaderPrefix renders at the beginning of the built-in header, + // before the filename and icon. + // renderHeaderMetadata renders at the end of the built-in header, + // after the +/- line metrics. + // renderCustomHeader replaces the built-in header content entirely. + // + // Render custom content at the end of the built-in header. + renderHeaderMetadata(fileDiff) { const span = document.createElement('span'); - span.textContent = fileDiff?.newName ?? ''; + span.textContent = fileDiff.name; return span; }, diff --git a/apps/docs/app/docs/VanillaAPI/content.mdx b/apps/docs/app/docs/VanillaAPI/content.mdx index 85bfc1aee..4c0e4ef96 100644 --- a/apps/docs/app/docs/VanillaAPI/content.mdx +++ b/apps/docs/app/docs/VanillaAPI/content.mdx @@ -34,10 +34,15 @@ uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). Header customization and collapsing behavior: -- Use `renderHeaderMetadata` to render custom UI at the end of the file header - (after the diff stats). -- Use `renderHeaderPrefix` to render custom UI at the start of the file header - (before the filename). +- Use `renderHeaderPrefix` to render custom UI at the beginning of the built-in + `FileDiff` header, before the filename and icon, while keeping the default + header layout. +- Use `renderHeaderMetadata` to render custom UI at the end of the built-in + `FileDiff` header, after the diff stats, while keeping the default header + layout. +- Use `renderCustomHeader` when you want to replace the built-in header content + entirely. +- In `File`, header callbacks receive `file: FileContents`. - Use `collapsed` in constructor options to hide file body content while keeping the file header visible. diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt index c29115336..a11c5d848 100644 --- a/apps/docs/public/llms-full.txt +++ b/apps/docs/public/llms-full.txt @@ -507,10 +507,16 @@ component has similar props, but uses `LineAnnotation` instead of Header customization and collapsing behavior: -- Use `renderHeaderMetadata` to render custom UI at the end of the file header - (after the diff stats). -- Use `renderHeaderPrefix` to render custom UI at the start of the file header - (before the filename). +- Use `renderHeaderPrefix` to render custom UI at the beginning of the built-in + header, before the filename and icons, while keeping the default header + layout. +- Use `renderHeaderMetadata` to render custom UI at the end of the built-in + header, after the diff stats, while keeping the default header layout. +- Use `renderCustomHeader` when you want to replace the built-in header content + with your own custom designed one. +- For diff components, these header callbacks receive + `fileDiff: FileDiffMetadata`. +- For `File`, the corresponding header callbacks receive `file: FileContents`. - Use `options.collapsed` to hide file body content while keeping the file header visible. @@ -923,14 +929,22 @@ interface ThreadMetadata { )} // ───────────────────────────────────────────────────────────── - // HEADER METADATA + // HEADER CALLBACKS // ───────────────────────────────────────────────────────────── - // Render custom content on the right side of the file header, + // All diff header render callbacks receive FileDiffMetadata directly. + // This includes renderCustomHeader, renderHeaderPrefix, and + // renderHeaderMetadata. + // renderHeaderPrefix renders at the beginning of the built-in header, + // before the filename. + // renderHeaderMetadata renders at the end of the built-in header, // after the +/- line metrics. - // Props: { oldFile?, newFile?, fileDiff? } - renderHeaderMetadata={({ fileDiff }) => ( - {fileDiff?.newName} + // renderCustomHeader replaces the built-in header content entirely. + // + // Render custom content on the right side of the built-in header. + // Callback arg: FileDiffMetadata + renderHeaderMetadata={(fileDiff) => ( + {fileDiff.name} )} // ───────────────────────────────────────────────────────────── @@ -1187,12 +1201,18 @@ interface CommentMetadata { )} // ───────────────────────────────────────────────────────────── - // HEADER METADATA + // HEADER CALLBACKS // ───────────────────────────────────────────────────────────── - // Render custom content on the right side of the file header. - // Props: { file } - renderHeaderMetadata={({ file }) => ( + // File header callbacks receive FileContents directly. + // renderHeaderPrefix renders at the beginning of the built-in header, + // before the filename. + // renderHeaderMetadata renders at the end of the built-in header. + // renderCustomHeader replaces the built-in header content entirely. + // Callback arg: FileContents + // + // Render custom content on the right side of the built-in header. + renderHeaderMetadata={(file) => ( {file.name} )} @@ -1338,10 +1358,15 @@ uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). Header customization and collapsing behavior: -- Use `renderHeaderMetadata` to render custom UI at the end of the file header - (after the diff stats). -- Use `renderHeaderPrefix` to render custom UI at the start of the file header - (before the filename). +- Use `renderHeaderPrefix` to render custom UI at the beginning of the built-in + `FileDiff` header, before the filename and icon, while keeping the default + header layout. +- Use `renderHeaderMetadata` to render custom UI at the end of the built-in + `FileDiff` header, after the diff stats, while keeping the default header + layout. +- Use `renderCustomHeader` when you want to replace the built-in header content + entirely. +- In `File`, header callbacks receive `file: FileContents`. - Use `collapsed` in constructor options to hide file body content while keeping the file header visible. @@ -1660,10 +1685,19 @@ const instance = new FileDiff({ // RENDER CALLBACKS // ───────────────────────────────────────────────────────────── - // Render custom content in the file header (after +/- stats) - renderHeaderMetadata({ oldFile, newFile, fileDiff }) { + // Diff header render callbacks receive FileDiffMetadata directly. + // This includes renderCustomHeader, renderHeaderPrefix, and + // renderHeaderMetadata. + // renderHeaderPrefix renders at the beginning of the built-in header, + // before the filename and icon. + // renderHeaderMetadata renders at the end of the built-in header, + // after the +/- line metrics. + // renderCustomHeader replaces the built-in header content entirely. + // + // Render custom content at the end of the built-in header. + renderHeaderMetadata(fileDiff) { const span = document.createElement('span'); - span.textContent = fileDiff?.newName ?? ''; + span.textContent = fileDiff.name; return span; }, diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 8e23056e8..72b1b9cd6 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -2,11 +2,13 @@ import type { Element as HASTElement } from 'hast'; import { toHtml } from 'hast-util-to-html'; import { + CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, DIFFS_TAG_NAME, EMPTY_RENDER_RANGE, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID, + THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; import { @@ -20,6 +22,7 @@ import { ResizeManager } from '../managers/ResizeManager'; import { FileRenderer, type FileRenderResult } from '../renderers/FileRenderer'; import { SVGSpriteSheet } from '../sprite'; import type { + AppliedThemeStyleCache, BaseCodeOptions, FileContents, LineAnnotation, @@ -35,9 +38,10 @@ import { areRenderRangesEqual } from '../utils/areRenderRangesEqual'; import { createAnnotationWrapperNode } from '../utils/createAnnotationWrapperNode'; import { createGutterUtilityContentNode } from '../utils/createGutterUtilityContentNode'; import { createUnsafeCSSStyleNode } from '../utils/createUnsafeCSSStyleNode'; -import { wrapUnsafeCSS } from '../utils/cssWrappers'; +import { wrapThemeCSS, wrapUnsafeCSS } from '../utils/cssWrappers'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; +import { upsertHostThemeStyle } from '../utils/hostTheme'; import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; import type { WorkerPoolManager } from '../worker'; @@ -72,6 +76,7 @@ export interface FileOptions enableHoverUtility?: boolean; renderHeaderPrefix?: RenderFileMetadata; renderCustomMetadata?: RenderFileMetadata; + renderCustomHeader?: RenderFileMetadata; /** * When true, errors during rendering are rethrown instead of being caught * and displayed in the DOM. Useful for testing or when you want to handle @@ -117,7 +122,10 @@ export class File { protected code: HTMLElement | undefined; protected bufferBefore: HTMLElement | undefined; protected bufferAfter: HTMLElement | undefined; + protected themeCSSStyle: HTMLStyleElement | undefined; + protected appliedThemeCSS: AppliedThemeStyleCache | undefined; protected unsafeCSSStyle: HTMLStyleElement | undefined; + protected appliedUnsafeCSS: string | undefined; protected gutterUtilityContent: HTMLElement | undefined; protected errorWrapper: HTMLElement | undefined; protected placeHolder: HTMLElement | undefined; @@ -126,6 +134,7 @@ export class File { protected lastRowCount: number | undefined; protected headerElement: HTMLElement | undefined; + protected headerCustom: HTMLElement | undefined; protected headerPrefix: HTMLElement | undefined; protected headerMetadata: HTMLElement | undefined; @@ -182,33 +191,23 @@ export class File { } public setThemeType(themeType: ThemeTypes): void { - const currentThemeType = this.options.themeType ?? 'system'; - if (currentThemeType === themeType) { + if ((this.options.themeType ?? 'system') === themeType) { return; } this.mergeOptions({ themeType }); - this.fileRenderer.setThemeType(themeType); - - if (this.headerElement != null) { - if (themeType === 'system') { - delete this.headerElement.dataset.themeType; - } else { - this.headerElement.dataset.themeType = themeType; - } - } - - // Update pre element theme mode - if (this.pre != null) { - switch (themeType) { - case 'system': - delete this.pre.dataset.themeType; - break; - case 'light': - case 'dark': - this.pre.dataset.themeType = themeType; - break; - } + if ( + typeof this.options.theme === 'string' || + this.fileContainer == null || + this.appliedThemeCSS == null + ) { + return; } + this.applyThemeState( + this.fileContainer, + this.appliedThemeCSS.themeStyles, + themeType, + this.appliedThemeCSS.baseThemeType + ); } public getHoveredLine = (): GetHoveredLineResult<'file'> | undefined => { @@ -252,13 +251,18 @@ export class File { this.headerElement = undefined; this.headerPrefix = undefined; this.headerMetadata = undefined; + this.headerCustom = undefined; this.lastRenderedHeaderHTML = undefined; this.errorWrapper = undefined; + this.themeCSSStyle = undefined; + this.appliedThemeCSS = undefined; this.unsafeCSSStyle = undefined; + this.appliedUnsafeCSS = undefined; this.placeHolder = undefined; } public hydrate(props: FileHydrateProps): void { + const { overflow = 'scroll' } = this.options; const { fileContainer, prerenderedHTML, preventEmit = false } = props; prerenderHTMLIfNecessary(fileContainer, prerenderedHTML); for (const element of Array.from( @@ -276,11 +280,19 @@ export class File { this.appliedPreAttributes = undefined; continue; } + if ( + element instanceof HTMLStyleElement && + element.hasAttribute(THEME_CSS_ATTRIBUTE) + ) { + this.themeCSSStyle = element; + continue; + } if ( element instanceof HTMLStyleElement && element.hasAttribute(UNSAFE_CSS_ATTRIBUTE) ) { this.unsafeCSSStyle = element; + this.appliedUnsafeCSS = element.textContent; continue; } if ('diffsHeader' in element.dataset) { @@ -296,7 +308,6 @@ export class File { // Otherwise orchestrate our setup else { const { file, lineAnnotations } = props; - const { overflow = 'scroll' } = this.options; this.fileContainer = fileContainer; delete this.pre.dataset.dehydrated; @@ -331,7 +342,7 @@ export class File { lineAnnotations, renderRange, }: FileRenderProps): boolean { - const { collapsed = false } = this.options; + const { collapsed = false, themeType = 'system' } = this.options; const nextRenderRange = collapsed ? undefined : renderRange; const previousRenderRange = this.renderRange; const annotationsChanged = @@ -352,7 +363,11 @@ export class File { this.renderRange = nextRenderRange; this.file = file; - this.fileRenderer.setOptions(this.options); + this.fileRenderer.setOptions({ + ...this.options, + headerRenderMode: + this.options.renderCustomHeader != null ? 'custom' : 'default', + }); if (lineAnnotations != null) { this.setLineAnnotations(lineAnnotations); } @@ -370,14 +385,7 @@ export class File { this.headerElement = undefined; this.lastRenderedHeaderHTML = undefined; } - if (this.headerPrefix != null) { - this.headerPrefix.remove(); - this.headerPrefix = undefined; - } - if (this.headerMetadata != null) { - this.headerMetadata.remove(); - this.headerMetadata = undefined; - } + this.clearHeaderSlots(); } fileContainer = this.getOrCreateFileContainerNode( @@ -394,6 +402,14 @@ export class File { file, EMPTY_RENDER_RANGE ); + if (fileResult != null) { + this.applyThemeState( + fileContainer, + fileResult.themeStyles, + themeType, + fileResult.baseThemeType + ); + } if (fileResult?.headerAST != null) { this.applyHeaderToDOM(fileResult.headerAST, fileContainer); } @@ -430,6 +446,12 @@ export class File { } return false; } + this.applyThemeState( + fileContainer, + fileResult.themeStyles, + themeType, + fileResult.baseThemeType + ); if (fileResult.headerAST != null) { this.applyHeaderToDOM(fileResult.headerAST, fileContainer); } @@ -533,8 +555,10 @@ export class File { this.gutterUtilityContent?.remove(); this.headerPrefix?.remove(); this.headerMetadata?.remove(); + this.headerCustom?.remove(); this.pre?.remove(); this.spriteSVG?.remove(); + this.themeCSSStyle?.remove(); this.unsafeCSSStyle?.remove(); this.bufferAfter = undefined; @@ -545,9 +569,13 @@ export class File { this.gutterUtilityContent = undefined; this.headerPrefix = undefined; this.headerMetadata = undefined; + this.headerCustom = undefined; this.pre = undefined; this.spriteSVG = undefined; + this.themeCSSStyle = undefined; + this.appliedThemeCSS = undefined; this.unsafeCSSStyle = undefined; + this.appliedUnsafeCSS = undefined; this.lastRenderedHeaderHTML = undefined; this.lastRowCount = undefined; @@ -620,26 +648,67 @@ export class File { } private injectUnsafeCSS(): void { - if (this.fileContainer?.shadowRoot == null) { + const { unsafeCSS } = this.options; + const shadowRoot = this.fileContainer?.shadowRoot; + if (shadowRoot == null) { return; } - const { unsafeCSS } = this.options; if (unsafeCSS == null || unsafeCSS === '') { if (this.unsafeCSSStyle != null) { this.unsafeCSSStyle.remove(); this.unsafeCSSStyle = undefined; } + this.appliedUnsafeCSS = undefined; + return; + } + + if ( + this.unsafeCSSStyle?.parentNode === shadowRoot && + this.appliedUnsafeCSS === unsafeCSS + ) { return; } // Create or update the style element - if (this.unsafeCSSStyle == null) { - this.unsafeCSSStyle = createUnsafeCSSStyleNode(); - this.fileContainer.shadowRoot.appendChild(this.unsafeCSSStyle); + this.unsafeCSSStyle ??= createUnsafeCSSStyleNode(); + if (this.unsafeCSSStyle.parentNode !== shadowRoot) { + shadowRoot.appendChild(this.unsafeCSSStyle); } // Wrap in @layer unsafe to match SSR behavior - this.unsafeCSSStyle.innerText = wrapUnsafeCSS(unsafeCSS); + this.unsafeCSSStyle.textContent = wrapUnsafeCSS(unsafeCSS); + this.appliedUnsafeCSS = unsafeCSS; + } + + private applyThemeState( + container: HTMLElement, + themeStyles: string, + themeType: ThemeTypes, + baseThemeType?: 'light' | 'dark' + ): void { + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const effectiveThemeType = baseThemeType ?? themeType; + if ( + this.themeCSSStyle?.parentNode === shadowRoot && + this.appliedThemeCSS?.themeStyles === themeStyles && + this.appliedThemeCSS.themeType === effectiveThemeType + ) { + return; + } + this.themeCSSStyle = upsertHostThemeStyle({ + shadowRoot, + currentNode: this.themeCSSStyle, + themeCSS: wrapThemeCSS(themeStyles, effectiveThemeType), + }); + this.appliedThemeCSS = + this.themeCSSStyle != null + ? { + themeStyles, + themeType: effectiveThemeType, + baseThemeType, + } + : undefined; } private applyFullRender(result: FileRenderResult, pre: HTMLPreElement): void { @@ -929,37 +998,87 @@ export class File { if (this.isContainerManaged) return; - const { renderHeaderPrefix, renderCustomMetadata } = this.options; - if (this.headerPrefix != null) { - this.headerPrefix.remove(); + const { renderHeaderPrefix, renderCustomHeader, renderCustomMetadata } = + this.options; + + if (renderCustomHeader != null) { + const content = renderCustomHeader(file) ?? undefined; + this.headerCustom = this.upsertHeaderSlotElement( + container, + this.headerCustom, + CUSTOM_HEADER_SLOT_ID, + content + ); + this.headerPrefix?.remove(); + this.headerMetadata?.remove(); + this.headerPrefix = undefined; + this.headerMetadata = undefined; + } else { + const prefix = renderHeaderPrefix?.(file) ?? undefined; + const content = renderCustomMetadata?.(file) ?? undefined; + this.headerPrefix = this.upsertHeaderSlotElement( + container, + this.headerPrefix, + HEADER_PREFIX_SLOT_ID, + prefix + ); + this.headerMetadata = this.upsertHeaderSlotElement( + container, + this.headerMetadata, + HEADER_METADATA_SLOT_ID, + content + ); + this.headerCustom?.remove(); + this.headerCustom = undefined; } - if (this.headerMetadata != null) { - this.headerMetadata.remove(); + } + + private clearHeaderSlots(): void { + this.headerPrefix?.remove(); + this.headerMetadata?.remove(); + this.headerCustom?.remove(); + this.headerPrefix = undefined; + this.headerMetadata = undefined; + this.headerCustom = undefined; + } + + // Header slot callbacks are presence-based render hooks, not reactive views. + private upsertHeaderSlotElement( + container: HTMLElement, + current: HTMLElement | undefined, + slot: string, + content: Element | string | number | undefined + ): HTMLElement | undefined { + if (content == null) { + current?.remove(); + return undefined; } - const prefix = renderHeaderPrefix?.(file) ?? undefined; - const content = renderCustomMetadata?.(file) ?? undefined; - if (prefix != null) { - this.headerPrefix = document.createElement('div'); - this.headerPrefix.slot = HEADER_PREFIX_SLOT_ID; - if (prefix instanceof Element) { - this.headerPrefix.appendChild(prefix); - } else { - this.headerPrefix.innerText = `${prefix}`; - } - container.appendChild(this.headerPrefix); + const element = current ?? this.createHeaderSlotElement(slot); + if (current == null) { + container.appendChild(element); } - if (content != null) { - this.headerMetadata = document.createElement('div'); - this.headerMetadata.slot = HEADER_METADATA_SLOT_ID; - if (content instanceof Element) { - this.headerMetadata.appendChild(content); - } else { - this.headerMetadata.innerText = `${content}`; - } - container.appendChild(this.headerMetadata); + this.replaceHeaderSlotContent(element, content); + return element; + } + + private replaceHeaderSlotContent( + element: HTMLElement, + content: Element | string | number + ): void { + element.replaceChildren(); + if (content instanceof Element) { + element.appendChild(content); + } else { + element.innerText = `${content}`; } } + private createHeaderSlotElement(slot: string): HTMLElement { + const element = document.createElement('div'); + element.slot = slot; + return element; + } + protected getOrCreateFileContainerNode( fileContainer?: HTMLElement, parentNode?: HTMLElement @@ -1013,20 +1132,14 @@ export class File { private applyPreNodeAttributes( pre: HTMLPreElement, - { totalLines, themeStyles, baseThemeType }: FileRenderResult + { totalLines }: FileRenderResult ): void { - const { - overflow = 'scroll', - themeType = 'system', - disableLineNumbers = false, - } = this.options; + const { overflow = 'scroll', disableLineNumbers = false } = this.options; const preProperties: PrePropertiesConfig = { type: 'file', split: false, - themeStyles, overflow, disableLineNumbers, - themeType: baseThemeType ?? themeType, diffIndicators: 'none', disableBackground: true, totalLines, diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 7a3d1415a..7c1171388 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -2,11 +2,13 @@ import type { ElementContent, Element as HASTElement } from 'hast'; import { toHtml } from 'hast-util-to-html'; import { + CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, DIFFS_TAG_NAME, EMPTY_RENDER_RANGE, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID, + THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; import { @@ -21,10 +23,12 @@ import { ResizeManager } from '../managers/ResizeManager'; import { ScrollSyncManager } from '../managers/ScrollSyncManager'; import { DiffHunksRenderer, + type DiffHunksRendererOptions, type HunksRenderResult, } from '../renderers/DiffHunksRenderer'; import { SVGSpriteSheet } from '../sprite'; import type { + AppliedThemeStyleCache, BaseDiffOptions, CustomPreProperties, DiffLineAnnotation, @@ -48,9 +52,10 @@ import { areRenderRangesEqual } from '../utils/areRenderRangesEqual'; import { createAnnotationWrapperNode } from '../utils/createAnnotationWrapperNode'; import { createGutterUtilityContentNode } from '../utils/createGutterUtilityContentNode'; import { createUnsafeCSSStyleNode } from '../utils/createUnsafeCSSStyleNode'; -import { wrapUnsafeCSS } from '../utils/cssWrappers'; +import { wrapThemeCSS, wrapUnsafeCSS } from '../utils/cssWrappers'; import { getLineAnnotationName } from '../utils/getLineAnnotationName'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; +import { upsertHostThemeStyle } from '../utils/hostTheme'; import { parseDiffFromFile } from '../utils/parseDiffFromFile'; import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; @@ -97,6 +102,7 @@ export interface FileDiffOptions enableHoverUtility?: boolean; renderHeaderPrefix?: RenderHeaderPrefixCallback; renderHeaderMetadata?: RenderHeaderMetadataCallback; + renderCustomHeader?: RenderHeaderMetadataCallback; /** * When true, errors during rendering are rethrown instead of being caught * and displayed in the DOM. Useful for testing or when you want to handle @@ -168,12 +174,16 @@ export class FileDiff { protected codeAdditions: HTMLElement | undefined; protected bufferBefore: HTMLElement | undefined; protected bufferAfter: HTMLElement | undefined; + protected themeCSSStyle: HTMLStyleElement | undefined; + protected appliedThemeCSS: AppliedThemeStyleCache | undefined; protected unsafeCSSStyle: HTMLStyleElement | undefined; + protected appliedUnsafeCSS: string | undefined; protected gutterUtilityContent: HTMLElement | undefined; protected headerElement: HTMLElement | undefined; protected headerPrefix: HTMLElement | undefined; protected headerMetadata: HTMLElement | undefined; + protected headerCustom: HTMLElement | undefined; protected separatorCache: Map = new Map(); protected errorWrapper: HTMLElement | undefined; protected placeHolder: HTMLElement | undefined; @@ -227,9 +237,11 @@ export class FileDiff { protected getHunksRendererOptions( options: FileDiffOptions - ): BaseDiffOptions { + ): DiffHunksRendererOptions { return { ...options, + headerRenderMode: + options.renderCustomHeader != null ? 'custom' : 'default', hunkSeparators: typeof options.hunkSeparators === 'function' ? 'custom' @@ -359,28 +371,19 @@ export class FileDiff { return; } this.mergeOptions({ themeType }); - this.hunksRenderer.setThemeType(themeType); - - if (this.headerElement != null) { - if (themeType === 'system') { - delete this.headerElement.dataset.themeType; - } else { - this.headerElement.dataset.themeType = themeType; - } - } - - // Update pre element theme mode - if (this.pre != null) { - switch (themeType) { - case 'system': - delete this.pre.dataset.themeType; - break; - case 'light': - case 'dark': - this.pre.dataset.themeType = themeType; - break; - } + if ( + typeof this.options.theme === 'string' || + this.fileContainer == null || + this.appliedThemeCSS == null + ) { + return; } + this.applyThemeState( + this.fileContainer, + this.appliedThemeCSS.themeStyles, + themeType, + this.appliedThemeCSS.baseThemeType + ); } public getHoveredLine = (): GetHoveredLineResult<'diff'> | undefined => { @@ -443,10 +446,15 @@ export class FileDiff { this.headerElement = undefined; this.headerPrefix = undefined; this.headerMetadata = undefined; + this.headerCustom = undefined; this.lastRenderedHeaderHTML = undefined; this.errorWrapper = undefined; this.spriteSVG = undefined; this.lastRowCount = undefined; + this.themeCSSStyle = undefined; + this.appliedThemeCSS = undefined; + this.unsafeCSSStyle = undefined; + this.appliedUnsafeCSS = undefined; if (recycle) { this.hunksRenderer.recycle(); @@ -468,7 +476,7 @@ export class FileDiff { } public hydrate(props: FileDiffHydrationProps): void { - const { overflow = 'scroll', diffStyle = 'split' } = this.options; + const { diffStyle = 'split', overflow = 'scroll' } = this.options; const { fileContainer, prerenderedHTML, preventEmit = false } = props; prerenderHTMLIfNecessary(fileContainer, prerenderedHTML); for (const element of fileContainer.shadowRoot?.children ?? []) { @@ -504,11 +512,19 @@ export class FileDiff { this.headerElement = element; continue; } + if ( + element instanceof HTMLStyleElement && + element.hasAttribute(THEME_CSS_ATTRIBUTE) + ) { + this.themeCSSStyle = element; + continue; + } if ( element instanceof HTMLStyleElement && element.hasAttribute(UNSAFE_CSS_ATTRIBUTE) ) { this.unsafeCSSStyle = element; + this.appliedUnsafeCSS = element.textContent; continue; } } @@ -658,13 +674,7 @@ export class FileDiff { if (this.fileDiff == null) { return false; } - this.hunksRenderer.setOptions({ - ...this.options, - hunkSeparators: - typeof this.options.hunkSeparators === 'function' - ? 'custom' - : this.options.hunkSeparators, - }); + this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); this.hunksRenderer.setLineAnnotations(this.lineAnnotations); @@ -673,6 +683,7 @@ export class FileDiff { disableErrorHandling = false, disableFileHeader = false, overflow = 'scroll', + themeType = 'system', } = this.options; if (disableFileHeader) { @@ -682,14 +693,7 @@ export class FileDiff { this.headerElement = undefined; this.lastRenderedHeaderHTML = undefined; } - if (this.headerPrefix != null) { - this.headerPrefix.remove(); - this.headerPrefix = undefined; - } - if (this.headerMetadata != null) { - this.headerMetadata.remove(); - this.headerMetadata = undefined; - } + this.clearHeaderSlots(); } fileContainer = this.getOrCreateFileContainer( fileContainer, @@ -705,6 +709,14 @@ export class FileDiff { this.fileDiff, EMPTY_RENDER_RANGE ); + if (hunksResult != null) { + this.applyThemeState( + fileContainer, + hunksResult.themeStyles, + themeType, + hunksResult.baseThemeType + ); + } if (hunksResult?.headerElement != null) { this.applyHeaderToDOM(hunksResult.headerElement, fileContainer); } @@ -755,6 +767,13 @@ export class FileDiff { return false; } + this.applyThemeState( + fileContainer, + hunksResult.themeStyles, + themeType, + hunksResult.baseThemeType + ); + if (hunksResult.headerElement != null) { this.applyHeaderToDOM(hunksResult.headerElement, fileContainer); } @@ -880,8 +899,10 @@ export class FileDiff { this.gutterUtilityContent?.remove(); this.headerPrefix?.remove(); this.headerMetadata?.remove(); + this.headerCustom?.remove(); this.pre?.remove(); this.spriteSVG?.remove(); + this.themeCSSStyle?.remove(); this.unsafeCSSStyle?.remove(); this.bufferAfter = undefined; @@ -894,9 +915,13 @@ export class FileDiff { this.gutterUtilityContent = undefined; this.headerPrefix = undefined; this.headerMetadata = undefined; + this.headerCustom = undefined; this.pre = undefined; this.spriteSVG = undefined; + this.themeCSSStyle = undefined; + this.appliedThemeCSS = undefined; this.unsafeCSSStyle = undefined; + this.appliedUnsafeCSS = undefined; this.lastRenderedHeaderHTML = undefined; this.lastRowCount = undefined; @@ -1090,6 +1115,7 @@ export class FileDiff { this.cleanupErrorWrapper(); this.placeHolder?.remove(); this.placeHolder = undefined; + const { fileDiff } = this; const headerHTML = toHtml(headerAST); if (headerHTML !== this.lastRenderedHeaderHTML) { const tempDiv = document.createElement('div'); @@ -1107,66 +1133,154 @@ export class FileDiff { this.lastRenderedHeaderHTML = headerHTML; } - if (this.isContainerManaged) return; - - const { renderHeaderPrefix, renderHeaderMetadata } = this.options; - if (this.headerPrefix != null) { - this.headerPrefix.remove(); - } - if (this.headerMetadata != null) { - this.headerMetadata.remove(); - } - const prefix = - renderHeaderPrefix?.({ - deletionFile: this.deletionFile, - additionFile: this.additionFile, - fileDiff: this.fileDiff, - }) ?? undefined; - const content = - renderHeaderMetadata?.({ - deletionFile: this.deletionFile, - additionFile: this.additionFile, - fileDiff: this.fileDiff, - }) ?? undefined; - if (prefix != null) { - this.headerPrefix = document.createElement('div'); - this.headerPrefix.slot = HEADER_PREFIX_SLOT_ID; - if (prefix instanceof Element) { - this.headerPrefix.appendChild(prefix); - } else { - this.headerPrefix.innerText = `${prefix}`; - } - container.appendChild(this.headerPrefix); + if (this.isContainerManaged || fileDiff == null) { + return; } - if (content != null) { - this.headerMetadata = document.createElement('div'); - this.headerMetadata.slot = HEADER_METADATA_SLOT_ID; - if (content instanceof Element) { - this.headerMetadata.appendChild(content); - } else { - this.headerMetadata.innerText = `${content}`; - } - container.appendChild(this.headerMetadata); + + const { renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata } = + this.options; + + if (renderCustomHeader != null) { + const content = renderCustomHeader(fileDiff) ?? undefined; + this.headerCustom = this.upsertHeaderSlotElement( + container, + this.headerCustom, + CUSTOM_HEADER_SLOT_ID, + content + ); + this.headerPrefix?.remove(); + this.headerMetadata?.remove(); + this.headerPrefix = undefined; + this.headerMetadata = undefined; + return; } + + const prefix = renderHeaderPrefix?.(fileDiff) ?? undefined; + const content = renderHeaderMetadata?.(fileDiff) ?? undefined; + this.headerPrefix = this.upsertHeaderSlotElement( + container, + this.headerPrefix, + HEADER_PREFIX_SLOT_ID, + prefix + ); + this.headerMetadata = this.upsertHeaderSlotElement( + container, + this.headerMetadata, + HEADER_METADATA_SLOT_ID, + content + ); + this.headerCustom?.remove(); + this.headerCustom = undefined; + } + + private clearHeaderSlots(): void { + this.headerPrefix?.remove(); + this.headerMetadata?.remove(); + this.headerCustom?.remove(); + this.headerPrefix = undefined; + this.headerMetadata = undefined; + this.headerCustom = undefined; + } + + // Header slot callbacks are presence-based render hooks, not reactive views. + private upsertHeaderSlotElement( + container: HTMLElement, + current: HTMLElement | undefined, + slot: string, + content: Element | string | number | undefined + ): HTMLElement | undefined { + if (content == null) { + current?.remove(); + return undefined; + } + const element = current ?? this.createHeaderSlotElement(slot); + if (current == null) { + container.appendChild(element); + } + this.replaceHeaderSlotContent(element, content); + return element; + } + + private replaceHeaderSlotContent( + element: HTMLElement, + content: Element | string | number + ): void { + element.replaceChildren(); + if (content instanceof Element) { + element.appendChild(content); + } else { + element.innerText = `${content}`; + } + } + + private createHeaderSlotElement(slot: string): HTMLElement { + const element = document.createElement('div'); + element.slot = slot; + return element; } protected injectUnsafeCSS(): void { - if (this.fileContainer?.shadowRoot == null) { + const { unsafeCSS } = this.options; + const shadowRoot = this.fileContainer?.shadowRoot; + if (shadowRoot == null) { return; } - const { unsafeCSS } = this.options; if (unsafeCSS == null || unsafeCSS === '') { + if (this.unsafeCSSStyle != null) { + this.unsafeCSSStyle.remove(); + this.unsafeCSSStyle = undefined; + } + this.appliedUnsafeCSS = undefined; + return; + } + + if ( + this.unsafeCSSStyle?.parentNode === shadowRoot && + this.appliedUnsafeCSS === unsafeCSS + ) { return; } // Create or update the style element - if (this.unsafeCSSStyle == null) { - this.unsafeCSSStyle = createUnsafeCSSStyleNode(); - this.fileContainer.shadowRoot.appendChild(this.unsafeCSSStyle); + this.unsafeCSSStyle ??= createUnsafeCSSStyleNode(); + if (this.unsafeCSSStyle.parentNode !== shadowRoot) { + shadowRoot.appendChild(this.unsafeCSSStyle); } // Wrap in @layer unsafe to match SSR behavior - this.unsafeCSSStyle.innerText = wrapUnsafeCSS(unsafeCSS); + this.unsafeCSSStyle.textContent = wrapUnsafeCSS(unsafeCSS); + this.appliedUnsafeCSS = unsafeCSS; + } + + private applyThemeState( + container: HTMLElement, + themeStyles: string, + themeType: ThemeTypes, + baseThemeType?: 'light' | 'dark' + ): void { + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const effectiveThemeType = baseThemeType ?? themeType; + if ( + this.themeCSSStyle?.parentNode === shadowRoot && + this.appliedThemeCSS?.themeStyles === themeStyles && + this.appliedThemeCSS.themeType === effectiveThemeType + ) { + return; + } + this.themeCSSStyle = upsertHostThemeStyle({ + shadowRoot, + currentNode: this.themeCSSStyle, + themeCSS: wrapThemeCSS(themeStyles, effectiveThemeType), + }); + this.appliedThemeCSS = + this.themeCSSStyle != null + ? { + themeStyles, + themeType: effectiveThemeType, + baseThemeType, + } + : undefined; } private applyHunksToDOM( @@ -1881,13 +1995,7 @@ export class FileDiff { protected applyPreNodeAttributes( pre: HTMLPreElement, - { - themeStyles, - baseThemeType, - additionsContentAST, - deletionsContentAST, - totalLines, - }: HunksRenderResult, + { additionsContentAST, deletionsContentAST, totalLines }: HunksRenderResult, customProperties?: CustomPreProperties ): void { const { @@ -1895,7 +2003,6 @@ export class FileDiff { disableBackground = false, disableLineNumbers = false, overflow = 'scroll', - themeType = 'system', diffStyle = 'split', } = this.options; const preProperties: PrePropertiesConfig = { @@ -1908,8 +2015,6 @@ export class FileDiff { diffStyle === 'unified' ? false : additionsContentAST != null && deletionsContentAST != null, - themeStyles, - themeType: baseThemeType ?? themeType, totalLines, customProperties, }; diff --git a/packages/diffs/src/components/FileStream.ts b/packages/diffs/src/components/FileStream.ts index 769b0ae2d..34b9bcd29 100644 --- a/packages/diffs/src/components/FileStream.ts +++ b/packages/diffs/src/components/FileStream.ts @@ -3,6 +3,7 @@ import { getSharedHighlighter } from '../highlighter/shared_highlighter'; import { queueRender } from '../managers/UniversalRenderingManager'; import { CodeToTokenTransformStream, type RecallToken } from '../shiki-stream'; import type { + AppliedThemeStyleCache, BaseCodeOptions, DiffsHighlighter, SupportedLanguages, @@ -10,10 +11,12 @@ import type { ThemeTypes, } from '../types'; import { createSpanFromToken } from '../utils/createSpanNodeFromToken'; +import { wrapThemeCSS } from '../utils/cssWrappers'; import { formatCSSVariablePrefix } from '../utils/formatCSSVariablePrefix'; import { getHighlighterOptions } from '../utils/getHighlighterOptions'; import { getHighlighterThemeStyles } from '../utils/getHighlighterThemeStyles'; import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode'; +import { upsertHostThemeStyle } from '../utils/hostTheme'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; export interface FileStreamOptions extends BaseCodeOptions { @@ -38,10 +41,12 @@ export class FileStream { private stream: ReadableStream | undefined; private abortController: AbortController | undefined; private fileContainer: HTMLElement | undefined; - pre: HTMLPreElement | undefined; + private pre: HTMLPreElement | undefined; private code: HTMLElement | undefined; private gutterElement: HTMLElement | undefined; private contentElement: HTMLElement | undefined; + private themeCSSStyle: HTMLStyleElement | undefined; + private appliedThemeCSS: AppliedThemeStyleCache | undefined; private currentRowCount = 0; constructor(public options: FileStreamOptions = { theme: DEFAULT_THEMES }) { @@ -58,19 +63,19 @@ export class FileStream { return; } this.options = { ...this.options, themeType }; - - // Update pre element theme mode - if (this.pre != null) { - switch (themeType) { - case 'system': - delete this.pre.dataset.themeType; - break; - case 'light': - case 'dark': - this.pre.dataset.themeType = themeType; - break; - } + if ( + typeof this.options.theme === 'string' || + this.fileContainer == null || + this.appliedThemeCSS == null + ) { + return; } + this.applyThemeState( + this.fileContainer, + this.appliedThemeCSS.themeStyles, + themeType, + this.appliedThemeCSS.baseThemeType + ); } private async initializeHighlighter(): Promise { @@ -121,9 +126,10 @@ export class FileStream { if (this.pre.parentElement == null) { fileContainer.shadowRoot?.appendChild(this.pre); } - const themeStyles = getHighlighterThemeStyles({ theme, highlighter }); const baseThemeType = typeof theme === 'string' ? highlighter.getTheme(theme).type : undefined; + const themeStyles = getHighlighterThemeStyles({ theme, highlighter }); + this.applyThemeState(fileContainer, themeStyles, themeType, baseThemeType); const pre = setPreNodeProperties(this.pre, { type: 'file', diffIndicators: 'none', @@ -131,8 +137,6 @@ export class FileStream { disableLineNumbers, overflow, split: false, - themeType: baseThemeType ?? themeType, - themeStyles, totalLines: 0, }); pre.innerHTML = ''; @@ -313,8 +317,47 @@ export class FileStream { ) { return this.fileContainer; } + if ( + this.fileContainer != null && + fileContainer != null && + fileContainer !== this.fileContainer + ) { + this.themeCSSStyle = undefined; + this.appliedThemeCSS = undefined; + } this.fileContainer = fileContainer ?? document.createElement(DIFFS_TAG_NAME); return this.fileContainer; } + + private applyThemeState( + container: HTMLElement, + themeStyles: string, + themeType: ThemeTypes, + baseThemeType?: 'light' | 'dark' + ): void { + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + const effectiveThemeType = baseThemeType ?? themeType; + if ( + this.themeCSSStyle?.parentNode === shadowRoot && + this.appliedThemeCSS?.themeStyles === themeStyles && + this.appliedThemeCSS.themeType === effectiveThemeType + ) { + return; + } + this.themeCSSStyle = upsertHostThemeStyle({ + shadowRoot, + currentNode: this.themeCSSStyle, + themeCSS: wrapThemeCSS(themeStyles, effectiveThemeType), + }); + this.appliedThemeCSS = + this.themeCSSStyle != null + ? { + themeStyles, + themeType: effectiveThemeType, + baseThemeType, + } + : undefined; + } } diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index 79bff0cd3..7941f0b28 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -510,7 +510,7 @@ export class UnresolvedFile< ): void { const action = this.conflictActions[conflictIndex]; if (action == null) { - return undefined; + return; } if (action.conflictIndex !== conflictIndex) { console.error({ conflictIndex, action }); @@ -530,7 +530,7 @@ export class UnresolvedFile< actions == null || markerRows == null ) { - return undefined; + return; } this.computedCache = { file, fileDiff, actions, markerRows }; diff --git a/packages/diffs/src/constants.ts b/packages/diffs/src/constants.ts index 955c6dd7f..60b9b47d8 100644 --- a/packages/diffs/src/constants.ts +++ b/packages/diffs/src/constants.ts @@ -30,12 +30,14 @@ export const MERGE_CONFLICT_END_MARKER_REGEX: RegExp = /^>{7,}(?:\s.*)?$/; export const HEADER_PREFIX_SLOT_ID = 'header-prefix'; export const HEADER_METADATA_SLOT_ID = 'header-metadata'; +export const CUSTOM_HEADER_SLOT_ID = 'header-custom'; export const DEFAULT_THEMES: ThemesType = { dark: 'pierre-dark', light: 'pierre-light', }; +export const THEME_CSS_ATTRIBUTE = 'data-theme-css'; export const UNSAFE_CSS_ATTRIBUTE = 'data-unsafe-css'; export const CORE_CSS_ATTRIBUTE = 'data-core-css'; diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx index ee93d937f..5fd631497 100644 --- a/packages/diffs/src/react/File.tsx +++ b/packages/diffs/src/react/File.tsx @@ -18,6 +18,7 @@ export function File({ className, style, renderAnnotation, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, prerenderedHTML, @@ -33,10 +34,12 @@ export function File({ prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null || renderHoverUtility != null, + hasCustomHeader: renderCustomHeader != null, }); const children = renderFileChildren({ file, renderAnnotation, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderGutterUtility, diff --git a/packages/diffs/src/react/FileDiff.tsx b/packages/diffs/src/react/FileDiff.tsx index 2b936c86b..d9e11923e 100644 --- a/packages/diffs/src/react/FileDiff.tsx +++ b/packages/diffs/src/react/FileDiff.tsx @@ -25,6 +25,7 @@ export function FileDiff({ style, prerenderedHTML, renderAnnotation, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderGutterUtility, @@ -39,9 +40,11 @@ export function FileDiff({ prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null || renderHoverUtility != null, + hasCustomHeader: renderCustomHeader != null, }); const children = renderDiffChildren({ fileDiff, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderAnnotation, diff --git a/packages/diffs/src/react/MultiFileDiff.tsx b/packages/diffs/src/react/MultiFileDiff.tsx index 569c93c26..ac6569c7d 100644 --- a/packages/diffs/src/react/MultiFileDiff.tsx +++ b/packages/diffs/src/react/MultiFileDiff.tsx @@ -1,7 +1,10 @@ 'use client'; +import { useMemo } from 'react'; + import { DIFFS_TAG_NAME } from '../constants'; import type { FileContents } from '../types'; +import { parseDiffFromFile } from '../utils/parseDiffFromFile'; import type { DiffBasePropsReact } from './types'; import { renderDiffChildren } from './utils/renderDiffChildren'; import { templateRender } from './utils/templateRender'; @@ -27,14 +30,17 @@ export function MultiFileDiff({ style, prerenderedHTML, renderAnnotation, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderGutterUtility, renderHoverUtility, }: MultiFileDiffProps): React.JSX.Element { + const fileDiff = useMemo(() => { + return parseDiffFromFile(oldFile, newFile); + }, [oldFile, newFile]); const { ref, getHoveredLine } = useFileDiffInstance({ - oldFile, - newFile, + fileDiff, options, metrics, lineAnnotations, @@ -42,10 +48,11 @@ export function MultiFileDiff({ prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null || renderHoverUtility != null, + hasCustomHeader: renderCustomHeader != null, }); const children = renderDiffChildren({ - deletionFile: oldFile, - additionFile: newFile, + fileDiff, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderAnnotation, diff --git a/packages/diffs/src/react/PatchDiff.tsx b/packages/diffs/src/react/PatchDiff.tsx index b5e929866..3e9ea196e 100644 --- a/packages/diffs/src/react/PatchDiff.tsx +++ b/packages/diffs/src/react/PatchDiff.tsx @@ -26,6 +26,7 @@ export function PatchDiff({ style, prerenderedHTML, renderAnnotation, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderGutterUtility, @@ -41,9 +42,11 @@ export function PatchDiff({ prerenderedHTML, hasGutterRenderUtility: renderGutterUtility != null || renderHoverUtility != null, + hasCustomHeader: renderCustomHeader != null, }); const children = renderDiffChildren({ fileDiff, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderAnnotation, diff --git a/packages/diffs/src/react/UnresolvedFile.tsx b/packages/diffs/src/react/UnresolvedFile.tsx index 5fd835cb8..b830e3982 100644 --- a/packages/diffs/src/react/UnresolvedFile.tsx +++ b/packages/diffs/src/react/UnresolvedFile.tsx @@ -67,6 +67,7 @@ export function UnresolvedFile({ style, prerenderedHTML, renderAnnotation, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderGutterUtility, @@ -83,9 +84,11 @@ export function UnresolvedFile({ hasConflictUtility: renderMergeConflictUtility != null, hasGutterRenderUtility: renderGutterUtility != null || renderHoverUtility != null, + hasCustomHeader: renderCustomHeader != null, }); const children = renderDiffChildren({ fileDiff, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderAnnotation, diff --git a/packages/diffs/src/react/types.ts b/packages/diffs/src/react/types.ts index f00452038..072ed7135 100644 --- a/packages/diffs/src/react/types.ts +++ b/packages/diffs/src/react/types.ts @@ -9,8 +9,8 @@ import type { import type { DiffLineAnnotation, FileContents, + FileDiffMetadata, LineAnnotation, - RenderHeaderMetadataProps, VirtualFileMetrics, } from '../types'; @@ -20,8 +20,9 @@ export interface DiffBasePropsReact { lineAnnotations?: DiffLineAnnotation[]; selectedLines?: SelectedLineRange | null; renderAnnotation?(annotations: DiffLineAnnotation): ReactNode; - renderHeaderPrefix?(props: RenderHeaderMetadataProps): ReactNode; - renderHeaderMetadata?(props: RenderHeaderMetadataProps): ReactNode; + renderCustomHeader?(fileDiff: FileDiffMetadata): ReactNode; + renderHeaderPrefix?(fileDiff: FileDiffMetadata): ReactNode; + renderHeaderMetadata?(fileDiff: FileDiffMetadata): ReactNode; renderGutterUtility?( getHoveredLine: () => GetHoveredLineResult<'diff'> | undefined ): ReactNode; @@ -43,6 +44,7 @@ export interface FileProps { lineAnnotations?: LineAnnotation[]; selectedLines?: SelectedLineRange | null; renderAnnotation?(annotations: LineAnnotation): ReactNode; + renderCustomHeader?(file: FileContents): ReactNode; renderHeaderPrefix?(file: FileContents): ReactNode; renderHeaderMetadata?(file: FileContents): ReactNode; renderGutterUtility?( diff --git a/packages/diffs/src/react/utils/renderDiffChildren.tsx b/packages/diffs/src/react/utils/renderDiffChildren.tsx index 80ebc4465..edab811c0 100644 --- a/packages/diffs/src/react/utils/renderDiffChildren.tsx +++ b/packages/diffs/src/react/utils/renderDiffChildren.tsx @@ -1,11 +1,12 @@ import type { ReactNode } from 'react'; import { + CUSTOM_HEADER_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID, } from '../../constants'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; -import type { FileContents, FileDiffMetadata } from '../../types'; +import type { FileDiffMetadata } from '../../types'; import { getLineAnnotationName } from '../../utils/getLineAnnotationName'; import { getMergeConflictActionSlotName } from '../../utils/getMergeConflictActionSlotName'; import { @@ -16,10 +17,9 @@ import { GutterUtilitySlotStyles, MergeConflictSlotStyles } from '../constants'; import type { DiffBasePropsReact } from '../types'; interface RenderDiffChildrenProps { - fileDiff?: FileDiffMetadata; + fileDiff: FileDiffMetadata; actions?: (MergeConflictDiffAction | undefined)[]; - deletionFile?: FileContents; - additionFile?: FileContents; + renderCustomHeader: DiffBasePropsReact['renderCustomHeader']; renderHeaderPrefix: DiffBasePropsReact['renderHeaderPrefix']; renderHeaderMetadata: DiffBasePropsReact['renderHeaderMetadata']; renderAnnotation: DiffBasePropsReact['renderAnnotation']; @@ -37,8 +37,7 @@ interface RenderDiffChildrenProps { export function renderDiffChildren({ fileDiff, actions, - deletionFile, - additionFile, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderAnnotation, @@ -50,20 +49,21 @@ export function renderDiffChildren({ getInstance, }: RenderDiffChildrenProps): ReactNode { const gutterUtility = renderGutterUtility ?? renderHoverUtility; - const prefix = renderHeaderPrefix?.({ - fileDiff, - deletionFile, - additionFile, - }); - const metadata = renderHeaderMetadata?.({ - fileDiff, - deletionFile, - additionFile, - }); + const customHeader = renderCustomHeader?.(fileDiff); + const prefix = renderHeaderPrefix?.(fileDiff); + const metadata = renderHeaderMetadata?.(fileDiff); return ( <> - {prefix != null &&
{prefix}
} - {metadata != null &&
{metadata}
} + {customHeader != null ? ( +
{customHeader}
+ ) : ( + <> + {prefix != null &&
{prefix}
} + {metadata != null && ( +
{metadata}
+ )} + + )} {renderAnnotation != null && lineAnnotations?.map((annotation, index) => (
@@ -74,7 +74,7 @@ export function renderDiffChildren({ renderMergeConflictUtility != null && getInstance != null && actions.map((action) => { - if (action == null || fileDiff == null) { + if (action == null) { return undefined; } const slot = getSlotName(action, fileDiff); diff --git a/packages/diffs/src/react/utils/renderFileChildren.tsx b/packages/diffs/src/react/utils/renderFileChildren.tsx index c28fb56ef..388d19f3d 100644 --- a/packages/diffs/src/react/utils/renderFileChildren.tsx +++ b/packages/diffs/src/react/utils/renderFileChildren.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { + CUSTOM_HEADER_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID, } from '../../constants'; @@ -12,6 +13,7 @@ import type { FileProps } from '../types'; interface RenderFileChildrenProps { file: FileContents; + renderCustomHeader: FileProps['renderCustomHeader']; renderHeaderPrefix: FileProps['renderHeaderPrefix']; renderHeaderMetadata: FileProps['renderHeaderMetadata']; renderAnnotation: FileProps['renderAnnotation']; @@ -23,6 +25,7 @@ interface RenderFileChildrenProps { export function renderFileChildren({ file, + renderCustomHeader, renderHeaderPrefix, renderHeaderMetadata, renderAnnotation, @@ -32,12 +35,21 @@ export function renderFileChildren({ getHoveredLine, }: RenderFileChildrenProps): ReactNode { const gutterUtility = renderGutterUtility ?? renderHoverUtility; + const customHeader = renderCustomHeader?.(file); const prefix = renderHeaderPrefix?.(file); const metadata = renderHeaderMetadata?.(file); return ( <> - {prefix != null &&
{prefix}
} - {metadata != null &&
{metadata}
} + {customHeader != null ? ( +
{customHeader}
+ ) : ( + <> + {prefix != null &&
{prefix}
} + {metadata != null && ( +
{metadata}
+ )} + + )} {renderAnnotation != null && lineAnnotations?.map((annotation, index) => (
diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts index 43538d655..fe4d2cf67 100644 --- a/packages/diffs/src/react/utils/useFileDiffInstance.ts +++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts @@ -14,12 +14,11 @@ import type { } from '../../managers/InteractionManager'; import type { DiffLineAnnotation, - FileContents, FileDiffMetadata, VirtualFileMetrics, } from '../../types'; import { areOptionsEqual } from '../../utils/areOptionsEqual'; -import { noopRender as renderGutterUtility } from '../constants'; +import { noopRender } from '../constants'; import { useVirtualizer } from '../Virtualizer'; import { WorkerPoolContext } from '../WorkerPoolContext'; import { useStableCallback } from './useStableCallback'; @@ -28,15 +27,14 @@ const useIsometricEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; interface UseFileDiffInstanceProps { - oldFile?: FileContents; - newFile?: FileContents; - fileDiff?: FileDiffMetadata; + fileDiff: FileDiffMetadata; options: FileDiffOptions | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; metrics?: VirtualFileMetrics; hasGutterRenderUtility: boolean; + hasCustomHeader: boolean; } interface UseFileDiffInstanceReturn { @@ -45,8 +43,6 @@ interface UseFileDiffInstanceReturn { } export function useFileDiffInstance({ - oldFile, - newFile, fileDiff, options, lineAnnotations, @@ -54,6 +50,7 @@ export function useFileDiffInstance({ prerenderedHTML, metrics, hasGutterRenderUtility, + hasCustomHeader, }: UseFileDiffInstanceProps): UseFileDiffInstanceReturn { const simpleVirtualizer = useVirtualizer(); const poolManager = useContext(WorkerPoolContext); @@ -69,7 +66,11 @@ export function useFileDiffInstance({ } if (simpleVirtualizer != null) { instanceRef.current = new VirtualizedFileDiff( - mergeFileDiffOptions(options, hasGutterRenderUtility), + mergeFileDiffOptions({ + hasCustomHeader, + hasGutterRenderUtility, + options, + }), simpleVirtualizer, metrics, poolManager, @@ -77,15 +78,17 @@ export function useFileDiffInstance({ ); } else { instanceRef.current = new FileDiff( - mergeFileDiffOptions(options, hasGutterRenderUtility), + mergeFileDiffOptions({ + hasCustomHeader, + hasGutterRenderUtility, + options, + }), poolManager, true ); } void instanceRef.current.hydrate({ fileDiff, - oldFile, - newFile, fileContainer, lineAnnotations, prerenderedHTML, @@ -104,14 +107,16 @@ export function useFileDiffInstance({ useIsometricEffect(() => { const { current: instance } = instanceRef; if (instance == null) return; - const newOptions = mergeFileDiffOptions(options, hasGutterRenderUtility); + const newOptions = mergeFileDiffOptions({ + hasCustomHeader, + hasGutterRenderUtility, + options, + }); const forceRender = !areOptionsEqual(instance.options, newOptions); instance.setOptions(newOptions); void instance.render({ forceRender, fileDiff, - oldFile, - newFile, lineAnnotations, }); if (selectedLines !== undefined) { @@ -128,12 +133,25 @@ export function useFileDiffInstance({ return { ref, getHoveredLine }; } -function mergeFileDiffOptions( - options: FileDiffOptions | undefined, - hasGutterRenderUtility: boolean -): FileDiffOptions | undefined { - if (hasGutterRenderUtility) { - return { ...options, renderGutterUtility }; +interface MergeFileDiffOptionsProps { + hasCustomHeader: boolean; + hasGutterRenderUtility: boolean; + options: FileDiffOptions | undefined; +} + +function mergeFileDiffOptions({ + options, + hasCustomHeader, + hasGutterRenderUtility, +}: MergeFileDiffOptionsProps): + | FileDiffOptions + | undefined { + if (hasGutterRenderUtility || hasCustomHeader) { + return { + ...options, + renderCustomHeader: hasCustomHeader ? noopRender : undefined, + renderGutterUtility: hasGutterRenderUtility ? noopRender : undefined, + }; } return options; } diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 808723bc6..a0c280e3d 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -18,7 +18,7 @@ import type { VirtualFileMetrics, } from '../../types'; import { areOptionsEqual } from '../../utils/areOptionsEqual'; -import { noopRender as renderGutterUtility } from '../constants'; +import { noopRender } from '../constants'; import { useVirtualizer } from '../Virtualizer'; import { WorkerPoolContext } from '../WorkerPoolContext'; import { useStableCallback } from './useStableCallback'; @@ -34,6 +34,7 @@ interface UseFileInstanceProps { prerenderedHTML: string | undefined; metrics?: VirtualFileMetrics; hasGutterRenderUtility: boolean; + hasCustomHeader: boolean; } interface UseFileInstanceReturn { @@ -49,6 +50,7 @@ export function useFileInstance({ prerenderedHTML, metrics, hasGutterRenderUtility, + hasCustomHeader, }: UseFileInstanceProps): UseFileInstanceReturn { const simpleVirtualizer = useVirtualizer(); const poolManager = useContext(WorkerPoolContext); @@ -64,7 +66,11 @@ export function useFileInstance({ } if (simpleVirtualizer != null) { instanceRef.current = new VirtualizedFile( - mergeFileOptions(options, hasGutterRenderUtility), + mergeFileOptions({ + hasCustomHeader, + hasGutterRenderUtility, + options, + }), simpleVirtualizer, metrics, poolManager, @@ -72,7 +78,11 @@ export function useFileInstance({ ); } else { instanceRef.current = new File( - mergeFileOptions(options, hasGutterRenderUtility), + mergeFileOptions({ + hasCustomHeader, + hasGutterRenderUtility, + options, + }), poolManager, true ); @@ -94,7 +104,11 @@ export function useFileInstance({ useIsometricEffect(() => { if (instanceRef.current == null) return; - const newOptions = mergeFileOptions(options, hasGutterRenderUtility); + const newOptions = mergeFileOptions({ + hasCustomHeader, + hasGutterRenderUtility, + options, + }); const forceRender = !areOptionsEqual( instanceRef.current.options, newOptions @@ -114,12 +128,23 @@ export function useFileInstance({ return { ref, getHoveredLine }; } -function mergeFileOptions( - options: FileOptions | undefined, - hasGutterRenderUtility: boolean -): FileOptions | undefined { - if (hasGutterRenderUtility) { - return { ...options, renderGutterUtility }; +interface MergeFileOptionsProps { + options: FileOptions | undefined; + hasGutterRenderUtility: boolean; + hasCustomHeader: boolean; +} + +function mergeFileOptions({ + options, + hasCustomHeader, + hasGutterRenderUtility, +}: MergeFileOptionsProps): FileOptions | undefined { + if (hasGutterRenderUtility || hasCustomHeader) { + return { + ...options, + renderCustomHeader: hasCustomHeader ? noopRender : undefined, + renderGutterUtility: hasGutterRenderUtility ? noopRender : undefined, + }; } return options; } diff --git a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts index 1a4386c4c..f06898133 100644 --- a/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts +++ b/packages/diffs/src/react/utils/useUnresolvedFileInstance.ts @@ -44,6 +44,7 @@ interface UseUnresolvedFileInstanceProps { prerenderedHTML: string | undefined; hasConflictUtility: boolean; hasGutterRenderUtility: boolean; + hasCustomHeader: boolean; } interface UseUnresolvedFileInstanceReturn { @@ -63,6 +64,7 @@ export function useUnresolvedFileInstance({ prerenderedHTML, hasConflictUtility, hasGutterRenderUtility, + hasCustomHeader, }: UseUnresolvedFileInstanceProps): UseUnresolvedFileInstanceReturn { const [{ fileDiff, actions, markerRows }, setState] = useState(() => { const { fileDiff, actions, markerRows } = parseMergeConflictDiffFromFile( @@ -104,12 +106,13 @@ export function useUnresolvedFileInstance({ ); } instanceRef.current = new UnresolvedFileClass( - mergeUnresolvedOptions( - options, - onMergeConflictAction, + mergeUnresolvedOptions({ hasConflictUtility, - hasGutterRenderUtility - ), + hasCustomHeader, + hasGutterRenderUtility, + onMergeConflictAction, + options, + }), poolManager, true ); @@ -135,12 +138,13 @@ export function useUnresolvedFileInstance({ useIsometricEffect(() => { if (instanceRef.current == null) return; const instance = instanceRef.current; - const newOptions = mergeUnresolvedOptions( - options, - onMergeConflictAction, + const newOptions = mergeUnresolvedOptions({ hasConflictUtility, - hasGutterRenderUtility - ); + hasCustomHeader, + hasGutterRenderUtility, + onMergeConflictAction, + options, + }); const forceRender = !areOptionsEqual(instance.options, newOptions); instance.setOptions(newOptions); void instance.render({ @@ -168,12 +172,21 @@ export function useUnresolvedFileInstance({ return { ref, getHoveredLine, fileDiff, actions, markerRows, getInstance }; } -function mergeUnresolvedOptions( - options: UnresolvedFileReactOptions | undefined, - onMergeConflictAction: UnresolvedFileOptions['onMergeConflictAction'], - hasConflictUtility: boolean, - hasGutterRenderUtility: boolean -): UnresolvedFileOptions { +interface MergeUnresolvedOptionsProps { + options: UnresolvedFileReactOptions | undefined; + onMergeConflictAction: UnresolvedFileOptions['onMergeConflictAction']; + hasConflictUtility: boolean; + hasGutterRenderUtility: boolean; + hasCustomHeader: boolean; +} + +function mergeUnresolvedOptions({ + options, + onMergeConflictAction, + hasConflictUtility, + hasCustomHeader, + hasGutterRenderUtility, +}: MergeUnresolvedOptionsProps): UnresolvedFileOptions { return { ...options, onMergeConflictAction, @@ -186,6 +199,7 @@ function mergeUnresolvedOptions( hasConflictUtility || options?.mergeConflictActionsType === 'custom' ? noopRender : options?.mergeConflictActionsType, + renderCustomHeader: hasCustomHeader ? noopRender : undefined, renderGutterUtility: hasGutterRenderUtility ? noopRender : undefined, }; } diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index bcf418a9b..9c2392627 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -24,6 +24,7 @@ import type { DiffsHighlighter, ExpansionDirections, FileDiffMetadata, + FileHeaderRenderMode, HunkData, HunkExpansionRegion, HunkSeparators, @@ -34,7 +35,6 @@ import type { RenderRange, SupportedLanguages, ThemedDiffResult, - ThemeTypes, } from '../types'; import { areRenderRangesEqual } from '../utils/areRenderRangesEqual'; import { areThemesEqual } from '../utils/areThemesEqual'; @@ -107,6 +107,17 @@ interface ProcessContext { incrementRowCount(count?: number): void; } +export interface DiffHunksRendererOptions extends BaseDiffOptions { + headerRenderMode?: FileHeaderRenderMode; +} + +export interface DiffHunksRendererOptionsWithDefaults extends Omit< + BaseDiffOptionsWithDefaults, + 'themeType' +> { + headerRenderMode: FileHeaderRenderMode; +} + export interface UnifiedLineDecorationProps { type: 'context' | 'context-expanded' | 'change'; lineType: LineTypes; @@ -199,7 +210,7 @@ export class DiffHunksRenderer { private renderCache: RenderedDiffASTCache | undefined; constructor( - public options: BaseDiffOptions = { theme: DEFAULT_THEMES }, + public options: DiffHunksRendererOptions = { theme: DEFAULT_THEMES }, private onRenderUpdate?: () => unknown, private workerManager?: WorkerPoolManager | undefined ) { @@ -226,21 +237,14 @@ export class DiffHunksRenderer { this.workerManager?.cleanUpPendingTasks(this); } - public setOptions(options: BaseDiffOptions): void { + public setOptions(options: DiffHunksRendererOptions): void { this.options = options; } - private mergeOptions(options: Partial) { + public mergeOptions(options: Partial): void { this.options = { ...this.options, ...options }; } - public setThemeType(themeType: ThemeTypes): void { - if (this.getOptionsWithDefaults().themeType === themeType) { - return; - } - this.mergeOptions({ themeType }); - } - public expandHunk( index: number, direction: ExpansionDirections, @@ -328,7 +332,7 @@ export class DiffHunksRenderer { ctx: RenderedLineContext ) => SplitInjectedRowPlacement | undefined; - protected getOptionsWithDefaults(): BaseDiffOptionsWithDefaults { + protected getOptionsWithDefaults(): DiffHunksRendererOptionsWithDefaults { const { diffIndicators = 'bars', diffStyle = 'split', @@ -345,7 +349,7 @@ export class DiffHunksRenderer { maxLineDiffLength = 1000, overflow = 'scroll', theme = DEFAULT_THEMES, - themeType = 'system', + headerRenderMode = 'default', tokenizeMaxLineLength = 1000, useCSSClasses = false, } = this.options; @@ -365,7 +369,7 @@ export class DiffHunksRenderer { maxLineDiffLength, overflow, theme: this.workerManager?.getDiffRenderOptions().theme ?? theme, - themeType, + headerRenderMode, tokenizeMaxLineLength, useCSSClasses, }; @@ -548,26 +552,17 @@ export class DiffHunksRenderer { protected createPreElement( split: boolean, totalLines: number, - themeStyles: string, - baseThemeType: 'light' | 'dark' | undefined, customProperties?: CustomPreProperties ): HASTElement { - const { - diffIndicators, - disableBackground, - disableLineNumbers, - overflow, - themeType, - } = this.getOptionsWithDefaults(); + const { diffIndicators, disableBackground, disableLineNumbers, overflow } = + this.getOptionsWithDefaults(); return createPreElement({ type: 'diff', diffIndicators, disableBackground, disableLineNumbers, overflow, - themeStyles, split, - themeType: baseThemeType ?? themeType, totalLines, customProperties, }); @@ -1100,9 +1095,7 @@ export class DiffHunksRenderer { const preNode = this.createPreElement( deletionsContentAST != null && additionsContentAST != null, - totalLines, - themeStyles, - baseThemeType + totalLines ); return { @@ -1124,7 +1117,7 @@ export class DiffHunksRenderer { themeStyles, baseThemeType, headerElement: !disableFileHeader - ? this.renderHeader(this.diff, themeStyles, baseThemeType) + ? this.renderHeader(this.diff) : undefined, totalLines, rowCount: context.rowCount, @@ -1309,16 +1302,11 @@ export class DiffHunksRenderer { return { deletionSpan, additionSpan }; } - private renderHeader( - diff: FileDiffMetadata, - themeStyles: string, - baseThemeType: 'light' | 'dark' | undefined - ): HASTElement { - const { themeType } = this.getOptionsWithDefaults(); + private renderHeader(diff: FileDiffMetadata): HASTElement { + const { headerRenderMode } = this.getOptionsWithDefaults(); return createFileHeaderElement({ fileOrDiff: diff, - themeStyles, - themeType: baseThemeType ?? themeType, + mode: headerRenderMode, }); } } diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 0c8658834..03b64ae51 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -13,6 +13,7 @@ import type { BaseCodeOptions, DiffsHighlighter, FileContents, + FileHeaderRenderMode, LineAnnotation, RenderedFileASTCache, RenderFileOptions, @@ -20,7 +21,6 @@ import type { RenderRange, SupportedLanguages, ThemedFileResult, - ThemeTypes, } from '../types'; import { areRenderRangesEqual } from '../utils/areRenderRangesEqual'; import { areThemesEqual } from '../utils/areThemesEqual'; @@ -72,8 +72,9 @@ interface LineCache { lines: string[]; } -// oxlint-disable-next-line typescript/no-empty-object-type -export interface FileRendererOptions extends BaseCodeOptions {} +export interface FileRendererOptions extends BaseCodeOptions { + headerRenderMode?: FileHeaderRenderMode; +} let instanceId = -1; @@ -102,18 +103,10 @@ export class FileRenderer { this.options = options; } - private mergeOptions(options: Partial): void { + public mergeOptions(options: Partial): void { this.options = { ...this.options, ...options }; } - public setThemeType(themeType: ThemeTypes): void { - const currentThemeType = this.options.themeType ?? 'system'; - if (currentThemeType === themeType) { - return; - } - this.mergeOptions({ themeType }); - } - public setLineAnnotations( lineAnnotations: LineAnnotation[] ): void { @@ -398,30 +391,23 @@ export class FileRenderer { return { gutterAST: gutter.children ?? [], contentAST: contentArray, - preAST: this.createPreElement(lines.length, themeStyles, baseThemeType), - headerAST: !disableFileHeader - ? this.renderHeader(file, themeStyles, baseThemeType) - : undefined, + preAST: this.createPreElement(lines.length), + headerAST: !disableFileHeader ? this.renderHeader(file) : undefined, totalLines: lines.length, rowCount, themeStyles: themeStyles, - baseThemeType: baseThemeType, + baseThemeType, bufferBefore: renderRange.bufferBefore, bufferAfter: renderRange.bufferAfter, css: '', }; } - private renderHeader( - file: FileContents, - themeStyles: string, - baseThemeType: 'light' | 'dark' | undefined - ) { - const { themeType = 'system' } = this.options; + private renderHeader(file: FileContents) { + const { headerRenderMode = 'default' } = this.options; return createFileHeaderElement({ fileOrDiff: file, - themeStyles, - themeType: baseThemeType ?? themeType, + mode: headerRenderMode, }); } @@ -507,24 +493,14 @@ export class FileRenderer { console.error(error); } - private createPreElement( - totalLines: number, - themeStyles: string, - baseThemeType: 'light' | 'dark' | undefined - ): HASTElement { - const { - disableLineNumbers = false, - overflow = 'scroll', - themeType = 'system', - } = this.options; + private createPreElement(totalLines: number): HASTElement { + const { disableLineNumbers = false, overflow = 'scroll' } = this.options; return createPreElement({ type: 'file', diffIndicators: 'none', disableBackground: true, disableLineNumbers, overflow, - themeStyles, - themeType: baseThemeType ?? themeType, split: false, totalLines, }); diff --git a/packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts b/packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts index d93b9bfde..beb45fe42 100644 --- a/packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts +++ b/packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts @@ -2,8 +2,6 @@ import type { Element as HASTElement, Properties } from 'hast'; import { DEFAULT_RENDER_RANGE, DEFAULT_THEMES } from '../constants'; import type { - BaseDiffOptions, - BaseDiffOptionsWithDefaults, FileDiffMetadata, MergeConflictMarkerRow, MergeConflictResolution, @@ -22,6 +20,8 @@ import { import type { WorkerPoolManager } from '../worker'; import { DiffHunksRenderer, + type DiffHunksRendererOptions, + type DiffHunksRendererOptionsWithDefaults, type HunksRenderResult, type InjectedRow, type LineDecoration, @@ -60,13 +60,13 @@ type MergeConflictInjectedRowData = | ({ type: 'actions' } & MergeConflictActionRowData) | MergeConflictMarkerInjectedRow; -interface BaseUnresolvedOptionsWithDefaults extends BaseDiffOptionsWithDefaults { +interface BaseUnresolvedOptionsWithDefaults extends DiffHunksRendererOptionsWithDefaults { mergeConflictActionsType: MergeConflictActionsType; } type MergeConflictActionsType = 'none' | 'default' | 'custom'; -export interface UnresolvedFileHunksRendererOptions extends BaseDiffOptions { +export interface UnresolvedFileHunksRendererOptions extends DiffHunksRendererOptions { mergeConflictActionsType?: MergeConflictActionsType; } @@ -168,17 +168,11 @@ export class UnresolvedFileHunksRenderer< protected override createPreElement( split: boolean, - totalLines: number, - themeStyles: string, - baseThemeType: 'light' | 'dark' | undefined + totalLines: number ): HASTElement { - return super.createPreElement( - split, - totalLines, - themeStyles, - baseThemeType, - { 'data-has-merge-conflict': '' } - ); + return super.createPreElement(split, totalLines, { + 'data-has-merge-conflict': '', + }); } protected override getUnifiedLineDecoration({ diff --git a/packages/diffs/src/ssr/preloadDiffs.ts b/packages/diffs/src/ssr/preloadDiffs.ts index 5e4243e86..3acfed112 100644 --- a/packages/diffs/src/ssr/preloadDiffs.ts +++ b/packages/diffs/src/ssr/preloadDiffs.ts @@ -5,16 +5,20 @@ import { } from '../components/UnresolvedFile'; import { DiffHunksRenderer, + type DiffHunksRendererOptions, type HunksRenderResult, } from '../renderers/DiffHunksRenderer'; import { UnresolvedFileHunksRenderer } from '../renderers/UnresolvedFileHunksRenderer'; import type { - BaseDiffOptions, DiffLineAnnotation, FileContents, FileDiffMetadata, } from '../types'; -import { createStyleElement } from '../utils/createStyleElement'; +import { + createStyleElement, + createThemeStyleElement, +} from '../utils/createStyleElement'; +import { wrapThemeCSS } from '../utils/cssWrappers'; import { getSingularPatch } from '../utils/getSingularPatch'; import { parseDiffFromFile } from '../utils/parseDiffFromFile'; import { parseMergeConflictDiffFromFile } from '../utils/parseMergeConflictDiffFromFile'; @@ -53,7 +57,8 @@ export async function preloadDiffHTML({ processHunkResult( await renderer.asyncRender(fileDiff), renderer, - options?.unsafeCSS + options?.unsafeCSS, + options?.themeType ?? 'system' ) ); } @@ -78,7 +83,8 @@ export async function preloadUnresolvedFileHTML({ processHunkResult( await renderer.asyncRender(fileDiff), renderer, - options?.unsafeCSS + options?.unsafeCSS, + options?.themeType ?? 'system' ) ); } @@ -220,9 +226,18 @@ function processHunkResult( renderer: | DiffHunksRenderer | UnresolvedFileHunksRenderer, - unsafeCSS: string | undefined + unsafeCSS: string | undefined, + themeType: 'system' | 'light' | 'dark' ) { const children = [createStyleElement(hunkResult.css, true)]; + children.push( + createThemeStyleElement( + wrapThemeCSS( + hunkResult.themeStyles, + hunkResult.baseThemeType ?? themeType + ) + ) + ); if (unsafeCSS != null) { children.push(createStyleElement(unsafeCSS)); } @@ -237,9 +252,11 @@ function processHunkResult( function getHunksRendererOptions( options: FileDiffOptions | undefined -): BaseDiffOptions { +): DiffHunksRendererOptions { return { ...options, + headerRenderMode: + options?.renderCustomHeader != null ? 'custom' : 'default', hunkSeparators: typeof options?.hunkSeparators === 'function' ? 'custom' diff --git a/packages/diffs/src/ssr/preloadFile.ts b/packages/diffs/src/ssr/preloadFile.ts index 51f3fbeac..199b205bf 100644 --- a/packages/diffs/src/ssr/preloadFile.ts +++ b/packages/diffs/src/ssr/preloadFile.ts @@ -1,7 +1,11 @@ import type { FileOptions } from '../components/File'; import { FileRenderer } from '../renderers/FileRenderer'; import type { FileContents, LineAnnotation } from '../types'; -import { createStyleElement } from '../utils/createStyleElement'; +import { + createStyleElement, + createThemeStyleElement, +} from '../utils/createStyleElement'; +import { wrapThemeCSS } from '../utils/cssWrappers'; import { renderHTML } from './renderHTML'; export type PreloadFileOptions = { @@ -22,7 +26,11 @@ export async function preloadFile({ options, annotations, }: PreloadFileOptions): Promise> { - const fileRenderer = new FileRenderer(options); + const fileRenderer = new FileRenderer({ + ...options, + headerRenderMode: + options?.renderCustomHeader != null ? 'custom' : 'default', + }); // Set line annotations if provided if (annotations !== undefined && annotations.length > 0) { @@ -30,9 +38,17 @@ export async function preloadFile({ } const fileResult = await fileRenderer.asyncRender(file); - const children = [createStyleElement(fileResult.css, true)]; + children.push( + createThemeStyleElement( + wrapThemeCSS( + fileResult.themeStyles, + fileResult.baseThemeType ?? options?.themeType ?? 'system' + ) + ) + ); + if (options?.unsafeCSS != null) { children.push(createStyleElement(options.unsafeCSS)); } diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css index dcb6812eb..2cb5c979d 100644 --- a/packages/diffs/src/style.css +++ b/packages/diffs/src/style.css @@ -1,9 +1,7 @@ -@layer base, theme, unsafe; +@layer base, theme, rendered, unsafe; @layer base { :host { - --diffs-bg: #fff; - --diffs-fg: #000; --diffs-font-fallback: 'SF Mono', Monaco, Consolas, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', monospace; @@ -70,70 +68,14 @@ font-size: var(--diffs-font-size, 13px); line-height: var(--diffs-line-height, 20px); font-feature-settings: var(--diffs-font-features); - } - - /* NOTE(mdo): Some semantic HTML elements (e.g. `pre`, `code`) have default - * user-agent styles. These must be overridden to use our custom styles. */ - pre, - code, - [data-error-wrapper] { - isolation: isolate; - margin: 0; - padding: 0; - display: block; - outline: none; - font-family: var(--diffs-font-family, var(--diffs-font-fallback)); - } - - pre, - code { - background-color: var(--diffs-bg); - } - - code { - contain: content; - } - - *, - *::before, - *::after { - box-sizing: border-box; - } - - [data-icon-sprite] { - display: none; - } - - /* NOTE(mdo): Headers and separators are within pre/code, so we need to reset - * their font-family explicitly. */ - [data-diffs-header], - [data-separator] { - font-family: var( - --diffs-header-font-family, - var(--diffs-header-font-fallback) - ); - } - - [data-file-info] { - padding: 10px; - font-weight: 700; - color: var(--fg); - /* NOTE(amadeus): we cannot use 'in oklch' because current versions of cursor - * and vscode use an older build of chrome that appears to have a bug with - * color-mix and 'in oklch', so use 'in lab' instead */ - background-color: color-mix(in lab, var(--bg) 98%, var(--fg)); - border-block: 1px solid color-mix(in lab, var(--bg) 95%, var(--fg)); - } - [data-diffs-header], - [data-diff], - [data-file], - [data-error-wrapper], - [data-virtualizer-buffer] { - --diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg)); /* NOTE(amadeus): we cannot use 'in oklch' because current versions of cursor - * and vscode use an older build of chrome that appears to have a bug with - * color-mix and 'in oklch', so use 'in lab' instead */ + * and vscode use an older build of chrome that appears to have a bug with + * color-mix and 'in oklch', so use 'in lab' instead */ + --diffs-bg: light-dark( + var(--diffs-light-bg, #fff), + var(--diffs-dark-bg, #000) + ); --diffs-bg-buffer: var( --diffs-bg-buffer-override, light-dark( @@ -282,7 +224,7 @@ ) ); - --diffs-fg: light-dark(var(--diffs-light), var(--diffs-dark)); + --diffs-fg: light-dark(var(--diffs-light, #000), var(--diffs-dark, #fff)); --diffs-fg-number: var( --diffs-fg-number-override, light-dark( @@ -420,6 +362,59 @@ color: var(--diffs-fg); } + /* NOTE(mdo): Some semantic HTML elements (e.g. `pre`, `code`) have default + * user-agent styles. These must be overridden to use our custom styles. */ + pre, + code, + [data-error-wrapper] { + isolation: isolate; + margin: 0; + padding: 0; + display: block; + outline: none; + font-family: var(--diffs-font-family, var(--diffs-font-fallback)); + } + + pre, + code { + background-color: var(--diffs-bg); + } + + code { + contain: content; + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + [data-icon-sprite] { + display: none; + } + + /* NOTE(mdo): Headers and separators are within pre/code, so we need to reset + * their font-family explicitly. */ + [data-diffs-header], + [data-separator] { + font-family: var( + --diffs-header-font-family, + var(--diffs-header-font-fallback) + ); + } + + [data-file-info] { + padding: 10px; + font-weight: 700; + color: var(--fg); + /* NOTE(amadeus): we cannot use 'in oklch' because current versions of cursor + * and vscode use an older build of chrome that appears to have a bug with + * color-mix and 'in oklch', so use 'in lab' instead */ + background-color: color-mix(in lab, var(--bg) 98%, var(--fg)); + border-block: 1px solid color-mix(in lab, var(--bg) 95%, var(--fg)); + } + [data-diff], [data-file] { /* This feels a bit crazy to me... so I need to think about it a bit more... */ @@ -430,35 +425,32 @@ --diffs-code-grid: var(--diffs-grid-number-column-width) minmax(0, 1fr); } - &[data-theme-type='light'], - & { - [data-line] span { - color: light-dark( - var(--diffs-token-light, var(--diffs-light)), - var(--diffs-token-dark, var(--diffs-dark)) - ); - font-weight: var(--diffs-token-light-font-weight, inherit); - font-style: var(--diffs-token-light-font-style, inherit); - text-decoration: var(--diffs-token-light-text-decoration, inherit); - } - } - - &[data-theme-type='dark'] [data-line] span { - font-weight: var(--diffs-token-dark-font-weight, inherit); - font-style: var(--diffs-token-dark-font-style, inherit); - text-decoration: var(--diffs-token-dark-text-decoration, inherit); - } - &:hover [data-code]::-webkit-scrollbar-thumb { background-color: var(--diffs-bg-context); } } [data-line] span { + color: light-dark( + var(--diffs-token-light, var(--diffs-light)), + var(--diffs-token-dark, var(--diffs-dark)) + ); background-color: light-dark( var(--diffs-token-light-bg, inherit), var(--diffs-token-dark-bg, inherit) ); + font-weight: light-dark( + var(--diffs-token-light-font-weight, inherit), + var(--diffs-token-dark-font-weight, inherit) + ); + font-style: light-dark( + var(--diffs-token-light-font-style, inherit), + var(--diffs-token-dark-font-style, inherit) + ); + text-decoration: light-dark( + var(--diffs-token-light-text-decoration, inherit), + var(--diffs-token-dark-text-decoration, inherit) + ); } [data-line], @@ -477,33 +469,6 @@ } } - @media (prefers-color-scheme: dark) { - [data-diffs-header], - [data-error-wrapper], - [data-diff], - [data-file] { - color-scheme: dark; - } - - [data-content] [data-line] span { - font-weight: var(--diffs-token-dark-font-weight, inherit); - font-style: var(--diffs-token-dark-font-style, inherit); - text-decoration: var(--diffs-token-dark-text-decoration, inherit); - } - } - - [data-diffs-header], - [data-diff], - [data-file] { - &[data-theme-type='light'] { - color-scheme: light; - } - - &[data-theme-type='dark'] { - color-scheme: dark; - } - } - [data-diff-type='split'][data-overflow='scroll'] { display: grid; grid-template-columns: 1fr 1fr; @@ -1386,7 +1351,7 @@ } } - [data-diffs-header] { + [data-diffs-header='default'] { position: relative; display: flex; flex-direction: row; @@ -1429,19 +1394,19 @@ flex-grow: 0; } - [data-diffs-header] [data-metadata] { + [data-diffs-header='default'] [data-metadata] { display: flex; align-items: center; gap: 1ch; white-space: nowrap; } - [data-diffs-header] [data-additions-count] { + [data-diffs-header='default'] [data-additions-count] { font-family: var(--diffs-font-family, var(--diffs-font-fallback)); color: var(--diffs-addition-base); } - [data-diffs-header] [data-deletions-count] { + [data-diffs-header='default'] [data-deletions-count] { font-family: var(--diffs-font-family, var(--diffs-font-fallback)); color: var(--diffs-deletion-base); } diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 1fa4406e6..56b1e86f2 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -407,37 +407,28 @@ export type CustomPreProperties = Record; export interface PrePropertiesConfig extends Required< Pick< BaseDiffOptions, - | 'diffIndicators' - | 'disableBackground' - | 'disableLineNumbers' - | 'overflow' - | 'themeType' + 'diffIndicators' | 'disableBackground' | 'disableLineNumbers' | 'overflow' > > { type: 'diff' | 'file'; split: boolean; - themeStyles: string; totalLines: number; customProperties?: CustomPreProperties; } -export interface RenderHeaderMetadataProps { - deletionFile?: FileContents; - additionFile?: FileContents; - fileDiff?: FileDiffMetadata; -} +export type FileHeaderRenderMode = 'default' | 'custom'; export type RenderHeaderMetadataCallback = ( - props: RenderHeaderMetadataProps -) => Element | null | undefined | string | number; + fileDiff: FileDiffMetadata +) => Element | string | number | null | undefined; export type RenderHeaderPrefixCallback = ( - props: RenderHeaderMetadataProps -) => Element | null | undefined | string | number; + fileDiff: FileDiffMetadata +) => Element | string | number | null | undefined; export type RenderFileMetadata = ( file: FileContents -) => Element | null | undefined | string | number; +) => Element | string | number | null | undefined; export type ExtensionFormatMap = Record; @@ -714,3 +705,9 @@ export interface ProcessFileConflictData { /** Hunk-content index that anchors the end marker row. */ endMarkerContentIndex: number; } + +export interface AppliedThemeStyleCache { + themeStyles: string; + themeType: ThemeTypes; + baseThemeType: 'light' | 'dark' | undefined; +} diff --git a/packages/diffs/src/utils/arePrePropertiesEqual.ts b/packages/diffs/src/utils/arePrePropertiesEqual.ts index f0e4a6c30..c2e908c4e 100644 --- a/packages/diffs/src/utils/arePrePropertiesEqual.ts +++ b/packages/diffs/src/utils/arePrePropertiesEqual.ts @@ -18,8 +18,6 @@ export function arePrePropertiesEqual( propsA.disableLineNumbers === propsB.disableLineNumbers && propsA.overflow === propsB.overflow && propsA.split === propsB.split && - propsA.themeStyles === propsB.themeStyles && - propsA.themeType === propsB.themeType && propsA.totalLines === propsB.totalLines ); } diff --git a/packages/diffs/src/utils/createFileHeaderElement.ts b/packages/diffs/src/utils/createFileHeaderElement.ts index d87af7819..f8fbbabac 100644 --- a/packages/diffs/src/utils/createFileHeaderElement.ts +++ b/packages/diffs/src/utils/createFileHeaderElement.ts @@ -1,11 +1,15 @@ import type { ElementContent, Element as HASTElement, Properties } from 'hast'; -import { HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from '../constants'; +import { + CUSTOM_HEADER_SLOT_ID, + HEADER_METADATA_SLOT_ID, + HEADER_PREFIX_SLOT_ID, +} from '../constants'; import type { ChangeTypes, FileContents, FileDiffMetadata, - ThemeTypes, + FileHeaderRenderMode, } from '../types'; import { getIconForType } from './getIconForType'; import { @@ -16,32 +20,34 @@ import { export interface CreateFileHeaderElementProps { fileOrDiff: FileDiffMetadata | FileContents; - themeStyles: string; - themeType: ThemeTypes; + mode: FileHeaderRenderMode; } export function createFileHeaderElement({ fileOrDiff, - themeStyles, - themeType, + mode, }: CreateFileHeaderElementProps): HASTElement { const fileDiff = 'type' in fileOrDiff ? fileOrDiff : undefined; const properties: Properties = { - 'data-diffs-header': '', + 'data-diffs-header': mode, 'data-change-type': fileDiff?.type, - 'data-theme-type': themeType !== 'system' ? themeType : undefined, - style: themeStyles, }; return createHastElement({ tagName: 'div', children: [ - createHeaderElement({ - name: fileOrDiff.name, - prevName: 'prevName' in fileOrDiff ? fileOrDiff.prevName : undefined, - iconType: fileDiff?.type ?? 'file', - }), - createMetadataElement(fileDiff), + mode === 'custom' + ? createHastElement({ + tagName: 'slot', + properties: { name: CUSTOM_HEADER_SLOT_ID }, + }) + : createHeaderElement({ + name: fileOrDiff.name, + prevName: + 'prevName' in fileOrDiff ? fileOrDiff.prevName : undefined, + iconType: fileDiff?.type ?? 'file', + }), + ...(mode === 'custom' ? [] : [createMetadataElement(fileDiff)]), ], properties, }); diff --git a/packages/diffs/src/utils/createPreElement.ts b/packages/diffs/src/utils/createPreElement.ts index cfbf9f1e0..890093abb 100644 --- a/packages/diffs/src/utils/createPreElement.ts +++ b/packages/diffs/src/utils/createPreElement.ts @@ -16,8 +16,6 @@ export function createPreWrapperProperties({ disableLineNumbers, overflow, split, - themeType, - themeStyles, totalLines, type, customProperties, @@ -37,13 +35,9 @@ export function createPreWrapperProperties({ diffIndicators === 'bars' || diffIndicators === 'classic' ? diffIndicators : undefined, - 'data-theme-type': themeType !== 'system' ? themeType : undefined, - // NOTE(amadeus): Alex, here we would probably set a class property - // instead, when that's working and supported - style: themeStyles, tabIndex: 0, + style: `--diffs-min-number-column-width-default:${`${totalLines}`.length}ch;`, }; - properties.style += `--diffs-min-number-column-width-default:${`${totalLines}`.length}ch;`; return properties; } diff --git a/packages/diffs/src/utils/createStyleElement.ts b/packages/diffs/src/utils/createStyleElement.ts index 4bf6b8bd1..3ce194ac0 100644 --- a/packages/diffs/src/utils/createStyleElement.ts +++ b/packages/diffs/src/utils/createStyleElement.ts @@ -1,6 +1,10 @@ import type { Element as HASTElement } from 'hast'; -import { CORE_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE } from '../constants'; +import { + CORE_CSS_ATTRIBUTE, + THEME_CSS_ATTRIBUTE, + UNSAFE_CSS_ATTRIBUTE, +} from '../constants'; import { wrapCoreCSS, wrapUnsafeCSS } from './cssWrappers'; import { createHastElement, createTextNodeElement } from './hast_utils'; @@ -21,3 +25,13 @@ export function createStyleElement( }, }); } + +export function createThemeStyleElement(content: string): HASTElement { + return createHastElement({ + tagName: 'style', + children: [createTextNodeElement(content)], + properties: { + [THEME_CSS_ATTRIBUTE]: '', + }, + }); +} diff --git a/packages/diffs/src/utils/cssWrappers.ts b/packages/diffs/src/utils/cssWrappers.ts index b8b8428ef..23b1321bd 100644 --- a/packages/diffs/src/utils/cssWrappers.ts +++ b/packages/diffs/src/utils/cssWrappers.ts @@ -1,6 +1,7 @@ import rawStyles from '../style.css'; +import type { ThemeTypes } from '../types'; -const LAYER_ORDER = `@layer base, theme, unsafe;`; +const LAYER_ORDER = `@layer base, theme, rendered, unsafe;`; export function wrapCoreCSS(mainCSS: string) { return `${LAYER_ORDER} @@ -16,3 +17,21 @@ export function wrapUnsafeCSS(unsafeCSS: string) { ${unsafeCSS} }`; } + +export function wrapThemeCSS( + themeCSS: string, + themeType: ThemeTypes = 'system' +) { + const colorSchemeRule = + themeType === 'system' + ? '' + : ` + color-scheme: ${themeType};`; + + return `${LAYER_ORDER} +@layer rendered { + :host {${colorSchemeRule} + ${themeCSS} + } +}`; +} diff --git a/packages/diffs/src/utils/hostTheme.ts b/packages/diffs/src/utils/hostTheme.ts new file mode 100644 index 000000000..2fecf168e --- /dev/null +++ b/packages/diffs/src/utils/hostTheme.ts @@ -0,0 +1,33 @@ +import { THEME_CSS_ATTRIBUTE } from '../constants'; + +interface UpsertHostThemeStyleProps { + shadowRoot: ShadowRoot; + currentNode: HTMLStyleElement | undefined; + themeCSS: string; +} + +// Keep the host theme style stable so renderers can update the host-scoped theme +// CSS without rebuilding the rest of the shadow DOM. +export function upsertHostThemeStyle({ + shadowRoot, + currentNode, + themeCSS, +}: UpsertHostThemeStyleProps): HTMLStyleElement | undefined { + if (themeCSS.trim() === '') { + currentNode?.remove(); + return undefined; + } + + currentNode ??= createHostThemeStyleNode(); + currentNode.textContent = themeCSS; + if (currentNode.parentNode !== shadowRoot) { + shadowRoot.appendChild(currentNode); + } + return currentNode; +} + +export function createHostThemeStyleNode(): HTMLStyleElement { + const node = document.createElement('style'); + node.setAttribute(THEME_CSS_ATTRIBUTE, ''); + return node; +} diff --git a/packages/diffs/src/utils/renderDiffWithHighlighter.ts b/packages/diffs/src/utils/renderDiffWithHighlighter.ts index 6ebfe4aad..7eab4bcab 100644 --- a/packages/diffs/src/utils/renderDiffWithHighlighter.ts +++ b/packages/diffs/src/utils/renderDiffWithHighlighter.ts @@ -59,13 +59,10 @@ export function renderDiffWithHighlighter( totalLines = Infinity; } const isWindowedHighlight = startingLine > 0 || totalLines < Infinity; - const baseThemeType = (() => { - const theme = options.theme ?? DEFAULT_THEMES; - if (typeof theme === 'string') { - return highlighter.getTheme(theme).type; - } - return undefined; - })(); + const baseThemeType = + typeof options.theme === 'string' + ? highlighter.getTheme(options.theme).type + : undefined; const themeStyles = getHighlighterThemeStyles({ theme: options.theme, highlighter, diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index e364dc046..521854e1c 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -48,12 +48,8 @@ export function renderFileWithHighlighter( const lang = forcePlainText ? 'text' : (file.lang ?? getFiletypeFromFileName(file.name)); - const baseThemeType = (() => { - if (typeof theme === 'string') { - return highlighter.getTheme(theme).type; - } - return undefined; - })(); + const baseThemeType = + typeof theme === 'string' ? highlighter.getTheme(theme).type : undefined; const themeStyles = getHighlighterThemeStyles({ theme, highlighter, diff --git a/packages/diffs/src/utils/setWrapperNodeProps.ts b/packages/diffs/src/utils/setWrapperNodeProps.ts index bcc4f020e..81cfb7c96 100644 --- a/packages/diffs/src/utils/setWrapperNodeProps.ts +++ b/packages/diffs/src/utils/setWrapperNodeProps.ts @@ -9,8 +9,6 @@ export function setPreNodeProperties( disableLineNumbers, overflow, split, - themeStyles, - themeType, totalLines, customProperties, }: PrePropertiesConfig @@ -32,11 +30,6 @@ export function setPreNodeProperties( pre.setAttribute('data-file', ''); pre.removeAttribute('data-diff'); } - if (themeType === 'system') { - pre.removeAttribute('data-theme-type'); - } else { - pre.setAttribute('data-theme-type', themeType); - } switch (diffIndicators) { case 'bars': case 'classic': @@ -63,8 +56,6 @@ export function setPreNodeProperties( } pre.setAttribute('data-overflow', overflow); pre.tabIndex = 0; - // Set theme color custom properties as inline styles on pre element - pre.style = themeStyles; // Set CSS custom property for line number column width pre.style.setProperty( '--diffs-min-number-column-width-default', diff --git a/packages/diffs/test/__snapshots__/DiffHunksRender.test.ts.snap b/packages/diffs/test/__snapshots__/DiffHunksRender.test.ts.snap index 7c84a2208..70928c580 100644 --- a/packages/diffs/test/__snapshots__/DiffHunksRender.test.ts.snap +++ b/packages/diffs/test/__snapshots__/DiffHunksRender.test.ts.snap @@ -2455,9 +2455,7 @@ exports[`DiffHunksRenderer proper buffers should be prepended to additions colum ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -2473,8 +2471,7 @@ exports[`DiffHunksRenderer proper buffers should be prepended to additions colum "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:2ch;", + "style": "--diffs-min-number-column-width-default:2ch;", "tabIndex": 0, }, "tagName": "pre", @@ -4943,9 +4940,7 @@ exports[`DiffHunksRenderer proper buffers should be prepended to deletions colum ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -4961,8 +4956,7 @@ exports[`DiffHunksRenderer proper buffers should be prepended to deletions colum "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:2ch;", + "style": "--diffs-min-number-column-width-default:2ch;", "tabIndex": 0, }, "tagName": "pre", @@ -5285,9 +5279,7 @@ exports[`DiffHunksRenderer additions and deletions should be empty when unified: ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -5303,8 +5295,7 @@ exports[`DiffHunksRenderer additions and deletions should be empty when unified: "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:2ch;", + "style": "--diffs-min-number-column-width-default:2ch;", "tabIndex": 0, }, "tagName": "pre", @@ -7981,9 +7972,7 @@ exports[`DiffHunksRenderer a diff with only additions should have an empty delet ], "properties": { "data-change-type": "new", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -7999,8 +7988,7 @@ exports[`DiffHunksRenderer a diff with only additions should have an empty delet "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:2ch;", + "style": "--diffs-min-number-column-width-default:2ch;", "tabIndex": 0, }, "tagName": "pre", @@ -9134,9 +9122,7 @@ exports[`DiffHunksRenderer a diff with only deletions should have an empty addit ], "properties": { "data-change-type": "deleted", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -9152,8 +9138,7 @@ exports[`DiffHunksRenderer a diff with only deletions should have an empty addit "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:2ch;", + "style": "--diffs-min-number-column-width-default:2ch;", "tabIndex": 0, }, "tagName": "pre", diff --git a/packages/diffs/test/__snapshots__/DiffHunksRendererVirtualization.test.ts.snap b/packages/diffs/test/__snapshots__/DiffHunksRendererVirtualization.test.ts.snap index 0c929dd23..bf4a2592d 100644 --- a/packages/diffs/test/__snapshots__/DiffHunksRendererVirtualization.test.ts.snap +++ b/packages/diffs/test/__snapshots__/DiffHunksRendererVirtualization.test.ts.snap @@ -115,9 +115,7 @@ exports[`DiffHunksRenderer - Virtualization expanded collapsed regions 3.2: Part ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -266,8 +264,7 @@ exports[`DiffHunksRenderer - Virtualization expanded collapsed regions 3.2: Part "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:3ch;", + "style": "--diffs-min-number-column-width-default:3ch;", "tabIndex": 0, }, "tagName": "pre", @@ -29324,9 +29321,7 @@ exports[`DiffHunksRenderer - Virtualization expanded collapsed regions 3.3: Part ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -29475,8 +29470,7 @@ exports[`DiffHunksRenderer - Virtualization expanded collapsed regions 3.3: Part "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:3ch;", + "style": "--diffs-min-number-column-width-default:3ch;", "tabIndex": 0, }, "tagName": "pre", @@ -58298,9 +58292,7 @@ exports[`DiffHunksRenderer - Virtualization expanded collapsed regions 3.4: Part ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -58449,8 +58441,7 @@ exports[`DiffHunksRenderer - Virtualization expanded collapsed regions 3.4: Part "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:3ch;", + "style": "--diffs-min-number-column-width-default:3ch;", "tabIndex": 0, }, "tagName": "pre", @@ -87635,9 +87626,7 @@ exports[`DiffHunksRenderer - Virtualization expanded collapsed regions 3.5: Wind ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -87665,8 +87654,7 @@ exports[`DiffHunksRenderer - Virtualization expanded collapsed regions 3.5: Wind "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:3ch;", + "style": "--diffs-min-number-column-width-default:3ch;", "tabIndex": 0, }, "tagName": "pre", @@ -90437,9 +90425,7 @@ exports[`DiffHunksRenderer - Virtualization correct lines rendered 6.1: Rendered ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -90455,8 +90441,7 @@ exports[`DiffHunksRenderer - Virtualization correct lines rendered 6.1: Rendered "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:3ch;", + "style": "--diffs-min-number-column-width-default:3ch;", "tabIndex": 0, }, "tagName": "pre", @@ -91693,9 +91678,7 @@ exports[`DiffHunksRenderer - Virtualization correct lines rendered 6.2: Rendered ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -91711,8 +91694,7 @@ exports[`DiffHunksRenderer - Virtualization correct lines rendered 6.2: Rendered "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:3ch;", + "style": "--diffs-min-number-column-width-default:3ch;", "tabIndex": 0, }, "tagName": "pre", diff --git a/packages/diffs/test/__snapshots__/createFileHeaderElement.test.ts.snap b/packages/diffs/test/__snapshots__/createFileHeaderElement.test.ts.snap new file mode 100644 index 000000000..b261b1c61 --- /dev/null +++ b/packages/diffs/test/__snapshots__/createFileHeaderElement.test.ts.snap @@ -0,0 +1,109 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`createFileHeaderElement renders default file header AST 1`] = ` +{ + "children": [ + { + "children": [ + { + "children": [], + "properties": { + "name": "header-prefix", + }, + "tagName": "slot", + "type": "element", + }, + { + "children": [ + { + "children": [], + "properties": { + "href": "#diffs-icon-file-code", + }, + "tagName": "use", + "type": "element", + }, + ], + "properties": { + "data-change-icon": "file", + "height": 16, + "viewBox": "0 0 16 16", + "width": 16, + }, + "tagName": "svg", + "type": "element", + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "src/index.ts", + }, + ], + "properties": {}, + "tagName": "bdi", + "type": "element", + }, + ], + "properties": { + "data-title": "", + }, + "tagName": "div", + "type": "element", + }, + ], + "properties": { + "data-header-content": "", + }, + "tagName": "div", + "type": "element", + }, + { + "children": [ + { + "children": [], + "properties": { + "name": "header-metadata", + }, + "tagName": "slot", + "type": "element", + }, + ], + "properties": { + "data-metadata": "", + }, + "tagName": "div", + "type": "element", + }, + ], + "properties": { + "data-change-type": undefined, + "data-diffs-header": "default", + }, + "tagName": "div", + "type": "element", +} +`; + +exports[`createFileHeaderElement renders custom file header AST 1`] = ` +{ + "children": [ + { + "children": [], + "properties": { + "name": "header-custom", + }, + "tagName": "slot", + "type": "element", + }, + ], + "properties": { + "data-change-type": undefined, + "data-diffs-header": "custom", + }, + "tagName": "div", + "type": "element", +} +`; diff --git a/packages/diffs/test/__snapshots__/patchFileRender.test.ts.snap b/packages/diffs/test/__snapshots__/patchFileRender.test.ts.snap index b3c7e33ee..80e9ab3c9 100644 --- a/packages/diffs/test/__snapshots__/patchFileRender.test.ts.snap +++ b/packages/diffs/test/__snapshots__/patchFileRender.test.ts.snap @@ -2681,9 +2681,7 @@ exports[`file.patch fixture parses and renders the patch file: rendered patch 1` ], "properties": { "data-change-type": "change", - "data-diffs-header": "", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;", + "data-diffs-header": "default", }, "tagName": "div", "type": "element", @@ -2714,8 +2712,7 @@ exports[`file.patch fixture parses and renders the patch file: rendered patch 1` "data-file": undefined, "data-indicators": "bars", "data-overflow": "scroll", - "data-theme-type": undefined, - "style": "--diffs-dark:#fbfbfb;--diffs-dark-bg:#070707;--diffs-dark-addition-color:#00cab1;--diffs-dark-deletion-color:#ff2e3f;--diffs-dark-modified-color:#009fff;--diffs-light:#070707;--diffs-light-bg:#ffffff;--diffs-light-addition-color:#00cab1;--diffs-light-deletion-color:#ff2e3f;--diffs-light-modified-color:#009fff;--diffs-min-number-column-width-default:4ch;", + "style": "--diffs-min-number-column-width-default:4ch;", "tabIndex": 0, }, "tagName": "pre", diff --git a/packages/diffs/test/createFileHeaderElement.test.ts b/packages/diffs/test/createFileHeaderElement.test.ts new file mode 100644 index 000000000..4d37d873e --- /dev/null +++ b/packages/diffs/test/createFileHeaderElement.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; + +import { createFileHeaderElement } from '../src/utils/createFileHeaderElement'; + +describe('createFileHeaderElement', () => { + test('renders default file header AST', () => { + const header = createFileHeaderElement({ + fileOrDiff: { + name: 'src/index.ts', + contents: 'export {}\n', + }, + mode: 'default', + }); + + expect(header).toMatchSnapshot(); + }); + + test('renders custom file header AST', () => { + const header = createFileHeaderElement({ + fileOrDiff: { + name: 'src/index.ts', + contents: 'export {}\n', + }, + mode: 'custom', + }); + + expect(header).toMatchSnapshot(); + }); +}); diff --git a/packages/diffs/test/preloadThemeType.test.ts b/packages/diffs/test/preloadThemeType.test.ts new file mode 100644 index 000000000..dee0d10c1 --- /dev/null +++ b/packages/diffs/test/preloadThemeType.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test'; + +import { preloadFile } from '../src/ssr'; +import { mockFiles } from './mocks'; + +describe('preloaded theme type', () => { + test('hardcoded themes use their own light or dark mode', async () => { + const { prerenderedHTML } = await preloadFile({ + file: mockFiles.file1, + options: { + theme: 'pierre-dark', + themeType: 'light', + }, + }); + + expect(prerenderedHTML).toContain('color-scheme: dark;'); + expect(prerenderedHTML).not.toContain('color-scheme: light;'); + }); + + test('paired themes still respect the requested themeType switch', async () => { + const { prerenderedHTML } = await preloadFile({ + file: mockFiles.file1, + options: { + theme: { dark: 'pierre-dark', light: 'pierre-light' }, + themeType: 'light', + }, + }); + + expect(prerenderedHTML).toContain('color-scheme: light;'); + expect(prerenderedHTML).not.toContain('color-scheme: dark;'); + }); +});