import * as React from 'react'; import { IThemeManager } from '@jupyterlab/apputils'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import type { TranslationBundle } from '@jupyterlab/translation'; import { undoIcon } from '@jupyterlab/ui-components'; import { FileDiff } from '@pierre/diffs/react'; import { diffAcceptRejectHunk, parseDiffFromFile, type DiffLineAnnotation, type FileContents, type FileDiffMetadata, type SelectedLineRange } from '@pierre/diffs'; import { ImageDiffView, imageDataType } from './imageDiff'; import { buildNotebookDiff, NotebookDiffView, type INotebookDiffResult } from './notebookDiff'; import { resolveDiffTheme } from './diffTheme'; /** * The CSS class added to the diff main-area widget. The selectors that * style the embedded `@pierre/diffs` viewer hang off this class. */ export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget'; /** * `localStorage` key for the user's last-chosen split ratio, persisted so * the preference survives reloads (the library has no built-in way to * remember it). */ const SPLIT_RATIO_STORAGE_KEY = 'xtralab:diff-split-ratio'; /** * Bounds on the left-pane fraction. Keeping a few percent on either side * guarantees both panes remain visible — dragging to 0/100 would hide a * column and is almost never what the user wants. */ const MIN_SPLIT_RATIO = 0.1; const MAX_SPLIT_RATIO = 0.9; const DEFAULT_SPLIT_RATIO = 0.5; /** * CSS injected via the `@pierre/diffs` `unsafeCSS` option. The library * places it inside the shadow root under `@layer unsafe`, which beats * the library's own `@layer base` rule that hardcodes * `grid-template-columns: 1fr 1fr` on the split-mode container. * * The override reads from `--xtralab-split-cols`, a custom property we set * on the host element via the `style` prop. Custom properties inherit * through the shadow DOM boundary, so adjusting it on the host instantly * resizes the columns without re-rendering the diff. */ const SPLIT_RESIZE_CSS = `pre[data-diff-type="split"][data-overflow="scroll"] { grid-template-columns: var(--xtralab-split-cols, 1fr 1fr); }`; function readStoredSplitRatio(): number { try { const raw = window.localStorage.getItem(SPLIT_RATIO_STORAGE_KEY); if (raw === null) { return DEFAULT_SPLIT_RATIO; } const parsed = Number.parseFloat(raw); if ( Number.isFinite(parsed) && parsed >= MIN_SPLIT_RATIO && parsed <= MAX_SPLIT_RATIO ) { return parsed; } } catch { // localStorage may throw in privacy mode or sandboxed contexts. Falling // back to the default is fine — the user can resize again on this run. } return DEFAULT_SPLIT_RATIO; } function writeStoredSplitRatio(ratio: number): void { try { window.localStorage.setItem(SPLIT_RATIO_STORAGE_KEY, ratio.toString()); } catch { // See readStoredSplitRatio — best-effort persistence. } } /** * Layout for textual/code diffs, forwarded to `@pierre/diffs` as its * `diffStyle` option. `split` is the side-by-side old|new view; `unified` * is the single-column inline view (what diffs.com calls "stacked"). */ export type DiffStyle = 'split' | 'unified'; const DIFF_STYLE_STORAGE_KEY = 'xtralab:diff-style'; export function readStoredDiffStyle(): DiffStyle { try { const raw = window.localStorage.getItem(DIFF_STYLE_STORAGE_KEY); if (raw === 'split' || raw === 'unified') { return raw; } } catch { // See readStoredSplitRatio — privacy-mode / sandboxed contexts can throw. } return 'split'; } export function writeStoredDiffStyle(style: DiffStyle): void { try { window.localStorage.setItem(DIFF_STYLE_STORAGE_KEY, style); } catch { // Best-effort. } } /** * View modes available for `.ipynb` diffs. `notebook` is the cell-by-cell * rendered view; `json` is the raw nbformat JSON file diff, for inspecting * exactly which bytes changed. */ export type NotebookDiffViewMode = 'notebook' | 'json'; const NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:notebook-diff-view-mode'; export function readStoredNotebookViewMode(): NotebookDiffViewMode { try { const raw = window.localStorage.getItem( NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY ); if (raw === 'json' || raw === 'notebook') { return raw; } } catch { // See readStoredSplitRatio — privacy-mode / sandboxed contexts can // throw on access. The notebook view is the better default anyway. } return 'notebook'; } export function writeStoredNotebookViewMode(mode: NotebookDiffViewMode): void { try { window.localStorage.setItem(NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY, mode); } catch { // Best-effort. } } /** * Annotation payload threaded through the diff library back into the * `renderAnnotation` callback. Carries the index of the hunk a particular * action button targets. */ interface IHunkActionAnnotation { hunkIndex: number; } /** * Detect notebook files by extension. We only look at the suffix so the * caller doesn't need to plumb a content-type field through; mistaking * another `.ipynb`-named file for a notebook is harmless because * {@link buildNotebookDiff} validates the JSON shape and we fall back to * the file diff if parsing fails. */ function isNotebookPath(path: string): boolean { return path.toLowerCase().endsWith('.ipynb'); } /** * Decide whether the active JupyterLab theme is a dark theme. Prefers the * `IThemeManager` token when available, and falls back to sniffing the * `data-jp-theme-light` attribute that JupyterLab sets on `` so the * component still works in stripped-down hosts where the token isn't * provided (lite, certain notebook configurations, …). */ export function isDarkTheme(themeManager: IThemeManager | null): boolean { if (themeManager !== null && themeManager.theme !== null) { return !themeManager.isLight(themeManager.theme); } return document.body.dataset.jpThemeLight === 'false'; } /** * Whether the active JupyterLab theme should keep Pierre diff highlighting. */ export function isPierreTheme(themeManager: IThemeManager | null): boolean { const theme = themeManager?.theme ?? null; return theme !== null && theme.toLowerCase().includes('pierre'); } /** * Optional per-hunk discard wiring. When supplied and `enabled`, each hunk * in a plain-text/code diff gets an inline "discard" button; clicking it * reverts that hunk and hands the rebuilt full-file text back to the host * via {@link IHunkDiscard.save}. The host owns the actual write (it knows * the on-disk path) and triggers any follow-up refresh in * {@link IHunkDiscard.onAfterSave}. */ interface IHunkDiscard { enabled: boolean; save: (fullText: string) => Promise; onAfterSave: () => void; } interface IDiffSurfaceProps { /** * Whether the host is still resolving the file contents. */ loading: boolean; /** * Fatal error message to show instead of a diff, or `null`. */ error: string | null; /** * Whether the file is binary (no textual diff is rendered). */ isBinary: boolean; /** * Resolved old/reference text. Ignored while loading/binary/errored. */ oldText: string; /** * Resolved new/challenger text. Ignored while loading/binary/errored. */ newText: string; /** * New-side path. Drives notebook detection and the `@pierre/diffs` file * name on the additions side. */ newName: string; /** * Old-side path. Differs from {@link newName} only for renames; defaults * to {@link newName} when the host does not track a previous path. */ oldName?: string; /** * Whether to render with the dark `@pierre/diffs` theme. */ dark: boolean; /** * Whether the diff should keep Pierre's own syntax palette. */ pierreTheme: boolean; /** * Rendermime registry used by the notebook view to render outputs and * markdown cells. May be `null` in stripped-down hosts; the notebook * diff falls back to a textual representation in that case. */ rendermime: IRenderMimeRegistry | null; /** * Current rendered-vs-JSON choice for notebook diffs (host-controlled). */ notebookViewMode: NotebookDiffViewMode; /** * Split vs unified layout for the textual/code file diff (host-controlled). */ diffStyle: DiffStyle; /** * Called whenever the availability of a rendered notebook view changes, * so the host can show/hide its Notebook/JSON toolbar toggle. */ onNotebookAvailabilityChange?: (available: boolean) => void; /** * Called whenever the textual/code file diff (the only view the split vs * unified choice affects) becomes the active view, so the host can * show/hide its Split/Unified toolbar toggle. False for image, binary, * rendered-notebook, loading and error states. */ onFileDiffActiveChange?: (active: boolean) => void; /** * Called after each (re)computation of the diff with the number of hunks * (or `null` when no textual diff exists). Hosts use this to implement * post-discard behaviors such as auto-closing an emptied diff. */ onMetadataChange?: (info: { hunkCount: number | null }) => void; /** * Optional per-hunk discard wiring; omit for a read-only diff. */ hunkDiscard?: IHunkDiscard; /** * When provided, the textual/code file diff gets line selection: clicking * or dragging over the line numbers selects a range, and a gutter "+" * button appears on the hovered/selected lines. Clicking that button * invokes this callback with the selected range and the button's viewport * rectangle (to anchor a popup to), `null` when it cannot be measured. */ onLineAsk?: (range: SelectedLineRange, anchor: DOMRect | null) => void; /** * Translation bundle for user-facing strings. */ trans: TranslationBundle; } /** * Shared renderer for text, notebook and image diffs. */ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement { const { loading, error, isBinary, oldText, newText, newName, oldName, dark, pierreTheme, rendermime, notebookViewMode, diffStyle, onNotebookAvailabilityChange, onFileDiffActiveChange, onMetadataChange, hunkDiscard, onLineAsk, trans } = props; const hasContent = !loading && error === null && !isBinary; // Raster images take a dedicated ``-based view instead of a // text/notebook diff; the host passes the two sides as base64 strings in // `oldText`/`newText` (the git server base64-encodes binary content). const imageType = React.useMemo(() => imageDataType(newName), [newName]); // Pre-compute the line-oriented diff metadata. Owning it here lets us // pass it to {@link FileDiff} *and* thread the same instance through // {@link diffAcceptRejectHunk} when the user discards a hunk — so hunk // indexes line up between what the user clicked and what we mutate. const metadata = React.useMemo(() => { if (!hasContent || imageType !== null) { return null; } const oldFile: FileContents = { name: oldName ?? newName, contents: oldText }; const newFile: FileContents = { name: newName, contents: newText }; return parseDiffFromFile(oldFile, newFile); }, [hasContent, imageType, oldText, newText, oldName, newName]); // Cell-by-cell notebook diff for `.ipynb` files. When non-null the // renderer uses a notebook-aware view instead of the line-oriented file // diff — kept distinct from {@link metadata} so a notebook whose JSON we // cannot parse can fall back to the file diff path. const notebookDiff = React.useMemo(() => { if (!hasContent || imageType !== null || !isNotebookPath(newName)) { return null; } return buildNotebookDiff({ oldText, newText }); }, [hasContent, imageType, oldText, newText, newName]); // Tell the host whether a rendered notebook view is currently available // so it can show/hide its Notebook/JSON toggle. React.useEffect(() => { onNotebookAvailabilityChange?.(notebookDiff !== null); }, [onNotebookAvailabilityChange, notebookDiff]); // Report the diff shape so hosts can react (e.g. auto-close on empty). React.useEffect(() => { onMetadataChange?.({ hunkCount: metadata !== null ? metadata.hunks.length : null }); }, [onMetadataChange, metadata]); // Decide which view to render. The notebook view requires a successful // nbformat parse; if that failed, we only show the raw JSON file diff. // Computed before the early returns below so the file-diff-active effect // can depend on it without violating the rules of hooks. const hasNotebookView = notebookDiff !== null; const showNotebookView = hasNotebookView && notebookViewMode === 'notebook'; const showFileDiff = !showNotebookView && metadata !== null; // The split vs unified choice only affects the textual/code file diff, so // tell the host whether that view is active to drive its Split/Unified // toolbar toggle. React.useEffect(() => { onFileDiffActiveChange?.(showFileDiff); }, [onFileDiffActiveChange, showFileDiff]); // Split ratio for the diff columns: fraction of width given to the // deletions (left) pane. Persisted across sessions so the user only // dials in their layout once. const [leftRatio, setLeftRatio] = React.useState(() => readStoredSplitRatio() ); const wrapperRef = React.useRef(null); // The drag handler reads the live ratio from a ref so the listeners we // attach on pointer-down don't capture a stale value across renders. const leftRatioRef = React.useRef(leftRatio); React.useEffect(() => { leftRatioRef.current = leftRatio; }, [leftRatio]); const canDiscardHunk = hunkDiscard?.enabled === true; const lineAnnotations = React.useMemo< DiffLineAnnotation[] >(() => { if (!canDiscardHunk || metadata === null) { return []; } return metadata.hunks.map((hunk, hunkIndex) => ({ side: 'additions', // The first line of each hunk in the new file. We anchor the button // there so it sits at the top of the hunk's body. lineNumber: hunk.additionStart, metadata: { hunkIndex } })); }, [canDiscardHunk, metadata]); const handleDiscardHunk = React.useCallback( async (hunkIndex: number) => { if (metadata === null || hunkDiscard === undefined) { return; } const updated = diffAcceptRejectHunk(metadata, hunkIndex, 'reject'); // `additionLines` after a 'reject' contains the full new file with // the hunk reverted back to the old content. The library splits // file contents with `/(?<=\n)/` (a lookbehind that keeps the // trailing `\n` on each line), so the entries already carry their // own line endings — joining with `''` rebuilds the original text // verbatim, while joining with `'\n'` would double every newline. const text = updated.additionLines.join(''); try { await hunkDiscard.save(text); hunkDiscard.onAfterSave(); } catch (err) { console.error('xtralab: failed to discard hunk', err); } }, [hunkDiscard, metadata] ); const renderAnnotation = React.useCallback( ( annotation: DiffLineAnnotation ): React.ReactNode => { if (annotation.metadata === undefined) { return null; } const { hunkIndex } = annotation.metadata; return (
); }, [handleDiscardHunk, trans] ); const handleGutterUtilityClick = React.useCallback( (range: SelectedLineRange) => { if (onLineAsk === undefined) { return; } // Anchor to the gutter "+" button that was just clicked. It lives in // the `@pierre/diffs` shadow root (which is open), parked on the // hovered/selected line's number element. const slot = wrapperRef.current ?.querySelector('diffs-container') ?.shadowRoot?.querySelector('[data-gutter-utility-slot]'); onLineAsk( range, slot instanceof Element ? slot.getBoundingClientRect() : null ); }, [onLineAsk] ); const handleResizerPointerDown = React.useCallback( (event: React.PointerEvent) => { // Only react to primary-button drags. Right-click / middle-click on // the handle should pass through to the browser default. if (event.button !== 0) { return; } const wrapper = wrapperRef.current; if (wrapper === null) { return; } event.preventDefault(); const handle = event.currentTarget; handle.setPointerCapture(event.pointerId); handle.dataset.dragging = 'true'; const rect = wrapper.getBoundingClientRect(); const startX = event.clientX; const startRatio = leftRatioRef.current; const onPointerMove = (ev: PointerEvent): void => { if (rect.width <= 0) { return; } const next = startRatio + (ev.clientX - startX) / rect.width; const clamped = Math.max( MIN_SPLIT_RATIO, Math.min(MAX_SPLIT_RATIO, next) ); setLeftRatio(clamped); }; const onPointerEnd = (ev: PointerEvent): void => { handle.releasePointerCapture(ev.pointerId); delete handle.dataset.dragging; handle.removeEventListener('pointermove', onPointerMove); handle.removeEventListener('pointerup', onPointerEnd); handle.removeEventListener('pointercancel', onPointerEnd); // Only persist on drag-end so a quick drag isn't followed by a // burst of writes for every intermediate position. writeStoredSplitRatio(leftRatioRef.current); }; handle.addEventListener('pointermove', onPointerMove); handle.addEventListener('pointerup', onPointerEnd); handle.addEventListener('pointercancel', onPointerEnd); }, [] ); const handleResizerDoubleClick = React.useCallback(() => { setLeftRatio(DEFAULT_SPLIT_RATIO); writeStoredSplitRatio(DEFAULT_SPLIT_RATIO); }, []); const handleResizerKeyDown = React.useCallback( (event: React.KeyboardEvent) => { const step = event.shiftKey ? 0.1 : 0.01; let next: number | null = null; switch (event.key) { case 'ArrowLeft': next = leftRatioRef.current - step; break; case 'ArrowRight': next = leftRatioRef.current + step; break; case 'Home': next = MIN_SPLIT_RATIO; break; case 'End': next = MAX_SPLIT_RATIO; break; } if (next === null) { return; } event.preventDefault(); const clamped = Math.max( MIN_SPLIT_RATIO, Math.min(MAX_SPLIT_RATIO, next) ); setLeftRatio(clamped); writeStoredSplitRatio(clamped); }, [] ); if (loading) { return (
{trans.__('Loading diff…')}
); } if (error !== null) { return (
{error}
); } if (isBinary) { return (
{trans.__('Binary file — diff not supported.')}
); } if (imageType !== null) { // `oldText`/`newText` carry the two revisions as base64 here. The // image view owns its own layout and mode selector, so it does not // use the split resizer or the notebook/JSON toggle. return (
); } if (!showNotebookView && !showFileDiff) { return (
{trans.__('No content to diff.')}
); } // Custom property set on the diffs-container host. The library's // shadow-root rule consumes it via `var(--xtralab-split-cols, …)` (see // SPLIT_RESIZE_CSS) and recomputes the column tracks instantly. Only // relevant in the file-diff path; the notebook view doesn't use it. const leftPercent = leftRatio * 100; const hostStyle = { '--xtralab-split-cols': `${leftPercent}% ${100 - leftPercent}%` } as React.CSSProperties; return (
{showNotebookView && notebookDiff !== null ? ( ) : showFileDiff && metadata !== null ? ( fileDiff={metadata} lineAnnotations={lineAnnotations} renderAnnotation={renderAnnotation} style={hostStyle} // Disable the worker pool: JupyterLab's webpack federation // pipeline doesn't ship the library's // `@pierre/diffs/worker/worker.js` file at a URL the worker // bootstrap can resolve from, so leaving the pool enabled // crashes on instantiation. Running on the main thread is // fine for the small files git diffs typically operate on. disableWorkerPool={true} options={{ diffStyle, // Drop the file header — the tab title already shows the // file name and the panel header carries the change // context. disableFileHeader: true, theme: resolveDiffTheme(dark, pierreTheme), themeType: dark ? 'dark' : 'light', // Inject the column-resize override into the shadow root // via the library's `@layer unsafe` channel. Keeping the // string constant lets the library short-circuit the // re-render path it uses when this option actually // changes. unsafeCSS: SPLIT_RESIZE_CSS, // Line selection + the gutter "+" button feed the // ask-agent popup; only wired up when a handler exists so // a plain diff keeps its passive gutter. ...(onLineAsk !== undefined ? { enableLineSelection: true, enableGutterUtility: true, onGutterUtilityClick: handleGutterUtilityClick } : {}) }} /> ) : null}
{showFileDiff && diffStyle === 'split' ? (
) : null}
); } /** * Segmented Notebook/JSON selector. Hosts mount this into whatever toolbar * they own (the launcher's `MainAreaWidget` toolbar, or the * `jupyterlab-git`-provided diff toolbar) and drive its value/visibility * from the same state they pass to {@link DiffSurface}. */ export function NotebookViewModeControl(props: { mode: NotebookDiffViewMode; available: boolean; onChange: (mode: NotebookDiffViewMode) => void; trans: TranslationBundle; }): React.ReactElement { const { mode, available, onChange, trans } = props; if (!available) { return <>; } return (
); } /** * Split / unified glyphs, inlined from the `@pierre/diffs` icon sprite * (`diffs-icon-diff-split` / `diffs-icon-diff-unified`) so the toggle looks * like the one on the library's own docs site without depending on the * sprite sheet being injected into the document. `currentColor` lets the * segmented-button styling drive the fill (including the active state). */ function DiffSplitIcon(): React.ReactElement { return ( ); } function DiffUnifiedIcon(): React.ReactElement { return ( ); } /** * Segmented Split/Unified selector. Mirrors {@link NotebookViewModeControl}: * hosts mount it into their own toolbar and drive its value/visibility from * the same state they pass to {@link DiffSurface}. Only meaningful while the * textual/code file diff is the active view, so `available` mirrors the * surface's `onFileDiffActiveChange`. */ export function DiffStyleControl(props: { diffStyle: DiffStyle; available: boolean; onChange: (style: DiffStyle) => void; trans: TranslationBundle; }): React.ReactElement { const { diffStyle, available, onChange, trans } = props; if (!available) { return <>; } return (
); }