mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
308e272b10
* feat(ui): expand theme system to 49 themes with matched syntax highlighting Add 25 new themes sourced from shiki's bundled TextMate themes (Everforest, Nord, Solarized, GitHub, One Dark Pro, Night Owl, Material, Vitesse, Vesper, Poimandres, Ayu Dark, Houston, Laserwave, Andromeeda, Aurora X, Dark+/Light+, Min, One Light, Plastic, Red, Slack, Snazzy Light, Vitesse Black). Each theme was audited against the shiki source JSON to ensure correct color mapping. Wire pierre/diffs syntax highlighting to match the active UI theme via a SHIKI_THEME_MAP (35 of 49 themes get matched syntax token colors in diffs). Add theme preview mode — a compact bottom-docked picker that lets users browse themes while seeing the page update live behind it. Settings panel enhancements: - Syntax highlighting badge on themes with diff color matching - Mode unavailability dimming (dark-only themes dim in light mode) - Plannotator pinned first, rest alphabetical * fix(themes): address review feedback — contrast, stale CSS, syntax mode safety 1. Material light: foreground #90A4AE → #546E7A (Blue Grey 600) for readable UI text (~7:1 contrast on white). Primary-foreground #FAFAFA → #263238 for contrast on teal buttons. 2. DiffHunkPreview: add colorTheme to useEffect deps so unsafeCSS (bg/gutter injection) recomputes when themes change, preventing stale mixed-theme state in mounted tooltips during preview mode. 3. SHIKI_THEME_MAP: make mode-aware with null entries for modes lacking a shiki counterpart. resolveSyntaxTheme() returns undefined when the current mode has no match, so pierre falls back to its defaults rather than applying dark token colors on a light background (fixes Tokyo Night light). * fix(review): self-review cleanup — memo, double-call, escape key - DiffHunkPreview: extract resolveSyntaxTheme to a variable instead of calling it twice in the spread expression - PierreDiffContent memo: compare syntaxTheme by dark/light string values instead of reference equality (resolver returns new objects each call) - Preview mode: add Escape key handler to return to full settings * fix(ui): consume Escape event in theme preview to prevent leaking to page
142 lines
5.0 KiB
TypeScript
142 lines
5.0 KiB
TypeScript
import React, { useMemo, useState, useEffect } from 'react';
|
|
import { FileDiff } from '@pierre/diffs/react';
|
|
import { getSingularPatch } from '@pierre/diffs';
|
|
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
|
|
import { useReviewState } from '../dock/ReviewStateContext';
|
|
import { resolveSyntaxTheme } from '../hooks/usePierreTheme';
|
|
|
|
interface DiffHunkPreviewProps {
|
|
/** Raw diff hunk string (unified diff format). */
|
|
hunk: string;
|
|
/** Max height in pixels before "Show more" toggle. Default 128. */
|
|
maxHeight?: number;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Build the unsafeCSS string for @pierre/diffs by reading computed CSS variables.
|
|
* Called synchronously so the first render is already themed (no flash on tooltip open).
|
|
*/
|
|
function buildPierreCSS(mode: 'dark' | 'light', fontFamily: string, fontSize: string): string {
|
|
try {
|
|
const styles = getComputedStyle(document.documentElement);
|
|
const bg = styles.getPropertyValue('--background').trim();
|
|
const fg = styles.getPropertyValue('--foreground').trim();
|
|
if (!bg || !fg) return '';
|
|
|
|
const fontCSS = (fontFamily || fontSize) ? `
|
|
pre, code, [data-line-content], [data-column-number] {
|
|
${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''}
|
|
${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''}
|
|
}` : '';
|
|
|
|
return `
|
|
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
|
|
--diffs-bg: ${bg} !important;
|
|
--diffs-fg: ${fg} !important;
|
|
--diffs-dark-bg: ${bg};
|
|
--diffs-light-bg: ${bg};
|
|
--diffs-dark: ${fg};
|
|
--diffs-light: ${fg};
|
|
}
|
|
pre, code { background-color: ${bg} !important; }
|
|
[data-column-number] { background-color: ${bg} !important; }
|
|
[data-file-info] { display: none !important; }
|
|
[data-diffs-header] { display: none !important; }
|
|
${fontCSS}
|
|
`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders a small inline diff hunk using @pierre/diffs.
|
|
* Compact, read-only, no file header. Shares theme + font settings
|
|
* with the main DiffViewer via the same unsafeCSS injection pattern.
|
|
*/
|
|
export const DiffHunkPreview: React.FC<DiffHunkPreviewProps> = ({
|
|
hunk,
|
|
maxHeight = 128,
|
|
className,
|
|
}) => {
|
|
const { resolvedMode, colorTheme } = useTheme();
|
|
const state = useReviewState();
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const fileDiff = useMemo(() => {
|
|
if (!hunk) return undefined;
|
|
try {
|
|
// Robustly handle all three hunk formats the tour agent might produce:
|
|
// 1. Full git diff: starts with "diff --git" — use as-is
|
|
// 2. File-level diff: starts with "--- " — prepend "diff --git" line only
|
|
// 3. Bare hunk: starts with "@@ " — prepend full synthetic headers
|
|
const patch = hunk.startsWith('diff --git')
|
|
? hunk
|
|
: hunk.startsWith('--- ')
|
|
? `diff --git a/file b/file\n${hunk}`
|
|
: `diff --git a/file b/file\n--- a/file\n+++ b/file\n${hunk}`;
|
|
return getSingularPatch(patch);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}, [hunk]);
|
|
|
|
// Initialize synchronously so the very first render (inside a tooltip) is already themed.
|
|
// The lazy initializer reads computed CSS variables from the document root.
|
|
const [pierreTheme, setPierreTheme] = useState<{ type: 'dark' | 'light'; css: string }>(() => ({
|
|
type: resolvedMode ?? 'dark',
|
|
css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize),
|
|
}));
|
|
|
|
// Re-compute on theme / font changes
|
|
useEffect(() => {
|
|
const rafId = requestAnimationFrame(() => {
|
|
setPierreTheme({
|
|
type: resolvedMode ?? 'dark',
|
|
css: buildPierreCSS(resolvedMode ?? 'dark', state.fontFamily, state.fontSize),
|
|
});
|
|
});
|
|
return () => cancelAnimationFrame(rafId);
|
|
}, [resolvedMode, colorTheme, state.fontFamily, state.fontSize]);
|
|
|
|
const syntaxTheme = resolveSyntaxTheme(colorTheme, resolvedMode ?? 'dark');
|
|
|
|
if (!fileDiff) {
|
|
return (
|
|
<div className="px-3 py-2 text-[11px] text-muted-foreground/30 italic">
|
|
Diff not available
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`rounded overflow-hidden border border-border/20 ${className ?? ''}`}>
|
|
<div
|
|
className="overflow-hidden"
|
|
style={expanded ? undefined : { maxHeight }}
|
|
>
|
|
<FileDiff
|
|
fileDiff={fileDiff}
|
|
options={{
|
|
themeType: pierreTheme.type,
|
|
unsafeCSS: pierreTheme.css,
|
|
...(syntaxTheme && { theme: syntaxTheme }),
|
|
diffStyle: 'unified',
|
|
disableLineNumbers: true,
|
|
overflow: 'wrap',
|
|
}}
|
|
/>
|
|
</div>
|
|
{!expanded && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
|
|
className="w-full text-[10px] text-muted-foreground hover:text-foreground py-1 bg-muted/20 border-t border-border/20 transition-colors"
|
|
>
|
|
Show full context
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|