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 && (
- mouseDownGuardActiveEditing(e, () => handleAcceptAll('base'))}
- onClick={() => {
- if (suppressGuardedClickRef.current) { suppressGuardedClickRef.current = false; return; }
- handleAcceptAll('base');
- }}
- >
- All Base
-
- )}
- mouseDownGuardActiveEditing(e, () => handleAcceptAll('current'))}
- onClick={() => {
- if (suppressGuardedClickRef.current) { suppressGuardedClickRef.current = false; return; }
- handleAcceptAll('current');
- }}
- >
- All Current
-
- mouseDownGuardActiveEditing(e, () => handleAcceptAll('incoming'))}
- onClick={() => {
- if (suppressGuardedClickRef.current) { suppressGuardedClickRef.current = false; return; }
- handleAcceptAll('incoming');
- }}
- >
- All Incoming
-
+
+ {(['base', 'current', 'incoming'] as const)
+ .filter(side => side !== 'base' || showBaseColumn)
+ .map(side => (
+ handleAcceptAll(side))}
+ >
+ All {side[0].toUpperCase() + side.slice(1)}
+
+ ))}
@@ -491,25 +460,45 @@ function MergeRowInner({
);
}
+ // Per-side descriptors for the three diff columns. Base compares against the
+ // first available other side; current/incoming compare against each other
+ // (falling back to base) in conflict diff mode.
+ const columnSides = [
+ {
+ side: 'base' as const,
+ cell: row.baseCell,
+ cellIndex: row.baseCellIndex,
+ compareCell: row.currentCell || row.incomingCell,
+ diffMode: undefined as 'conflict' | undefined,
+ },
+ {
+ side: 'current' as const,
+ cell: row.currentCell,
+ cellIndex: row.currentCellIndex,
+ compareCell: row.incomingCell || row.baseCell,
+ diffMode: 'conflict' as const,
+ },
+ {
+ side: 'incoming' as const,
+ cell: row.incomingCell,
+ cellIndex: row.incomingCellIndex,
+ compareCell: row.currentCell || row.baseCell,
+ diffMode: 'conflict' as const,
+ },
+ ];
+
+ const resolutionSides = [
+ { side: 'base' as const, has: hasBase },
+ { side: 'current' as const, has: hasCurrent },
+ { side: 'incoming' as const, has: hasIncoming },
+ ];
+
return (
{/* Top action bar - always present for conflicts */}
- {isReordered && !row.isUserUnmatched && (
-
- {currentDelta !== undefined && currentDelta !== 0 && (
-
- {currentDelta > 0 ? '\u2193' : '\u2191'} {Math.abs(currentDelta)}
-
- )}
- {incomingDelta !== undefined && incomingDelta !== 0 && (
-
- {incomingDelta > 0 ? '\u2193' : '\u2191'} {Math.abs(incomingDelta)}
-
- )}
-
- )}
+ {!row.isUserUnmatched && reorderIndicator}
- {showBaseColumn && (
-
- {row.baseCell ? (
-
- ) : (
-
- {getPlaceholderText('base')}
-
- )}
-
- )}
-
- {row.currentCell ? (
-
- ) : (
-
- {getPlaceholderText('current')}
-
- )}
-
-
- {row.incomingCell ? (
-
- ) : (
-
- {getPlaceholderText('incoming')}
+ {columnSides
+ .filter(col => col.side !== 'base' || showBaseColumn)
+ .map(col => (
+
+ {col.cell ? (
+
+ ) : (
+
+ {getPlaceholderText(col.side)}
+
+ )}
- )}
-
+ ))}
{/* Resolution bar - select which branch to use as base */}
- {showBaseColumn && !row.isUserUnmatched && (
-
- {hasBase && (
- handleChoiceClick('base')}
- >
- Use Base
-
- )}
-
- )}
-
- {hasCurrent && (
- handleChoiceClick('current')}
- >
- Use Current
-
- )}
-
-
- {hasIncoming && (
- handleChoiceClick('incoming')}
- >
- Use Incoming
-
- )}
-
+ {resolutionSides
+ .filter(({ side }) => side !== 'base' || (showBaseColumn && !row.isUserUnmatched))
+ .map(({ side, has }) => (
+
+ {has && (
+ handleChoiceClick(side)}
+ >
+ Use {side[0].toUpperCase() + side.slice(1)}
+
+ )}
+
+ ))}
diff --git a/packages/web/client/src/components/WarningModal.tsx b/packages/web/client/src/components/WarningModal.tsx
new file mode 100644
index 00000000..1e3c04fe
--- /dev/null
+++ b/packages/web/client/src/components/WarningModal.tsx
@@ -0,0 +1,109 @@
+/**
+ * @file WarningModal.tsx
+ * @description Shared confirm/cancel warning dialog rendered into document.body.
+ */
+
+import React, { useEffect, useId, useRef } from 'react';
+import { createPortal } from 'react-dom';
+
+interface WarningModalProps {
+ title: string;
+ message: string;
+ confirmLabel: string;
+ cancelLabel?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ /** data-testid for the overlay element (used to locate the modal in tests). */
+ testId?: string;
+ /** data-testid for the confirm button. */
+ confirmTestId?: string;
+ /** Mark the overlay as an editing-allowed surface so editor blur autosave is suppressed. */
+ editingAllow?: boolean;
+}
+
+export function WarningModal({
+ title,
+ message,
+ confirmLabel,
+ cancelLabel = 'Keep my edits',
+ onConfirm,
+ onCancel,
+ testId,
+ confirmTestId,
+ editingAllow = false,
+}: WarningModalProps): React.ReactElement {
+ const titleId = useId();
+ const messageId = useId();
+ const cancelRef = useRef(null);
+ const confirmRef = useRef(null);
+
+ useEffect(() => {
+ const previouslyFocused = document.activeElement as HTMLElement | null;
+ confirmRef.current?.focus();
+ return () => {
+ previouslyFocused?.focus();
+ };
+ }, []);
+
+ const handleKeyDown = (event: React.KeyboardEvent): void => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ onCancel();
+ return;
+ }
+ if (event.key !== 'Tab') {
+ return;
+ }
+ const first = cancelRef.current;
+ const last = confirmRef.current;
+ if (!first || !last) {
+ return;
+ }
+ if (event.shiftKey) {
+ if (document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+ };
+
+ return createPortal(
+
+
+ ⚠️
+ {title}
+ {message}
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+
+
+ ,
+ document.body
+ );
+}
diff --git a/packages/web/client/src/store/resolverStore.ts b/packages/web/client/src/store/resolverStore.ts
index 01cefdca..d4a5f4a5 100644
--- a/packages/web/client/src/store/resolverStore.ts
+++ b/packages/web/client/src/store/resolverStore.ts
@@ -127,7 +127,15 @@ function applySnapshot(state: ResolverStoreState, snapshot: ResolverSnapshot): v
state.takeAllChoice = snapshot.takeAllChoice;
}
-function getCellForSide(
+// Move to an existing history entry: restore its snapshot, exit any edit session,
+// and point the index at it. Callers guard bounds/no-op before calling.
+function goToHistoryIndex(state: ResolverStoreState, targetIndex: number): void {
+ applySnapshot(state, state.history.entries[targetIndex].snapshot);
+ state.editingConflicts.clear();
+ state.history.index = targetIndex;
+}
+
+export function getCellForSide(
row: MergeRowType,
side: TakeAllChoice
): NotebookCell | undefined {
@@ -233,10 +241,7 @@ export function createResolverStore(initialRows: MergeRowType[]): ResolverStore
jumpToHistory: (targetIndex: number) => set(state => {
if (targetIndex === state.history.index) return;
if (targetIndex < 0 || targetIndex >= state.history.entries.length) return;
- const targetSnapshot = state.history.entries[targetIndex].snapshot;
- applySnapshot(state, targetSnapshot);
- state.editingConflicts.clear();
- state.history.index = targetIndex;
+ goToHistoryIndex(state, targetIndex);
}),
unmatchRow: (rowIndex: number) => set(state => {
const row = state.rows[rowIndex];
@@ -344,19 +349,11 @@ export function createResolverStore(initialRows: MergeRowType[]): ResolverStore
}),
undo: () => set(state => {
if (state.history.index === 0) return;
- const nextIndex = state.history.index - 1;
- const targetSnapshot = state.history.entries[nextIndex].snapshot;
- applySnapshot(state, targetSnapshot);
- state.editingConflicts.clear();
- state.history.index = nextIndex;
+ goToHistoryIndex(state, state.history.index - 1);
}),
redo: () => set(state => {
if (state.history.index >= state.history.entries.length - 1) return;
- const nextIndex = state.history.index + 1;
- const targetSnapshot = state.history.entries[nextIndex].snapshot;
- applySnapshot(state, targetSnapshot);
- state.editingConflicts.clear();
- state.history.index = nextIndex;
+ goToHistoryIndex(state, state.history.index + 1);
}),
}))
);
diff --git a/packages/web/client/src/styles.ts b/packages/web/client/src/styles.ts
index 4908c9e0..6a8c6cc8 100644
--- a/packages/web/client/src/styles.ts
+++ b/packages/web/client/src/styles.ts
@@ -388,6 +388,24 @@ ${bodySel} {
background: #3c3c3c;
}
+.take-all-group {
+ display: flex;
+ gap: 6px;
+ margin-right: 12px;
+ padding-right: 12px;
+ border-right: 1px solid var(--border-color);
+}
+
+.btn-take-all {
+ color: var(--text-primary);
+ font-size: 11px;
+ padding: 4px 8px;
+}
+
+.btn-take-all.base { background: var(--base-bg); border: 1px solid var(--base-border); }
+.btn-take-all.current { background: var(--current-bg); border: 1px solid var(--current-border); }
+.btn-take-all.incoming { background: var(--incoming-bg); border: 1px solid var(--incoming-border); }
+
/* Main content */
.main-content {
flex: 1;
@@ -621,8 +639,7 @@ ${bodySel} {
}
/* Static code uses @uiw/codemirror-theme-github HighlightStyle (see CellContent.tsx) */
-.cell-source-static code,
-.markdown-content pre.has-syntax-highlight code {
+.cell-source-static code {
font-family: var(--font-code);
font-weight: 400;
}
@@ -1126,27 +1143,6 @@ ${bodySel} {
outline: none !important;
}
-.resolved-cell.markdown-cell textarea.resolved-content-input {
- width: 100%;
- min-height: 120px;
- padding: 10px 12px;
- border: 1px solid rgba(78, 201, 176, 0.4);
- border-left: 3px solid var(--accent-green);
- border-radius: 4px;
- outline: none !important;
- resize: vertical;
- background: var(--cell-surface);
- color: var(--text-primary);
- font-family: var(--font-ui);
- font-size: 13px;
- line-height: 1.5;
-}
-
-.resolved-cell.markdown-cell textarea.resolved-content-input:focus {
- border-color: var(--accent-green);
- box-shadow: 0 0 0 2px rgba(78, 201, 176, 0.2);
-}
-
.resolved-cell.markdown-cell .resolved-content-input .cm-editor {
border-left: 3px solid var(--accent-green);
}