diff --git a/apps/diffshub/components/DiffsHubHeader.tsx b/apps/diffshub/components/DiffsHubHeader.tsx index 6b7445b37..0c0a74590 100644 --- a/apps/diffshub/components/DiffsHubHeader.tsx +++ b/apps/diffshub/components/DiffsHubHeader.tsx @@ -20,9 +20,8 @@ import { type ColorMode } from '@pierre/theming'; import Link from 'next/link'; import { type CSSProperties, - type Dispatch, memo, - type SetStateAction, + useCallback, useLayoutEffect, useMemo, useRef, @@ -44,6 +43,11 @@ import { import { Switch } from '@/components/Switch'; import { docsThemeCatalog } from '@/components/themeCatalog'; import { cn } from '@/lib/cn'; +import { + DIFFS_HUB_CODE_FONT_OPTIONS, + type DiffsHubCodeFont, + isDiffsHubDefaultCodeFont, +} from '@/lib/displayPreferences'; import { diffshubChromeMapping } from '@/lib/theme/diffshubChromeMapping'; import { getDropdownThemeStyle } from '@/lib/theme/dropdownChromeStyle'; @@ -55,6 +59,7 @@ const SETTING_ROW_CLASS = interface HeaderProps { className?: string; + codeFont: DiffsHubCodeFont; collapseMode: 'expanded' | 'collapsed'; colorMode: ColorMode; darkThemeName: DarkThemeName; @@ -68,19 +73,21 @@ interface HeaderProps { overflow: 'wrap' | 'scroll'; onToggleCollapseMode(): void; onToggleFileTreeOverlay(): void; + setCodeFont(value: DiffsHubCodeFont): void; setColorMode(mode: ColorMode): void; setDarkThemeName(name: DarkThemeName): void; - setDiffIndicators: Dispatch>; - setDiffStyle: Dispatch>; + setDiffIndicators(value: DiffIndicators): void; + setDiffStyle(value: 'split' | 'unified'): void; setLightThemeName(name: LightThemeName): void; - setLineNumbers: Dispatch>; - setOverflow: Dispatch>; - setShowBackgrounds: Dispatch>; + setLineNumbers(value: boolean): void; + setOverflow(value: 'wrap' | 'scroll'): void; + setShowBackgrounds(value: boolean): void; showBackgrounds: boolean; } export const DiffsHubHeader = memo(function DiffsHubHeader({ className, + codeFont, collapseMode, colorMode, darkThemeName, @@ -94,6 +101,7 @@ export const DiffsHubHeader = memo(function DiffsHubHeader({ overflow, onToggleCollapseMode, onToggleFileTreeOverlay, + setCodeFont, setColorMode, setDarkThemeName, setDiffIndicators, @@ -105,6 +113,20 @@ export const DiffsHubHeader = memo(function DiffsHubHeader({ showBackgrounds, }: HeaderProps) { const [currentUrl, setCurrentUrl] = useState(initialUrl); + const codeFontSelection = codeFont.kind === 'default' ? 'default' : 'custom'; + const customCodeFontFamily = + codeFont.kind === 'default' ? '' : codeFont.input; + const focusCustomCodeFontInput = useCallback( + (input: HTMLInputElement | null) => { + if (input == null || document.activeElement === input) { + return; + } + + input.focus({ preventScroll: true }); + input.setSelectionRange(input.value.length, input.value.length); + }, + [] + ); // Only show the external-link button when the input still reflects the // committed URL — otherwise we'd be pointing at a draft the user is editing. const showExternalLink = currentUrl === initialUrl; @@ -238,7 +260,7 @@ export const DiffsHubHeader = memo(function DiffsHubHeader({ +
+
+ Code font + { + if (isDiffsHubDefaultCodeFont(value)) { + setCodeFont({ + kind: 'default', + }); + return; + } + + if (value === 'custom') { + setCodeFont( + codeFont.kind !== 'default' + ? codeFont + : { + input: '', + kind: 'custom', + } + ); + } + }} + > + {DIFFS_HUB_CODE_FONT_OPTIONS.map((option) => ( + + {option.label} + + ))} + + Custom + + +
+ {codeFont.kind !== 'default' && ( + { + setCodeFont({ + input: currentTarget.value, + kind: 'custom', + }); + }} + onKeyDown={(event) => event.stopPropagation()} + /> + )} +
e.preventDefault()} diff --git a/apps/diffshub/components/DiffsHubViewer.tsx b/apps/diffshub/components/DiffsHubViewer.tsx index 218673f81..c9f5be6e2 100644 --- a/apps/diffshub/components/DiffsHubViewer.tsx +++ b/apps/diffshub/components/DiffsHubViewer.tsx @@ -12,7 +12,14 @@ import { } from '@pierre/diffs'; import { type CodeViewHandle, useStableCallback } from '@pierre/diffs/react'; import { IconChevronSm } from '@pierre/icons'; -import { memo, type RefObject, useMemo, useRef, useState } from 'react'; +import { + type CSSProperties, + memo, + type RefObject, + useMemo, + useRef, + useState, +} from 'react'; import { DraftAnnotation } from './DraftAnnotation'; import { ExampleAnnotation } from './ExampleAnnotation'; @@ -63,6 +70,7 @@ interface ActiveDraftComment { interface DiffsHubViewerProps { className?: string; + codeFontFamily: string; diffStyle: 'split' | 'unified'; onCommentDeleted(comment: DiffsHubDeletedCommentEvent): void; onCommentSaved(comment: DiffsHubSavedCommentEvent): void; @@ -80,6 +88,7 @@ interface DiffsHubViewerProps { export const DiffsHubViewer = memo(function DiffsHubViewer({ className, + codeFontFamily, diffStyle, onCommentDeleted, onCommentSaved, @@ -107,6 +116,14 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ () => buildAnnotationThemeStyle(themeChromeStyle), [themeChromeStyle] ); + const viewerStyle = useMemo( + () => + ({ + ...(annotationThemeStyle ?? {}), + '--diffs-font-family': codeFontFamily, + }) satisfies CSSProperties & { '--diffs-font-family': string }, + [annotationThemeStyle, codeFontFamily] + ); const handleSetSelection = useStableCallback( (selection: CodeViewLineSelection | null) => { @@ -471,7 +488,7 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ 'cv-scrollbar relative h-full min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-clip overscroll-contain border-b border-border w-full [contain:strict] [overflow-anchor:none] [will-change:scroll-position] md:border-b-0 [&_diffs-container]:overflow-clip [&_diffs-container]:[contain:layout_paint_style] [&_diffs-container]:shadow-[0_-1px_0_var(--diffshub-diff-separator,var(--color-border-opaque)),0_1px_0_var(--diffshub-diff-separator,var(--color-border-opaque))]' )} options={options} - style={annotationThemeStyle} + style={viewerStyle} selectedLines={selectedLines} onSelectedLinesChange={handleSetSelection} renderAnnotation={renderCommentAnnotation} diff --git a/apps/diffshub/components/ReviewUI.tsx b/apps/diffshub/components/ReviewUI.tsx index 49d586e2e..1db36d77f 100644 --- a/apps/diffshub/components/ReviewUI.tsx +++ b/apps/diffshub/components/ReviewUI.tsx @@ -1,6 +1,5 @@ 'use client'; -import { type DiffIndicators } from '@pierre/diffs'; import { type CodeViewHandle, useWorkerPool } from '@pierre/diffs/react'; import { type ColorMode } from '@pierre/theming'; import { useThemeController } from '@pierre/theming/react'; @@ -24,6 +23,10 @@ import { themeController, } from '@/components/themeController'; import { preloadAvatars } from '@/lib/annotation'; +import { + getDiffsHubCodeFontFamily, + useDiffsHubDisplayPreferences, +} from '@/lib/displayPreferences'; import { removeSavedCommentSidebarEntry } from '@/lib/removeSavedCommentSidebarEntry'; import type { DarkThemeName, LightThemeName } from '@/lib/themeNames'; import type { @@ -54,15 +57,31 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { useEffect(preloadAvatars, []); const isWorkerPoolReadyOrDisable = useIsWorkerPoolReadyOrDisabled(); - const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>('split'); - const [collapseMode, setCollapseMode] = useState<'expanded' | 'collapsed'>( - 'expanded' - ); const [fileTreeOverlayOpen, setFileTreeOverlayOpen] = useState(false); - const [overflow, setOverflow] = useState<'wrap' | 'scroll'>('scroll'); - const [showBackgrounds, setShowBackgrounds] = useState(true); - const [diffIndicators, setDiffIndicators] = useState('bars'); - const [lineNumbers, setLineNumbers] = useState(true); + const [forceUnifiedDiffStyle, setForceUnifiedDiffStyle] = useState(false); + const { + displayPreferences, + displayPreferencesHydrated, + setCodeFont, + setDiffIndicators, + setDiffStyle, + setLineNumbers, + setOverflow, + setShowBackgrounds, + updateDisplayPreferences, + } = useDiffsHubDisplayPreferences(); + const { + collapseMode, + codeFont, + diffIndicators, + lineNumbers, + overflow, + showBackgrounds, + } = displayPreferences; + const diffStyle = forceUnifiedDiffStyle + ? 'unified' + : displayPreferences.diffStyle; + const codeFontFamily = getDiffsHubCodeFontFamily(codeFont); // All theming state — color mode and the light/dark theme-name picks — lives // in the single @pierre/theming controller (the same instance the app-wide // ThemeProvider is bound to). Reading it here means picking Auto/Light/Dark @@ -137,6 +156,7 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { } = usePatchLoader({ collapseMode, domain, + enabled: displayPreferencesHydrated, onLoadStart: handlePatchLoadStart, path, viewerRef, @@ -145,7 +165,7 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { useEffect(() => { const mediaQuery = window.matchMedia('(max-width: 767px)'); const updateMobileState = (matches: boolean) => { - setDiffStyle(matches ? 'unified' : 'split'); + setForceUnifiedDiffStyle(matches); if (!matches) setFileTreeOverlayOpen(false); }; const handleChange = (event: MediaQueryListEvent) => { @@ -177,9 +197,12 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { }, []); const handleToggleCollapseMode = useCallback(() => { const next = collapseMode === 'expanded' ? 'collapsed' : 'expanded'; - setCollapseMode(next); + updateDisplayPreferences((previous) => ({ + ...previous, + collapseMode: next, + })); applyCollapseModeToLoaded(next); - }, [applyCollapseModeToLoaded, collapseMode]); + }, [applyCollapseModeToLoaded, collapseMode, updateDisplayPreferences]); const handleCommentSaved = useCallback( (comment: DiffsHubSavedCommentEvent) => { setCommentSections((prev) => @@ -227,6 +250,7 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { // first batch of files against the wrong palette. const viewerAvailable = isWorkerPoolReadyOrDisable && + displayPreferencesHydrated && themesHydrated && (loadState === 'ready' || (loadState === 'streaming' && initialItems.length > 0)); @@ -235,6 +259,7 @@ function ReviewUIInner({ domain, initialUrl, path }: ReviewUIProps) { | null>; @@ -80,6 +81,7 @@ interface UsePatchLoaderResult { export function usePatchLoader({ collapseMode, domain, + enabled, onLoadStart, path, viewerRef, @@ -212,6 +214,10 @@ export function usePatchLoader({ ); useEffect(() => { + if (!enabled) { + return; + } + const patchRequestKey = domain == null || domain === '' ? path : `${domain}${path}`; const patchSearchParams = new URLSearchParams({ path }); @@ -488,6 +494,7 @@ export function usePatchLoader({ }; }, [ domain, + enabled, loadAttempt, onLoadStart, path, diff --git a/apps/diffshub/lib/browserStorage.ts b/apps/diffshub/lib/browserStorage.ts new file mode 100644 index 000000000..b5976965c --- /dev/null +++ b/apps/diffshub/lib/browserStorage.ts @@ -0,0 +1,16 @@ +export function readBrowserStorageKey(key: string): string | null { + try { + return globalThis.localStorage?.getItem(key) ?? null; + } catch { + return null; + } +} + +export function writeBrowserStorageKey(key: string, value: string): void { + try { + globalThis.localStorage?.setItem(key, value); + } catch { + // Storage may be unavailable (private mode / denied) or full. Callers keep + // their in-memory state, so persistence failure is non-fatal. + } +} diff --git a/apps/diffshub/lib/displayPreferences.ts b/apps/diffshub/lib/displayPreferences.ts new file mode 100644 index 000000000..c431143bb --- /dev/null +++ b/apps/diffshub/lib/displayPreferences.ts @@ -0,0 +1,573 @@ +import type { DiffIndicators } from '@pierre/diffs'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + readBrowserStorageKey, + writeBrowserStorageKey, +} from './browserStorage'; + +export type DiffsHubCollapseMode = 'expanded' | 'collapsed'; +export type DiffsHubCodeFont = + | { + kind: 'default'; + } + | { + input: string; + kind: 'custom'; + }; +export type DiffsHubDiffStyle = 'split' | 'unified'; +export type DiffsHubOverflow = 'wrap' | 'scroll'; + +export interface DiffsHubCodeFontOption { + fontFamily: string; + label: string; + value: 'default'; +} + +export interface DiffsHubDisplayPreferences { + collapseMode: DiffsHubCollapseMode; + codeFont: DiffsHubCodeFont; + diffIndicators: DiffIndicators; + diffStyle: DiffsHubDiffStyle; + lineNumbers: boolean; + overflow: DiffsHubOverflow; + showBackgrounds: boolean; +} + +export const DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES = { + collapseMode: 'expanded', + codeFont: { + kind: 'default', + }, + diffIndicators: 'bars', + diffStyle: 'split', + lineNumbers: true, + overflow: 'scroll', + showBackgrounds: true, +} satisfies DiffsHubDisplayPreferences; + +const DEFAULT_CODE_FONT_FAMILY = 'var(--font-berkeley-mono)'; +const SYSTEM_CODE_FONT_FAMILY = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; +const CUSTOM_CODE_FONT_FALLBACK = `${DEFAULT_CODE_FONT_FAMILY}, ${SYSTEM_CODE_FONT_FAMILY}`; +const MAX_CUSTOM_CODE_FONT_LENGTH = 80; +const SYSTEM_MONO_ALIASES = [ + 'monospace', + 'system', + 'system mono', + 'system monospace', + 'ui monospace', +] satisfies readonly string[]; +const SYSTEM_MONO_ALIAS_KEYS = new Set( + SYSTEM_MONO_ALIASES.flatMap((alias) => [ + normalizeFontQuery(alias), + compactFontKey(alias), + ]) +); + +interface KnownInstalledCodeFont { + aliases: readonly string[]; + family: string; +} + +const KNOWN_INSTALLED_CODE_FONTS = [ + { + aliases: [ + 'jetbrains', + 'jetbrains mono', + 'jetbrains-mono', + 'jetbrainsmono', + 'jb mono', + 'jbmono', + ], + family: 'JetBrains Mono', + }, + { + aliases: ['fira', 'fira code', 'fira-code', 'firacode'], + family: 'Fira Code', + }, + { + aliases: ['cascadia', 'cascadia code', 'cascadia-code', 'cascadiacode'], + family: 'Cascadia Code', + }, + { + aliases: [ + 'ibm plex', + 'ibm plex mono', + 'ibmplexmono', + 'plex mono', + 'plexmono', + ], + family: 'IBM Plex Mono', + }, + { + aliases: [ + 'adobe source code pro', + 'source code pro', + 'source-code-pro', + 'sourcecodepro', + ], + family: 'Source Code Pro', + }, + { + aliases: ['roboto mono', 'roboto-mono', 'robotomono'], + family: 'Roboto Mono', + }, + { + aliases: ['sf mono', 'sf-mono', 'sfmono'], + family: 'SF Mono', + }, +] satisfies readonly KnownInstalledCodeFont[]; +const FONT_ALIAS_INDEX = buildFontAliasIndex(KNOWN_INSTALLED_CODE_FONTS); + +export const DIFFS_HUB_CODE_FONT_OPTIONS = [ + { + fontFamily: DEFAULT_CODE_FONT_FAMILY, + label: 'Default', + value: 'default', + }, +] satisfies readonly DiffsHubCodeFontOption[]; + +const COLLAPSE_MODE_VALUES = [ + 'expanded', + 'collapsed', +] satisfies readonly DiffsHubCollapseMode[]; +const DIFF_INDICATOR_VALUES = [ + 'bars', + 'classic', + 'none', +] satisfies readonly DiffIndicators[]; +const DIFF_STYLE_VALUES = [ + 'split', + 'unified', +] satisfies readonly DiffsHubDiffStyle[]; +const OVERFLOW_VALUES = [ + 'wrap', + 'scroll', +] satisfies readonly DiffsHubOverflow[]; + +const DISPLAY_PREFERENCES_STORAGE_KEY = 'diffshub.displayPreferences.v1'; +const DISPLAY_PREFERENCES_STORAGE_VERSION = 1; + +interface StoredDisplayPreferences { + preferences: DiffsHubDisplayPreferences; + version: typeof DISPLAY_PREFERENCES_STORAGE_VERSION; +} + +interface UseDiffsHubDisplayPreferencesResult { + displayPreferences: DiffsHubDisplayPreferences; + displayPreferencesHydrated: boolean; + setCodeFont(value: DiffsHubDisplayPreferences['codeFont']): void; + setDiffIndicators(value: DiffIndicators): void; + setDiffStyle(value: DiffsHubDiffStyle): void; + setLineNumbers(value: boolean): void; + setOverflow(value: DiffsHubOverflow): void; + setShowBackgrounds(value: boolean): void; + updateDisplayPreferences( + update: (previous: DiffsHubDisplayPreferences) => DiffsHubDisplayPreferences + ): void; +} + +type ResolvedCodeFontInput = + | { + kind: 'system'; + } + | { + family: string; + kind: 'custom'; + }; + +export function useDiffsHubDisplayPreferences(): UseDiffsHubDisplayPreferencesResult { + const [displayPreferences, setDisplayPreferences] = + useState(DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES); + const displayPreferencesRef = useRef( + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES + ); + const [displayPreferencesHydrated, setDisplayPreferencesHydrated] = + useState(false); + + useEffect(() => { + const storedPreferences = readDiffsHubDisplayPreferences(); + displayPreferencesRef.current = storedPreferences; + setDisplayPreferences(storedPreferences); + setDisplayPreferencesHydrated(true); + }, []); + + const updateDisplayPreferences = useCallback( + ( + update: ( + previous: DiffsHubDisplayPreferences + ) => DiffsHubDisplayPreferences + ) => { + const next = update(displayPreferencesRef.current); + displayPreferencesRef.current = next; + setDisplayPreferences(next); + writeDiffsHubDisplayPreferences(next); + }, + [] + ); + const updateDisplayPreference = useCallback( + ( + key: Key, + value: DiffsHubDisplayPreferences[Key] + ) => { + updateDisplayPreferences((previous) => ({ + ...previous, + [key]: value, + })); + }, + [updateDisplayPreferences] + ); + const setCodeFont = useCallback( + (value: DiffsHubDisplayPreferences['codeFont']) => { + updateDisplayPreference('codeFont', value); + }, + [updateDisplayPreference] + ); + const setDiffIndicators = useCallback( + (value: DiffIndicators) => { + updateDisplayPreference('diffIndicators', value); + }, + [updateDisplayPreference] + ); + const setDiffStyle = useCallback( + (value: DiffsHubDiffStyle) => { + updateDisplayPreference('diffStyle', value); + }, + [updateDisplayPreference] + ); + const setLineNumbers = useCallback( + (value: boolean) => { + updateDisplayPreference('lineNumbers', value); + }, + [updateDisplayPreference] + ); + const setOverflow = useCallback( + (value: DiffsHubOverflow) => { + updateDisplayPreference('overflow', value); + }, + [updateDisplayPreference] + ); + const setShowBackgrounds = useCallback( + (value: boolean) => { + updateDisplayPreference('showBackgrounds', value); + }, + [updateDisplayPreference] + ); + + return { + displayPreferences, + displayPreferencesHydrated, + setCodeFont, + setDiffIndicators, + setDiffStyle, + setLineNumbers, + setOverflow, + setShowBackgrounds, + updateDisplayPreferences, + }; +} + +export function readDiffsHubDisplayPreferences(): DiffsHubDisplayPreferences { + const rawValue = readBrowserStorageKey(DISPLAY_PREFERENCES_STORAGE_KEY); + if (rawValue == null) { + return DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES; + } + + let parsedValue: unknown; + try { + parsedValue = JSON.parse(rawValue); + } catch { + return DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES; + } + + return parseStoredDisplayPreferences(parsedValue); +} + +export function writeDiffsHubDisplayPreferences( + preferences: DiffsHubDisplayPreferences +): void { + const storedPreferences = { + preferences, + version: DISPLAY_PREFERENCES_STORAGE_VERSION, + } satisfies StoredDisplayPreferences; + + writeBrowserStorageKey( + DISPLAY_PREFERENCES_STORAGE_KEY, + JSON.stringify(storedPreferences) + ); +} + +export function getDiffsHubCodeFontFamily(font: DiffsHubCodeFont): string { + switch (font.kind) { + case 'default': + return DEFAULT_CODE_FONT_FAMILY; + case 'custom': + return ( + getCustomCodeFontFamily(font.input) ?? + getDiffsHubCodeFontFamily( + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.codeFont + ) + ); + } +} + +export function getCustomCodeFontFamily(family: string): string | null { + const resolvedFont = resolveCodeFontInput(family); + if (resolvedFont == null) { + return null; + } + + return resolvedFont.kind === 'custom' + ? getCustomCodeFontFamilyName(resolvedFont.family) + : SYSTEM_CODE_FONT_FAMILY; +} + +function resolveCodeFontInput(input: string): ResolvedCodeFontInput | null { + const cleanedInput = cleanCustomCodeFontFamilyInput(input); + if (cleanedInput == null) { + return null; + } + + const normalizedInput = normalizeFontQuery(cleanedInput); + const compactInput = compactFontKey(cleanedInput); + if ( + SYSTEM_MONO_ALIAS_KEYS.has(normalizedInput) || + SYSTEM_MONO_ALIAS_KEYS.has(compactInput) + ) { + return { + kind: 'system', + }; + } + + const knownFont = + FONT_ALIAS_INDEX.get(normalizedInput) ?? FONT_ALIAS_INDEX.get(compactInput); + return { + family: knownFont?.family ?? cleanedInput, + kind: 'custom', + }; +} + +export function isDiffsHubDefaultCodeFont(value: string): value is 'default' { + return value === 'default'; +} + +function parseStoredDisplayPreferences( + value: unknown +): DiffsHubDisplayPreferences { + if ( + getObjectProperty(value, 'version') !== DISPLAY_PREFERENCES_STORAGE_VERSION + ) { + return DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES; + } + + return parseDisplayPreferences(getObjectProperty(value, 'preferences')); +} + +function parseDisplayPreferences(value: unknown): DiffsHubDisplayPreferences { + return { + collapseMode: parseStringChoice( + getObjectProperty(value, 'collapseMode'), + COLLAPSE_MODE_VALUES, + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.collapseMode + ), + codeFont: parseCodeFont( + getObjectProperty(value, 'codeFont'), + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.codeFont + ), + diffIndicators: parseStringChoice( + getObjectProperty(value, 'diffIndicators'), + DIFF_INDICATOR_VALUES, + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.diffIndicators + ), + diffStyle: parseStringChoice( + getObjectProperty(value, 'diffStyle'), + DIFF_STYLE_VALUES, + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.diffStyle + ), + lineNumbers: parseBoolean( + getObjectProperty(value, 'lineNumbers'), + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.lineNumbers + ), + overflow: parseStringChoice( + getObjectProperty(value, 'overflow'), + OVERFLOW_VALUES, + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.overflow + ), + showBackgrounds: parseBoolean( + getObjectProperty(value, 'showBackgrounds'), + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.showBackgrounds + ), + }; +} + +function parseStringChoice( + value: unknown, + choices: readonly Value[], + fallback: Value +): Value { + if (typeof value !== 'string') { + return fallback; + } + + for (const choice of choices) { + if (choice === value) { + return choice; + } + } + + return fallback; +} + +function parseCodeFont( + value: unknown, + fallback: DiffsHubCodeFont +): DiffsHubCodeFont { + const kind = getObjectProperty(value, 'kind'); + if (kind === 'default') { + return { + kind: 'default', + }; + } + + if (kind === 'preset') { + const presetValue = getObjectProperty(value, 'value'); + return presetValue === 'default' ? { kind: 'default' } : fallback; + } + + if (kind === 'custom') { + const customInput = + cleanCustomCodeFontFamilyInput(getObjectProperty(value, 'input')) ?? + cleanCustomCodeFontFamilyInput(getObjectProperty(value, 'family')); + if (customInput != null) { + return { + input: customInput, + kind: 'custom', + }; + } + } + + return fallback; +} + +function parseBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function getObjectProperty(value: unknown, property: string): unknown { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + return Object.getOwnPropertyDescriptor(value, property)?.value; +} + +function cleanCustomCodeFontFamilyInput(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const unquotedValue = removeWrappingQuotes( + removeControlCharacters(value.normalize('NFKC')).trim() + ); + const cleanedValue = unquotedValue.replaceAll(/\s+/g, ' ').trim(); + + if ( + cleanedValue.length === 0 || + cleanedValue.length > MAX_CUSTOM_CODE_FONT_LENGTH || + cleanedValue.includes(',') + ) { + return null; + } + + return cleanedValue; +} + +function getCustomCodeFontFamilyName(family: string): string | null { + const fontFamilyName = cleanCustomCodeFontFamilyInput(family); + if (fontFamilyName == null) { + return null; + } + + return `${quoteCSSString(fontFamilyName)}, ${CUSTOM_CODE_FONT_FALLBACK}`; +} + +function buildFontAliasIndex( + fonts: readonly KnownInstalledCodeFont[] +): ReadonlyMap { + const index = new Map(); + for (const font of fonts) { + indexFontAlias(index, font, font.family); + for (const alias of font.aliases) { + indexFontAlias(index, font, alias); + } + } + return index; +} + +function indexFontAlias( + index: Map, + font: KnownInstalledCodeFont, + alias: string +): void { + for (const key of [normalizeFontQuery(alias), compactFontKey(alias)]) { + if (key.length === 0) { + continue; + } + + const existing = index.get(key); + if (existing != null && existing.family !== font.family) { + throw new Error( + `Font alias collision for "${key}": "${existing.family}" and "${font.family}"` + ); + } + index.set(key, font); + } +} + +function normalizeFontQuery(value: string): string { + return value + .normalize('NFKC') + .replaceAll(/([a-z])([A-Z])/g, '$1 $2') + .toLowerCase() + .replaceAll(/['’]/g, '') + .replaceAll(/[^a-z0-9]+/g, ' ') + .trim() + .replaceAll(/\s+/g, ' '); +} + +function compactFontKey(value: string): string { + return normalizeFontQuery(value).replaceAll(/\s+/g, ''); +} + +function removeWrappingQuotes(value: string): string { + if (value.length < 2) { + return value; + } + + const firstCharacter = value[0]; + const lastCharacter = value.at(-1); + if ( + (firstCharacter === '"' && lastCharacter === '"') || + (firstCharacter === "'" && lastCharacter === "'") + ) { + return value.slice(1, -1); + } + + return value; +} + +function quoteCSSString(value: string): string { + return `"${value.replaceAll(/["\\]/g, '\\$&')}"`; +} + +function removeControlCharacters(value: string): string { + let result = ''; + for (const character of value) { + const code = character.charCodeAt(0); + if (code > 0x1f && code !== 0x7f) { + result += character; + } + } + return result; +} diff --git a/apps/diffshub/lib/test/displayPreferences.test.ts b/apps/diffshub/lib/test/displayPreferences.test.ts new file mode 100644 index 000000000..08c2d8de6 --- /dev/null +++ b/apps/diffshub/lib/test/displayPreferences.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from 'bun:test'; + +import { + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES, + type DiffsHubDisplayPreferences, + getCustomCodeFontFamily, + getDiffsHubCodeFontFamily, + readDiffsHubDisplayPreferences, + writeDiffsHubDisplayPreferences, +} from '../displayPreferences'; + +const STORAGE_KEY = 'diffshub.displayPreferences.v1'; +const SYSTEM_CODE_FONT_FAMILY = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; +const CUSTOM_CODE_FONT_FALLBACK = `var(--font-berkeley-mono), ${SYSTEM_CODE_FONT_FAMILY}`; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +class ThrowingStorage extends MemoryStorage { + override getItem(): string | null { + throw new Error('storage unavailable'); + } + + override setItem(): void { + throw new Error('storage unavailable'); + } +} + +describe('DiffsHub display preferences', () => { + test('falls back to defaults when storage is empty or unavailable', () => { + withLocalStorage(new MemoryStorage(), () => { + expect(readDiffsHubDisplayPreferences()).toEqual( + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES + ); + }); + withLocalStorage(new ThrowingStorage(), () => { + expect(readDiffsHubDisplayPreferences()).toEqual( + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES + ); + }); + }); + + test('round-trips validated display preferences', () => { + const storage = new MemoryStorage(); + const preferences: DiffsHubDisplayPreferences = { + collapseMode: 'collapsed', + codeFont: { + input: 'jetbrains', + kind: 'custom', + }, + diffIndicators: 'classic', + diffStyle: 'unified', + lineNumbers: false, + overflow: 'wrap', + showBackgrounds: false, + }; + + withLocalStorage(storage, () => { + writeDiffsHubDisplayPreferences(preferences); + + expect(readDiffsHubDisplayPreferences()).toEqual(preferences); + }); + }); + + test('ignores malformed stored preferences per field', () => { + const storage = new MemoryStorage(); + storage.setItem( + STORAGE_KEY, + JSON.stringify({ + preferences: { + collapseMode: 'closed', + codeFont: { + kind: 'legacy', + value: 'system-mono', + }, + diffIndicators: 'classic', + diffStyle: 'side-by-side', + lineNumbers: false, + overflow: 'wrap', + showBackgrounds: 'no', + }, + version: 1, + }) + ); + + withLocalStorage(storage, () => { + expect(readDiffsHubDisplayPreferences()).toEqual({ + ...DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES, + diffIndicators: 'classic', + lineNumbers: false, + overflow: 'wrap', + }); + }); + }); + + test('ignores incompatible storage versions', () => { + const storage = new MemoryStorage(); + storage.setItem( + STORAGE_KEY, + JSON.stringify({ + preferences: { + collapseMode: 'collapsed', + codeFont: { + input: 'system', + kind: 'custom', + }, + diffIndicators: 'none', + diffStyle: 'unified', + lineNumbers: false, + overflow: 'wrap', + showBackgrounds: false, + }, + version: 2, + }) + ); + + withLocalStorage(storage, () => { + expect(readDiffsHubDisplayPreferences()).toEqual( + DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES + ); + }); + }); + + test('formats custom installed font names as a quoted font-family fallback stack', () => { + for (const input of [ + 'jetbrains', + 'jetbrainsmono', + 'jetbrains mono', + 'jetbrains-mono', + 'JetBrainsMono', + 'JETBRAINS MONO', + ]) { + expect(getCustomCodeFontFamily(input)).toBe( + `"JetBrains Mono", ${CUSTOM_CODE_FONT_FALLBACK}` + ); + } + + expect(getCustomCodeFontFamily('Vendor "Mono" \\ Preview\n')).toBe( + `"Vendor \\"Mono\\" \\\\ Preview", ${CUSTOM_CODE_FONT_FALLBACK}` + ); + + expect(getCustomCodeFontFamily('sourcecodepro')).toBe( + `"Source Code Pro", ${CUSTOM_CODE_FONT_FALLBACK}` + ); + + for (const input of [ + 'system', + 'monospace', + 'system mono', + 'system monospace', + 'ui monospace', + ]) { + expect(getCustomCodeFontFamily(input)).toBe(SYSTEM_CODE_FONT_FAMILY); + } + + expect( + getDiffsHubCodeFontFamily({ + input: 'Commit Mono', + kind: 'custom', + }) + ).toBe(`"Commit Mono", ${CUSTOM_CODE_FONT_FALLBACK}`); + + expect( + getDiffsHubCodeFontFamily({ + input: 'monospace', + kind: 'custom', + }) + ).toBe(SYSTEM_CODE_FONT_FAMILY); + + expect(getCustomCodeFontFamily('JetBrains Mono, serif')).toBeNull(); + expect(getCustomCodeFontFamily('x'.repeat(81))).toBeNull(); + + expect( + getDiffsHubCodeFontFamily({ + input: 'JetBrains Mono, serif', + kind: 'custom', + }) + ).toBe( + getDiffsHubCodeFontFamily(DEFAULT_DIFFS_HUB_DISPLAY_PREFERENCES.codeFont) + ); + + expect(getCustomCodeFontFamily(' ')).toBeNull(); + }); +}); + +function withLocalStorage(storage: Storage, callback: () => void): void { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'localStorage' + ); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }); + + try { + callback(); + } finally { + if (descriptor == null) { + Reflect.deleteProperty(globalThis, 'localStorage'); + } else { + Object.defineProperty(globalThis, 'localStorage', descriptor); + } + } +} diff --git a/apps/diffshub/lib/test/reviewDisplayPreferences.test.tsx b/apps/diffshub/lib/test/reviewDisplayPreferences.test.tsx new file mode 100644 index 000000000..6debe644b --- /dev/null +++ b/apps/diffshub/lib/test/reviewDisplayPreferences.test.tsx @@ -0,0 +1,656 @@ +/** @jsxImportSource react */ + +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from 'bun:test'; +import { JSDOM, VirtualConsole } from 'jsdom'; +import { + AppRouterContext, + type AppRouterInstance, +} from 'next/dist/shared/lib/app-router-context.shared-runtime'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +const DISPLAY_PREFERENCES_STORAGE_KEY = 'diffshub.displayPreferences.v1'; +const MOBILE_MEDIA_QUERY = '(max-width: 767px)'; +const PATCH_TEXT = `diff --git a/src/example.ts b/src/example.ts +index 1111111..2222222 100644 +--- a/src/example.ts ++++ b/src/example.ts +@@ -1,3 +1,3 @@ + export function example() { +- return 1; ++ return 2; + } +`; + +const originalGlobals = { + CSSStyleSheet: Reflect.get(globalThis, 'CSSStyleSheet'), + CustomEvent: Reflect.get(globalThis, 'CustomEvent'), + Element: Reflect.get(globalThis, 'Element'), + Event: Reflect.get(globalThis, 'Event'), + Image: Reflect.get(globalThis, 'Image'), + cancelAnimationFrame: Reflect.get(globalThis, 'cancelAnimationFrame'), + customElements: Reflect.get(globalThis, 'customElements'), + document: Reflect.get(globalThis, 'document'), + fetch: Reflect.get(globalThis, 'fetch'), + getComputedStyle: Reflect.get(globalThis, 'getComputedStyle'), + HTMLButtonElement: Reflect.get(globalThis, 'HTMLButtonElement'), + HTMLDivElement: Reflect.get(globalThis, 'HTMLDivElement'), + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + HTMLInputElement: Reflect.get(globalThis, 'HTMLInputElement'), + HTMLStyleElement: Reflect.get(globalThis, 'HTMLStyleElement'), + HTMLTemplateElement: Reflect.get(globalThis, 'HTMLTemplateElement'), + IntersectionObserver: Reflect.get(globalThis, 'IntersectionObserver'), + IS_REACT_ACT_ENVIRONMENT: Reflect.get( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }, + 'IS_REACT_ACT_ENVIRONMENT' + ), + localStorage: Reflect.get(globalThis, 'localStorage'), + matchMedia: Reflect.get(globalThis, 'matchMedia'), + MutationObserver: Reflect.get(globalThis, 'MutationObserver'), + navigator: Reflect.get(globalThis, 'navigator'), + Node: Reflect.get(globalThis, 'Node'), + requestAnimationFrame: Reflect.get(globalThis, 'requestAnimationFrame'), + ResizeObserver: Reflect.get(globalThis, 'ResizeObserver'), + ShadowRoot: Reflect.get(globalThis, 'ShadowRoot'), + SVGElement: Reflect.get(globalThis, 'SVGElement'), + window: Reflect.get(globalThis, 'window'), +}; + +const virtualConsole = new VirtualConsole(); +virtualConsole.on('jsdomError', (error) => { + if ('type' in error && error.type === 'css parsing') { + return; + } + + console.error(error); +}); + +const dom = new JSDOM('', { + pretendToBeVisual: true, + url: 'http://localhost', + virtualConsole, +}); +const originalHTMLElementPrototype = { + attachEvent: Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'attachEvent' + ), + detachEvent: Object.getOwnPropertyDescriptor( + dom.window.HTMLElement.prototype, + 'detachEvent' + ), +}; + +let mobileMatches = false; +type MediaListener = + | EventListenerOrEventListenerObject + | ((this: MediaQueryList, event: MediaQueryListEvent) => unknown); +let mediaListeners = new Map< + MediaListener, + (event: MediaQueryListEvent) => void +>(); + +class MockResizeObserver { + observe(_target: Element): void {} + unobserve(_target: Element): void {} + disconnect(): void {} +} + +class MockIntersectionObserver { + observe(_target: Element): void {} + unobserve(_target: Element): void {} + disconnect(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } +} + +class MockCSSStyleSheet { + replaceSync(_cssText: string): void {} +} + +function defineHTMLElementEventShim( + property: keyof typeof originalHTMLElementPrototype +): void { + const isAttachEvent = property === 'attachEvent'; + Object.defineProperty(dom.window.HTMLElement.prototype, property, { + configurable: true, + value( + this: HTMLElement, + eventName: string, + listener: EventListenerOrEventListenerObject + ) { + const type = eventName.startsWith('on') ? eventName.slice(2) : eventName; + if (isAttachEvent) { + this.addEventListener(type, listener); + } else { + this.removeEventListener(type, listener); + } + }, + }); +} + +function restoreHTMLElementEventShim( + property: keyof typeof originalHTMLElementPrototype +): void { + const descriptor = originalHTMLElementPrototype[property]; + if (descriptor == null) { + Reflect.deleteProperty(dom.window.HTMLElement.prototype, property); + return; + } + + Object.defineProperty(dom.window.HTMLElement.prototype, property, descriptor); +} + +beforeAll(() => { + Object.assign(globalThis, { + CSSStyleSheet: MockCSSStyleSheet, + CustomEvent: dom.window.CustomEvent, + Element: dom.window.Element, + Event: dom.window.Event, + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + customElements: dom.window.customElements, + document: dom.window.document, + fetch: fetchPatch, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + HTMLButtonElement: dom.window.HTMLButtonElement, + HTMLDivElement: dom.window.HTMLDivElement, + HTMLElement: dom.window.HTMLElement, + HTMLInputElement: dom.window.HTMLInputElement, + HTMLStyleElement: dom.window.HTMLStyleElement, + HTMLTemplateElement: dom.window.HTMLTemplateElement, + Image: dom.window.Image, + IntersectionObserver: MockIntersectionObserver, + localStorage: dom.window.localStorage, + matchMedia, + MutationObserver: dom.window.MutationObserver, + navigator: dom.window.navigator, + Node: dom.window.Node, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + ResizeObserver: MockResizeObserver, + ShadowRoot: dom.window.ShadowRoot, + SVGElement: dom.window.SVGElement, + window: dom.window, + }); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + Object.assign(dom.window, { + CSSStyleSheet: MockCSSStyleSheet, + IntersectionObserver: MockIntersectionObserver, + matchMedia, + ResizeObserver: MockResizeObserver, + }); + defineHTMLElementEventShim('attachEvent'); + defineHTMLElementEventShim('detachEvent'); +}); + +beforeEach(() => { + mediaListeners = new Map(); + mobileMatches = false; + localStorage.clear(); + document.body.textContent = ''; +}); + +afterAll(() => { + for (const [key, value] of Object.entries(originalGlobals)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + restoreHTMLElementEventShim('attachEvent'); + restoreHTMLElementEventShim('detachEvent'); + dom.window.close(); +}); + +describe('ReviewUI display preferences', () => { + test('uses unified view on mobile without overwriting the stored desktop split preference', async () => { + mobileMatches = true; + writeStoredPreferences({ + collapseMode: 'expanded', + diffIndicators: 'bars', + diffStyle: 'split', + lineNumbers: true, + overflow: 'scroll', + showBackgrounds: true, + }); + + const rendered = await renderReviewUI(); + + try { + await waitFor(() => { + expect( + querySelectorDeep(rendered.container, 'pre[data-diff]')?.getAttribute( + 'data-diff-type' + ) + ).toBe('single'); + }); + expect(readStoredDiffStyle()).toBe('split'); + + await act(async () => { + setMobileMatches(false); + await flushReact(); + }); + + await waitFor(() => { + expect( + querySelectorDeep(rendered.container, 'pre[data-diff]')?.getAttribute( + 'data-diff-type' + ) + ).toBe('split'); + }); + + const toggle = await waitForElement( + rendered.container, + 'button[title="Switch to unified view"]' + ); + await act(async () => { + toggle.click(); + await flushReact(); + }); + + await waitFor(() => { + expect(readStoredDiffStyle()).toBe('unified'); + }); + } finally { + await cleanup(rendered); + } + }); + + test('uses stored collapsed mode for initially loaded diff items', async () => { + writeStoredPreferences({ + collapseMode: 'collapsed', + diffIndicators: 'bars', + diffStyle: 'split', + lineNumbers: true, + overflow: 'scroll', + showBackgrounds: true, + }); + + const rendered = await renderReviewUI(); + + try { + await waitFor(() => { + expect( + rendered.container.querySelector('button[aria-label="Expand diff"]') + ).not.toBeNull(); + }); + } finally { + await cleanup(rendered); + } + }); + + test('hydrates, applies, and persists the custom code font preference', async () => { + writeStoredPreferences({ + codeFont: { + input: 'jetbrains', + kind: 'custom', + }, + collapseMode: 'expanded', + diffIndicators: 'bars', + diffStyle: 'split', + lineNumbers: true, + overflow: 'scroll', + showBackgrounds: true, + }); + + const rendered = await renderReviewUI(); + + try { + await waitFor(() => { + expect(readViewerFontFamily(rendered.container)).toBe( + '"JetBrains Mono", var(--font-berkeley-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' + ); + }); + + const settingsButton = await waitForElement( + rendered.container, + 'button[aria-label="Display settings"]' + ); + await act(async () => { + pointerClick(settingsButton); + await flushReact(); + }); + + const customButton = await waitForElement( + document, + 'button[title="custom"]' + ); + await act(async () => { + customButton.click(); + await flushReact(); + }); + + const customInput = await waitForElement( + document, + 'input[placeholder="JetBrains Mono"]' + ); + expect(document.activeElement).toBe(customInput); + + await act(async () => { + setInputValue(customInput, 'sourcecodepro'); + await flushReact(); + }); + + await waitFor(() => { + expect(readStoredCodeFont()).toEqual({ + input: 'sourcecodepro', + kind: 'custom', + }); + expect(readViewerFontFamily(rendered.container)).toBe( + '"Source Code Pro", var(--font-berkeley-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' + ); + }); + + await act(async () => { + setInputValue(customInput, 'monospace'); + await flushReact(); + }); + + await waitFor(() => { + expect(readStoredCodeFont()).toEqual({ + input: 'monospace', + kind: 'custom', + }); + expect(readViewerFontFamily(rendered.container)).toBe( + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' + ); + }); + } finally { + await cleanup(rendered); + } + }); +}); + +interface StoredPreferences { + codeFont?: + | { + kind: 'default'; + } + | { + input: string; + kind: 'custom'; + }; + collapseMode: 'expanded' | 'collapsed'; + diffIndicators: 'bars' | 'classic' | 'none'; + diffStyle: 'split' | 'unified'; + lineNumbers: boolean; + overflow: 'wrap' | 'scroll'; + showBackgrounds: boolean; +} + +interface RenderedReviewUI { + container: HTMLDivElement; + root: Root; +} + +async function renderReviewUI(): Promise { + const container = document.createElement('div'); + document.body.append(container); + const { ReviewUI } = await import('../../components/ReviewUI'); + let root: Root | undefined; + + await act(async () => { + root = createRoot(container); + root.render( + + + + ); + await flushReact(); + }); + + if (root == null) { + throw new Error('ReviewUI root was not created'); + } + return { container, root }; +} + +async function cleanup({ container, root }: RenderedReviewUI): Promise { + await act(async () => { + root.unmount(); + await flushReact(); + }); + container.remove(); +} + +function writeStoredPreferences(preferences: StoredPreferences): void { + localStorage.setItem( + DISPLAY_PREFERENCES_STORAGE_KEY, + JSON.stringify({ preferences, version: 1 }) + ); +} + +function readStoredDiffStyle(): string | undefined { + const rawValue = localStorage.getItem(DISPLAY_PREFERENCES_STORAGE_KEY); + if (rawValue == null) { + return undefined; + } + + return ( + JSON.parse(rawValue) as { + preferences?: { diffStyle?: string }; + } + ).preferences?.diffStyle; +} + +function readStoredCodeFont(): unknown { + const rawValue = localStorage.getItem(DISPLAY_PREFERENCES_STORAGE_KEY); + if (rawValue == null) { + return undefined; + } + + return ( + JSON.parse(rawValue) as { + preferences?: { codeFont?: unknown }; + } + ).preferences?.codeFont; +} + +function readViewerFontFamily(container: ParentNode): string | null { + return ( + container + .querySelector('[style*="--diffs-font-family"]') + ?.style.getPropertyValue('--diffs-font-family') ?? null + ); +} + +function setInputValue(input: HTMLInputElement, value: string): void { + const valueSetter = Object.getOwnPropertyDescriptor( + dom.window.HTMLInputElement.prototype, + 'value' + )?.set; + if (valueSetter == null) { + throw new Error('Expected HTMLInputElement.value setter to exist'); + } + + valueSetter.call(input, value); + const propertyChangeEvent = new dom.window.Event('propertychange', { + bubbles: true, + }); + Object.defineProperty(propertyChangeEvent, 'propertyName', { + value: 'value', + }); + input.dispatchEvent(propertyChangeEvent); + input.dispatchEvent(new dom.window.Event('input', { bubbles: true })); +} + +function fetchPatch(): Promise { + return Promise.resolve({ + body: null, + ok: true, + text: () => Promise.resolve(PATCH_TEXT), + } as Response); +} + +const testRouter: AppRouterInstance = { + back() {}, + forward() {}, + prefetch() {}, + push() {}, + refresh() {}, + replace() {}, +}; + +function matchMedia(query: string): MediaQueryList { + const matches = query === MOBILE_MEDIA_QUERY ? mobileMatches : false; + let mediaQueryList: MediaQueryList; + + mediaQueryList = { + addEventListener( + _type: 'change', + listener: EventListenerOrEventListenerObject | null + ) { + if (listener == null) { + return; + } + mediaListeners.set(listener, (event) => { + if (typeof listener === 'function') { + listener(event); + } else { + listener.handleEvent(event); + } + }); + }, + addListener( + listener: + | ((this: MediaQueryList, event: MediaQueryListEvent) => unknown) + | null + ) { + if (listener == null) { + return; + } + mediaListeners.set(listener, (event) => { + listener.call(mediaQueryList, event); + }); + }, + dispatchEvent() { + return true; + }, + matches, + media: query, + onchange: null, + removeEventListener( + _type: 'change', + listener: EventListenerOrEventListenerObject | null + ) { + if (listener != null) { + mediaListeners.delete(listener); + } + }, + removeListener( + listener: + | ((this: MediaQueryList, event: MediaQueryListEvent) => unknown) + | null + ) { + if (listener != null) { + mediaListeners.delete(listener); + } + }, + } as MediaQueryList; + + return mediaQueryList; +} + +function setMobileMatches(matches: boolean): void { + mobileMatches = matches; + const event = { matches, media: MOBILE_MEDIA_QUERY } as MediaQueryListEvent; + for (const listener of mediaListeners.values()) { + listener(event); + } +} + +async function waitFor(assertion: () => void | Promise): Promise { + const startedAt = Date.now(); + let lastError: unknown; + + while (Date.now() - startedAt < 2_000) { + try { + await assertion(); + return; + } catch (error) { + lastError = error; + } + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushReact(); + }); + } + + throw lastError; +} + +async function waitForElement( + container: ParentNode, + selector: string +): Promise { + let element: ElementType | null = null; + await waitFor(() => { + element = container.querySelector(selector); + expect(element).not.toBeNull(); + }); + if (element == null) { + throw new Error(`Expected to find element matching ${selector}`); + } + return element; +} + +function pointerClick(element: HTMLElement): void { + const pointerDown = new dom.window.MouseEvent('pointerdown', { + bubbles: true, + button: 0, + buttons: 1, + cancelable: true, + }); + Object.defineProperty(pointerDown, 'pointerType', { value: 'mouse' }); + element.dispatchEvent(pointerDown); + element.dispatchEvent( + new dom.window.MouseEvent('pointerup', { + bubbles: true, + button: 0, + cancelable: true, + }) + ); + element.click(); +} + +function querySelectorDeep( + root: ParentNode, + selector: string +): ElementType | null { + const directMatch = root.querySelector(selector); + if (directMatch != null) { + return directMatch; + } + + for (const element of root.querySelectorAll('*')) { + const shadowMatch = + element.shadowRoot == null + ? null + : querySelectorDeep(element.shadowRoot, selector); + if (shadowMatch != null) { + return shadowMatch; + } + } + + return null; +} + +async function flushReact(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}