diff --git a/packages/web/client/src/components/CellContent.tsx b/packages/web/client/src/components/CellContent.tsx index 08950de4..dd91ee24 100644 --- a/packages/web/client/src/components/CellContent.tsx +++ b/packages/web/client/src/components/CellContent.tsx @@ -7,9 +7,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { EditorView } from '@codemirror/view'; import { EditorState } from '@codemirror/state'; import type { Extension } from '@codemirror/state'; -import { HighlightStyle, LanguageDescription, ensureSyntaxTree } from '@codemirror/language'; +import { HighlightStyle, ensureSyntaxTree } from '@codemirror/language'; import { githubDarkStyle, githubLightStyle } from '@uiw/codemirror-theme-github'; -import { languages } from '@codemirror/language-data'; import { StyleModule } from 'style-mod'; import { IRenderMime, OutputModel, RenderMimeRegistry, standardRendererFactories } from '@jupyterlab/rendermime'; import { Widget } from '@lumino/widgets'; @@ -46,7 +45,7 @@ if (typeof document !== 'undefined') { // Plain HTML (
) with HighlightStyle classes (GitHub light/dark), mounted
 // via style-mod — same palette as the resolved CodeMirror editor, no duplicate CSS.
 
-/** Parse `source` with the given language extensions and return token spans. */
+/** Parse `source` with the given language extensions and return styled token spans, in document order. */
 function getSyntaxTokens(
     source: string,
     langExtensions: Extension[],
@@ -66,7 +65,7 @@ function getSyntaxTokens(
         let pos = 0;
         highlightCode(source, tree, highlighter,
             (text, classes) => {
-                tokens.push({ from: pos, to: pos + text.length, classes: classes || '' });
+                if (classes) tokens.push({ from: pos, to: pos + text.length, classes });
                 pos += text.length;
             },
             () => { pos++; }, // newline
@@ -102,15 +101,15 @@ function buildFlatSegments(source: string, tokens: { from: number; to: number; c
     let lastTo = 0;
     for (const t of tokens) {
         if (t.from > lastTo) parts.push({ text: source.slice(lastTo, t.from) });
-        const text = source.slice(t.from, t.to);
-        parts.push(t.classes ? { text, classes: t.classes } : { text });
+        parts.push({ text: source.slice(t.from, t.to), classes: t.classes });
         lastTo = t.to;
     }
     if (lastTo < source.length) parts.push({ text: source.slice(lastTo) });
     return parts;
 }
 
-/** Build line-wrapped spans with merged syntax + diff highlighting. */
+/** Build line-wrapped spans with merged syntax + diff highlighting.
+    Both `syntaxTokens` and `inlineRanges` arrive in document order. */
 function buildLineSegments(
     source: string,
     syntaxTokens: { from: number; to: number; classes: string }[],
@@ -118,8 +117,8 @@ function buildLineSegments(
     inlineRanges: { from: number; to: number; classes: string }[],
 ): StaticLine[] {
     const lines = source.split('\n');
-    const sortedSyntax = syntaxTokens.slice().sort((a, b) => a.from - b.from);
-    const sortedInline = inlineRanges.slice().sort((a, b) => a.from - b.from);
+    const sortedSyntax = syntaxTokens;
+    const sortedInline = inlineRanges;
     const result: StaticLine[] = [];
     let offset = 0;
     let syntaxIndex = 0;
@@ -234,37 +233,7 @@ function renderStaticToReact(render: StaticRender): React.ReactNode {
     ));
 }
 
-function renderSegmentsToDom(parent: HTMLElement | DocumentFragment, segments: StaticSegment[]): void {
-    for (const segment of segments) {
-        if (segment.classes) {
-            const span = document.createElement('span');
-            span.className = segment.classes;
-            span.textContent = segment.text;
-            parent.appendChild(span);
-        } else {
-            parent.appendChild(document.createTextNode(segment.text));
-        }
-    }
-}
-
-function renderStaticToDom(render: StaticRender): DocumentFragment {
-    const fragment = document.createDocumentFragment();
-    if (render.kind === 'flat') {
-        renderSegmentsToDom(fragment, render.segments);
-        return fragment;
-    }
-    for (const line of render.lines) {
-        const span = document.createElement('span');
-        span.className = line.lineClass ? `source-line ${line.lineClass}` : 'source-line';
-        renderSegmentsToDom(span, line.segments);
-        fragment.appendChild(span);
-    }
-    return fragment;
-}
-
 type RenderMimeOutputValue = ConstructorParameters[0]['value'];
-const renderMimeRegistryCache = new Map();
-const MAX_RENDERMIME_REGISTRY_CACHE_SIZE = 32;
 
 interface CellContentProps {
     cell: NotebookCell | undefined;
@@ -279,7 +248,7 @@ interface CellContentProps {
     theme?: 'dark' | 'light';
     isLightweight?: boolean;
 }
-const EMPTY_EXTENSIONS: Extension[] = [];
+export const EMPTY_EXTENSIONS: Extension[] = [];
 function CellContentInner({
     cell,
     cellIndex,
@@ -293,10 +262,6 @@ function CellContentInner({
     theme = 'light',
     isLightweight = false,
 }: CellContentProps): React.ReactElement {
-    const renderMimeRegistry = useMemo(
-        () => getRenderMimeRegistry(),
-        []
-    );
     const encodedCell = useMemo(
         () => (cell ? encodeURIComponent(JSON.stringify(cell)) : ''),
         [cell]
@@ -340,36 +305,24 @@ function CellContentInner({
                 {cellType === 'markdown' && !isConflict ? (
                     
-                ) : isConflict && compareCell ? (
-                    
-                ) : cellType !== 'markdown' ? (
-                    
-                ) : (
-                    // Markdown in conflict mode: plain pre (diff view takes over)
-                    
{source}
)} {showOutputs && cellType === 'code' && cell.outputs && cell.outputs.length > 0 && ( )} @@ -379,93 +332,18 @@ function CellContentInner({ interface MarkdownContentProps { source: string; - theme: 'dark' | 'light'; isLightweight?: boolean; } -const markdownFenceLanguageSupportCache = new Map>(); - -function getFenceLanguageTag(codeNode: HTMLElement): string | null { - for (const className of Array.from(codeNode.classList)) { - if (className.startsWith('language-')) { - return className.slice('language-'.length).trim().toLowerCase(); - } - if (className.startsWith('lang-')) { - return className.slice('lang-'.length).trim().toLowerCase(); - } - } - return null; -} - -function loadFenceLanguageSupport(languageTag: string): Promise { - const key = languageTag.trim().toLowerCase(); - if (!key) return Promise.resolve(null); - - const cached = markdownFenceLanguageSupportCache.get(key); - if (cached) return cached; - - const supportPromise = (async () => { - const description = - LanguageDescription.matchLanguageName(languages, key, true) ?? - LanguageDescription.matchFilename(languages, `file.${key}`); - - if (!description) return null; - if (description.support) return description.support; - - try { - return await description.load(); - } catch (err) { - markdownFenceLanguageSupportCache.delete(key); - logger.warn('[MergeNB] Failed to load markdown fence language support:', err); - return null; - } - })(); - - markdownFenceLanguageSupportCache.set(key, supportPromise); - return supportPromise; -} - -async function enhanceMarkdownCodeBlocks(host: HTMLElement, theme: 'dark' | 'light'): Promise { - const codeNodes = Array.from(host.querySelectorAll('pre > code')) as HTMLElement[]; - if (codeNodes.length === 0) return; - - // Load all fence language bundles in parallel so the DOM replacements - // below happen in a single synchronous pass instead of one per await. - const languageSupports = await Promise.all( - codeNodes.map(node => { - const tag = getFenceLanguageTag(node); - return tag ? loadFenceLanguageSupport(tag) : Promise.resolve(null); - }) - ); - - for (let i = 0; i < codeNodes.length; i++) { - const codeNode = codeNodes[i]; - const preNode = codeNode.parentElement; - if (!preNode) continue; - - const languageSupport = languageSupports[i]; - if (!languageSupport) continue; - - const source = codeNode.textContent ?? ''; - const tokens = getSyntaxTokens(source, [languageSupport], theme); - const renderPlan = buildStaticRender(source, tokens); - codeNode.replaceChildren(renderStaticToDom(renderPlan)); - preNode.classList.add('has-syntax-highlight'); - } -} - -export function MarkdownContent({ source, theme, isLightweight = false }: MarkdownContentProps): React.ReactElement { +export function MarkdownContent({ source, isLightweight = false }: MarkdownContentProps): React.ReactElement { const hostRef = useRef(null); useEffect(() => { if (isLightweight) return; const host = hostRef.current; - if (!host || !host.isConnected) return; - - host.replaceChildren(); + if (!host) return; - const html = renderMarkdown(source); - host.innerHTML = html; + host.innerHTML = renderMarkdown(source); // Resolve local image/link URLs to notebook-asset endpoints const { sessionId, token } = getCurrentSessionCredentials(); @@ -481,16 +359,7 @@ export function MarkdownContent({ source, theme, isLightweight = false }: Markdo anchor.setAttribute('href', buildNotebookAssetUrl(sessionId, token, normalizeLocalPath(href))); } }); - - void enhanceMarkdownCodeBlocks(host, theme) - .catch((err) => { - logger.warn('[MergeNB] Failed to highlight markdown fenced code blocks:', err); - }); - - return () => { - host.replaceChildren(); - }; - }, [source, theme, isLightweight]); + }, [source, isLightweight]); if (isLightweight) { return
{source}
; @@ -498,62 +367,54 @@ export function MarkdownContent({ source, theme, isLightweight = false }: Markdo return
; } -// ─── Static display components (replace CodeMirror read-only instances) ─────── +// ─── Static cell source (replaces CodeMirror read-only instances) ───────────── -export function StaticHighlightedCode({ source, langExtensions, theme, className = 'cell-source-static', isLightweight = false }: { +export function CellSource({ + source, + langExtensions, + theme, + compareSource, + side = 'base', + diffMode = 'base', + isMarkdown = false, + className = 'cell-source-static', + isLightweight = false, +}: { source: string; langExtensions: Extension[]; theme: 'dark' | 'light'; + /** When set, line/inline diff marks against this content are rendered. */ + compareSource?: string; + side?: 'base' | 'current' | 'incoming'; + diffMode?: 'base' | 'conflict'; + isMarkdown?: boolean; className?: string; isLightweight?: boolean; }): React.ReactElement { const nodes = useMemo(() => { if (isLightweight) return null; - const tokens = getSyntaxTokens(source, langExtensions, theme); - return renderStaticToReact(buildStaticRender(source, tokens)); - }, [source, langExtensions, theme, isLightweight]); - + const tokens = getSyntaxTokens(source, isMarkdown ? [] : langExtensions, theme); + const marks = compareSource !== undefined + ? computeDiffMarks(source, compareSource, side, diffMode) + : undefined; + return renderStaticToReact(buildStaticRender(source, tokens, marks?.lineClasses, marks?.inlineRanges)); + }, [source, compareSource, side, diffMode, langExtensions, theme, isMarkdown, isLightweight]); + + const content = isLightweight ? source : nodes; + // Markdown cells don't need a wrapper - it's text content, not code return (
-            {isLightweight ? source : nodes}
-        
- ); -} - -function StaticDiffContent({ source, compareSource, side, diffMode, langExtensions, theme, isMarkdown = false, isLightweight = false }: { - source: string; - compareSource: string; - side: 'base' | 'current' | 'incoming'; - diffMode: 'base' | 'conflict'; - langExtensions: Extension[]; - theme: 'dark' | 'light'; - isMarkdown?: boolean; - isLightweight?: boolean; -}): React.ReactElement { - const nodes = useMemo(() => { - if (isLightweight) return null; - const tokens = getSyntaxTokens(source, langExtensions, theme); - const { lineClasses, inlineRanges } = computeDiffMarks(source, compareSource, side, diffMode); - return renderStaticToReact(buildStaticRender(source, tokens, lineClasses, inlineRanges)); - }, [source, compareSource, side, diffMode, langExtensions, theme, isLightweight]); - - // Markdown cells don't need wrapper - it's text content, not code - return ( -
-            {isMarkdown
-                ? (isLightweight ? source : nodes)
-                : {isLightweight ? source : nodes}}
+            {isMarkdown ? content : {content}}
         
); } interface CellOutputsProps { outputs: CellOutput[]; - renderMimeRegistry: RenderMimeRegistry; isLightweight?: boolean; } -function CellOutputs({ outputs, renderMimeRegistry, isLightweight = false }: CellOutputsProps): React.ReactElement { +function CellOutputs({ outputs, isLightweight = false }: CellOutputsProps): React.ReactElement { if (isLightweight) { return (
@@ -566,23 +427,13 @@ function CellOutputs({ outputs, renderMimeRegistry, isLightweight = false }: Cel return (
{outputs.map((output, i) => ( - + ))}
); } -function RenderMimeOutput({ - output, - renderMimeRegistry -}: { - output: CellOutput; - renderMimeRegistry: RenderMimeRegistry; -}): React.ReactElement { +function RenderMimeOutput({ output }: { output: CellOutput }): React.ReactElement { const hostRef = useRef(null); const [fallback, setFallback] = useState(null); @@ -592,6 +443,7 @@ function RenderMimeOutput({ host.replaceChildren(); setFallback(null); + const renderMimeRegistry = getRenderMimeRegistry(); let disposed = false; let renderer: ReturnType | null = null; let model: OutputModel | null = null; @@ -653,7 +505,7 @@ function RenderMimeOutput({ model?.dispose(); host.replaceChildren(); }; - }, [output, renderMimeRegistry]); + }, [output]); return (
@@ -734,53 +586,19 @@ function getCurrentSessionCredentials(): { sessionId: string; token: string } { }; } -function getRenderMimeRegistry(): RenderMimeRegistry { - const { sessionId, token } = getCurrentSessionCredentials(); - const cacheKey = `${sessionId}::${token}`; - const cached = renderMimeRegistryCache.get(cacheKey); - if (cached) { - renderMimeRegistryCache.delete(cacheKey); - renderMimeRegistryCache.set(cacheKey, cached); - return cached; - } - - const registry = new RenderMimeRegistry({ - initialFactories: standardRendererFactories, - resolver: createNotebookAssetResolver(sessionId, token), - }); - - renderMimeRegistryCache.set(cacheKey, registry); - evictRenderMimeRegistryCacheEntries(); - return registry; -} - -function evictRenderMimeRegistryCacheEntries(): void { - while (renderMimeRegistryCache.size > MAX_RENDERMIME_REGISTRY_CACHE_SIZE) { - const leastRecentlyUsedKey = renderMimeRegistryCache.keys().next().value as string | undefined; - if (!leastRecentlyUsedKey) return; +// Session credentials come from window.location and never change within a page, +// so one registry serves the whole session. +let renderMimeRegistry: RenderMimeRegistry | null = null; - const leastRecentlyUsedRegistry = renderMimeRegistryCache.get(leastRecentlyUsedKey); - renderMimeRegistryCache.delete(leastRecentlyUsedKey); - disposeRenderMimeRegistry(leastRecentlyUsedRegistry); - } -} - -function disposeRenderMimeRegistry(registry: RenderMimeRegistry | undefined): void { - if (!registry) return; - - const resolver = registry.resolver as (IRenderMime.IResolver & { dispose?: () => void }) | null; - try { - resolver?.dispose?.(); - } catch (err) { - logger.warn('[MergeNB] Failed to dispose rendermime resolver:', err); - } - - const disposableRegistry = registry as RenderMimeRegistry & { dispose?: () => void }; - try { - disposableRegistry.dispose?.(); - } catch (err) { - logger.warn('[MergeNB] Failed to dispose rendermime registry:', err); +function getRenderMimeRegistry(): RenderMimeRegistry { + if (!renderMimeRegistry) { + const { sessionId, token } = getCurrentSessionCredentials(); + renderMimeRegistry = new RenderMimeRegistry({ + initialFactories: standardRendererFactories, + resolver: createNotebookAssetResolver(sessionId, token), + }); } + return renderMimeRegistry; } function createNotebookAssetResolver(sessionId: string, token: string): IRenderMime.IResolver { diff --git a/packages/web/client/src/components/ConflictResolver.tsx b/packages/web/client/src/components/ConflictResolver.tsx index d00e9089..5f515842 100644 --- a/packages/web/client/src/components/ConflictResolver.tsx +++ b/packages/web/client/src/components/ConflictResolver.tsx @@ -7,15 +7,15 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { LanguageDescription } from '@codemirror/language'; import { languages } from '@codemirror/language-data'; import { useStore } from 'zustand'; -import { createPortal } from 'react-dom'; +import { WarningModal } from './WarningModal'; import type { UnifiedConflictData, MergeRow as MergeRowType, - NotebookCell, } from '../types'; import { MergeRow } from './MergeRow'; import { createResolverStore, + getCellForSide, type ResolutionState, type TakeAllChoice, } from '../store/resolverStore'; @@ -33,6 +33,66 @@ interface ConflictResolverProps { onCancel: () => void; } +// Shared title/message for the "a cell is still being edited" prompt shown when +// applying a resolution. Only the confirm label differs between entry points. +const APPLY_RESOLUTION_WHILE_EDITING_WARNING = { + title: 'Apply resolution now?', + message: 'A cell is still in edit mode. Apply the current saved content, or keep editing first.', +} as const; + +type GuardedHandlers = { + onMouseDown: (event: React.MouseEvent) => void; + onClick: () => void; +}; + +interface UndoRedoButtonsProps { + guardedClick: (action: () => void) => GuardedHandlers; + onUndo: () => void; + onRedo: () => void; + canUndo: boolean; + canRedo: boolean; + undoTestId: string; + redoTestId: string; + undoTitle?: string; + redoTitle?: string; +} + +// Undo/Redo pair rendered identically in the toolbar and the history panel. +function UndoRedoButtons({ + guardedClick, + onUndo, + onRedo, + canUndo, + canRedo, + undoTestId, + redoTestId, + undoTitle, + redoTitle, +}: UndoRedoButtonsProps): React.ReactElement { + return ( + <> + + + + ); +} + export function ConflictResolver({ conflict, onResolve, @@ -120,8 +180,7 @@ export function ConflictResolver({ const dismissDestructiveActionWarning = useCallback(() => { pendingDestructiveActionRef.current = null; setDestructiveActionWarning(null); - - }, [activeEditingConflictIndex, rows]); + }, []); const confirmDestructiveActionWarning = useCallback(() => { const action = pendingDestructiveActionRef.current; @@ -165,6 +224,24 @@ export function ConflictResolver({ [activeEditingConflictIndex] ); + // Build the onMouseDown/onClick pair for a control that must defer to the + // active-editing guard: mousedown intercepts to prompt the user, and the + // click is discarded once (via the suppress ref) so the action doesn't + // double-fire after the prompt is shown. + const guardedClick = useCallback( + (action: () => void) => ({ + onMouseDown: (event: React.MouseEvent) => mouseDownGuardActiveEditing(event, action), + onClick: () => { + if (suppressGuardedClickRef.current) { + suppressGuardedClickRef.current = false; + return; + } + action(); + }, + }), + [mouseDownGuardActiveEditing] + ); + const isEditableTarget = useCallback((target: EventTarget | null): boolean => { if (!target || !(target as HTMLElement).closest) return false; const element = target as HTMLElement; @@ -363,9 +440,7 @@ export function ConflictResolver({ if (activeEditingConflictIndex !== null) { pendingDestructiveActionRef.current = applyResolutionNow; setDestructiveActionWarning({ - title: 'Apply resolution now?', - message: - 'A cell is still in edit mode. Apply the current saved content, or keep editing first.', + ...APPLY_RESOLUTION_WHILE_EDITING_WARNING, confirmLabel: 'Apply resolution (without saving edits)', }); return; @@ -386,9 +461,7 @@ export function ConflictResolver({ suppressApplyResolutionClickRef.current = true; pendingDestructiveActionRef.current = applyResolutionNow; setDestructiveActionWarning({ - title: 'Apply resolution now?', - message: - 'A cell is still in edit mode. Apply the current saved content, or keep editing first.', + ...APPLY_RESOLUTION_WHILE_EDITING_WARNING, confirmLabel: 'Apply resolution', }); }, @@ -397,37 +470,18 @@ export function ConflictResolver({ const fileName = conflict.filePath.split('/').pop() || 'notebook.ipynb'; - const destructiveActionModal = destructiveActionWarning - ? createPortal( -
-
-
⚠️
-

{destructiveActionWarning.title}

-

{destructiveActionWarning.message}

-
- - -
-
-
, - document.body - ) - : null; + const destructiveActionModal = destructiveActionWarning ? ( + + ) : null; return (
@@ -458,32 +512,17 @@ export function ConflictResolver({ Next Conflict ↓
- - +
- +
    @@ -537,12 +561,7 @@ export function ConflictResolver({ role="button" tabIndex={0} aria-current={index === history.index ? 'true' : undefined} - onMouseDown={e => mouseDownGuardActiveEditing(e, () => { handleJumpToHistory(index); setHistoryOpen(false); })} - onClick={() => { - if (suppressGuardedClickRef.current) { suppressGuardedClickRef.current = false; return; } - handleJumpToHistory(index); - setHistoryOpen(false); - }} + {...guardedClick(() => { handleJumpToHistory(index); setHistoryOpen(false); })} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); @@ -558,63 +577,19 @@ export function ConflictResolver({
-
- {showBaseColumn && ( - - )} - - +
+ {(['base', 'current', 'incoming'] as const) + .filter(side => side !== 'base' || showBaseColumn) + .map(side => ( + + ))}