This commit is contained in:
Timothy Jaeryang Baek committed 2026-10-14 16:51:09 +04:00
1 parent c143625cc4
commit de642f1dba
1 file changed
+301 -164
+301 -164
View File
@@ -3,7 +3,7 @@
import { getContext } from 'svelte';
import { copyToClipboard } from '$lib/utils';
import { basicSetup, EditorView } from 'codemirror';
import { Compartment, type Text } from '@codemirror/state';
import { Compartment, EditorState, type Text } from '@codemirror/state';
import { keymap, type ViewUpdate } from '@codemirror/view';
import { indentWithTab, isolateHistory } from '@codemirror/commands';
import { LanguageDescription, indentUnit } from '@codemirror/language';
@@ -30,6 +30,12 @@
type MergeDirection
} from './diffEditor';
type FileLabel = { name: string; path: string };
type ComparisonTexts = { initialContent: string; proposedContent: string };
export let mode: 'edit' | 'review' = 'edit';
export let leftFile: FileLabel | undefined = undefined;
export let rightFile: FileLabel | undefined = undefined;
export let onRefresh: (() => Promise<ComparisonTexts>) | undefined = undefined;
export let show = false;
export let initialContent = '';
export let proposedContent: string | undefined = undefined;
@@ -47,6 +53,11 @@
proposal: string;
path: string | null;
title: string;
mode: 'edit' | 'review';
hasProposal: boolean;
leftFile?: FileLabel;
rightFile?: FileLabel;
onRefresh?: () => Promise<ComparisonTexts>;
} | null = null;
let working = '';
let proposal = '';
@@ -63,8 +74,32 @@
let error = '';
let confirmDiscard = false;
let swapped = false;
let refreshing = false;
let editorRevision = 0;
$: displayedFiles = swapped
? [session?.rightFile, session?.leftFile]
: [session?.leftFile, session?.rightFile];
const paneFile = (side: string) =>
(side === 'a') !== swapped ? session?.leftFile : session?.rightFile;
const paneLabel = (side: string) =>
paneFile(side)?.name ?? (side === 'a' ? $i18n.t('Original') : $i18n.t('Proposed revision'));
const parentPath = (path: string) => path.replace(/[\\/][^\\/]*$/, '') || '/';
const begin = () => {
session = { original: initialContent, proposal: proposedContent ?? '', path: filePath, title };
session = {
original: initialContent,
proposal: proposedContent ?? '',
path: filePath,
title,
mode,
hasProposal: proposedContent !== undefined,
leftFile: leftFile && { ...leftFile },
rightFile: rightFile && { ...rightFile },
onRefresh
};
swapped = refreshing = false;
editorRevision++;
working = session.original;
proposal = session.proposal;
comparing = proposedContent !== undefined;
@@ -73,7 +108,10 @@
lineChanges = { removed: 0, added: 0 };
copied = null;
loading = approximate = false;
error = '';
error =
mode === 'review' && proposedContent === undefined
? $i18n.t('Both files are required for comparison.')
: '';
confirmDiscard = false;
};
$: if (show && !session) begin();
@@ -87,11 +125,13 @@
onClose();
};
const requestClose = () => {
if (session?.mode === 'review') return close();
if (session && (working !== session.original || proposal !== session.proposal))
confirmDiscard = true;
else close();
};
const apply = () => {
if (session?.mode === 'review') return;
try {
onApply(proposal);
close();
@@ -99,6 +139,36 @@
error = cause instanceof Error ? cause.message : String(cause);
}
};
const replaceReview = (texts: ComparisonTexts) => {
working = swapped ? texts.proposedContent : texts.initialContent;
proposal = swapped ? texts.initialContent : texts.proposedContent;
activeChunk = 0;
copied = null;
editorRevision++;
};
const swap = () => {
if (session?.mode !== 'review') return;
swapped = !swapped;
replaceReview({ initialContent: session.original, proposedContent: session.proposal });
};
const reload = async () => {
const current = session;
if (current?.mode !== 'review' || !current.onRefresh || refreshing) return;
refreshing = true;
error = '';
try {
const texts = await current.onRefresh();
if (session !== current || !show) return;
current.original = texts.initialContent;
current.proposal = texts.proposedContent;
replaceReview(texts);
} catch (cause) {
if (session === current && show)
error = cause instanceof Error ? cause.message : String(cause);
} finally {
if (session === current) refreshing = false;
}
};
const copyPane = async (side: 'a' | 'b') => {
const text = side === 'a' ? working : proposal;
const currentSession = session;
@@ -118,8 +188,10 @@
const refresh = (update?: ViewUpdate, side: 'a' | 'b' = 'a') => {
if (!merge) return;
if (!update || update.docChanged) {
working = merge.a.state.doc.toString();
proposal = merge.b.state.doc.toString();
if (session?.mode !== 'review') {
working = merge.a.state.doc.toString();
proposal = merge.b.state.doc.toString();
}
chunkCount = merge.chunks.length;
lineNumberDigits = String(Math.max(merge.a.state.doc.lines, merge.b.state.doc.lines)).length;
lineChanges = changedLineCounts(merge.a.state.doc, merge.b.state.doc, merge.chunks);
@@ -169,7 +241,7 @@
};
const copy = (direction: MergeDirection) => {
if (!merge || !merge.chunks[activeChunk]) return;
if (session?.mode === 'review' || !merge || !merge.chunks[activeChunk]) return;
const destination = direction === 'to-working' ? merge.a : merge.b;
destination.dispatch({
changes: copyChunk(
@@ -193,16 +265,20 @@
let alignmentGeometry = '';
const themes = [new Compartment(), new Compartment()];
const language = [new Compartment(), new Compartment()];
const path = session?.path;
const paths = ['a', 'b'].map((side) => paneFile(side)?.path ?? session?.path);
const review = session?.mode === 'review';
loading = true;
const extensions = (side: 'a' | 'b', index: number) => [
basicSetup,
EditorState.readOnly.of(review),
EditorView.editable.of(!review),
lineAlignment,
keymap.of([indentWithTab, { key: 'Mod-s', run: () => true }]),
indentUnit.of(' '),
EditorView.lineWrapping,
EditorView.contentAttributes.of({
'aria-label': side === 'a' ? $i18n.t('Original') : $i18n.t('Proposed revision')
'aria-label': paneLabel(side),
...(review ? { tabindex: '0', 'aria-readonly': 'true' } : {})
}),
themes[index].of(document.documentElement.classList.contains('dark') ? oneDark : []),
language[index].of([]),
@@ -289,13 +365,17 @@
attributes: true,
attributeFilter: ['class']
});
const match = path && LanguageDescription.matchFilename(languages, path);
if (match) {
const support = await match.load();
if (disposed) return;
view.a.dispatch({ effects: language[0].reconfigure(support) });
view.b.dispatch({ effects: language[1].reconfigure(support) });
}
await Promise.all(
paths.map(async (path, index) => {
const match = path && LanguageDescription.matchFilename(languages, path);
if (!match) return;
const support = await match.load();
if (!disposed)
(index === 0 ? view!.a : view!.b).dispatch({
effects: language[index].reconfigure(support)
});
})
);
} catch (cause) {
if (!disposed) {
error = cause instanceof Error ? cause.message : String(cause);
@@ -331,7 +411,7 @@
<h2 class="font-medium">{session.title || $i18n.t('Compare revision')}</h2>
{#if session.path}<p class="mt-1 truncate font-mono text-gray-500">{session.path}</p>{/if}
</div>
{#if !comparing}
{#if !comparing && session.mode === 'edit'}
<button type="button" class="primary" on:click={() => (comparing = true)}
>{$i18n.t('Compare')}</button
>
@@ -374,22 +454,41 @@
disabled={!chunkCount || activeChunk === chunkCount - 1}
on:click={() => reveal(activeChunk + 1)}><span aria-hidden="true">↓</span></button
>
<button
type="button"
class="secondary"
disabled={!chunkCount}
aria-label={$i18n.t('Copy change to original')}
title={$i18n.t('Copy change to original')}
on:click={() => copy('to-working')}>← {$i18n.t('Original')}</button
>
<button
type="button"
class="secondary"
disabled={!chunkCount}
aria-label={$i18n.t('Copy change to proposal')}
title={$i18n.t('Copy change to proposal')}
on:click={() => copy('to-proposal')}>{$i18n.t('Proposed revision')} →</button
>
{#if session.mode === 'edit'}
<button
type="button"
class="secondary"
disabled={!chunkCount}
aria-label={$i18n.t('Copy change to original')}
title={$i18n.t('Copy change to original')}
on:click={() => copy('to-working')}>← {$i18n.t('Original')}</button
>
<button
type="button"
class="secondary"
disabled={!chunkCount}
aria-label={$i18n.t('Copy change to proposal')}
title={$i18n.t('Copy change to proposal')}
on:click={() => copy('to-proposal')}>{$i18n.t('Proposed revision')} →</button
>
{:else}
<button type="button" class="secondary" disabled={loading} on:click={swap}
>{$i18n.t('Swap sides')}</button
>
{#if session.onRefresh}
<button
type="button"
class="secondary"
disabled={refreshing || loading}
on:click={reload}
>{refreshing
? $i18n.t('Refreshing...')
: error
? $i18n.t('Retry')
: $i18n.t('Refresh')}</button
>
{/if}
{/if}
</div>
{/if}
<button
@@ -400,140 +499,161 @@
>
</header>
{#if error}<p class="px-4 py-2 text-red-600" role="alert">{error}</p>{/if}
<div class="relative flex min-h-0 flex-1">
<div class="min-h-0 min-w-0 flex-1 overflow-x-auto px-4">
<div class="flex h-full min-w-[640px] flex-col">
<div class="grid shrink-0 grid-cols-2 gap-4 py-2">
{#each ['a', 'b'] as side}
{@const lines = (side === 'a' ? working : proposal).split('\n').length}
<div
class="pane-header flex min-w-0 items-center gap-2 rounded-lg bg-gray-50/70 px-3 py-2 text-gray-500 dark:bg-white/[0.03]"
>
<span class="min-w-0 truncate font-medium"
>{side === 'a' ? $i18n.t('Original') : $i18n.t('Proposed revision')}</span
{#if session.mode === 'edit' || session.hasProposal}
<div class="relative flex min-h-0 flex-1">
<div class="min-h-0 min-w-0 flex-1 overflow-x-auto px-4">
<div class="flex h-full min-w-[640px] flex-col">
<div class="grid shrink-0 grid-cols-2 gap-4 py-2">
{#each ['a', 'b'] as side, index}
{@const lines = (side === 'a' ? working : proposal).split(/\r\n|\r|\n/).length}
{@const file = displayedFiles[index]}
<div
class="pane-header flex min-w-0 items-center gap-2 rounded-lg bg-gray-50/70 px-3 py-2 text-gray-500 dark:bg-white/[0.03]"
>
{#if comparing && !loading}
<span
class="whitespace-nowrap"
class:text-red-600={side === 'a'}
class:dark:text-red-400={side === 'a'}
class:text-teal-700={side === 'b'}
class:dark:text-teal-400={side === 'b'}
title={$i18n.t('Lines containing changed text')}
<div class="min-w-0" title={file?.path}>
<span class="block truncate font-medium"
>{file?.name ??
(side === 'a' ? $i18n.t('Original') : $i18n.t('Proposed revision'))}</span
>
{#if file}<span class="block truncate text-[10px]" aria-label={file.path}
>{parentPath(file.path)}</span
>{/if}
</div>
{#if comparing && !loading}
<span
class="whitespace-nowrap"
class:text-red-600={side === 'a'}
class:dark:text-red-400={side === 'a'}
class:text-teal-700={side === 'b'}
class:dark:text-teal-400={side === 'b'}
title={$i18n.t('Lines containing changed text')}
>
<span aria-hidden="true">{side === 'a' ? '−' : '+'}</span>
{side === 'a'
? $i18n.t(
lineChanges.removed === 1
? '{{count}} removal'
: '{{count}} removals',
{ count: lineChanges.removed }
)
: $i18n.t(
lineChanges.added === 1
? '{{count}} addition'
: '{{count}} additions',
{ count: lineChanges.added }
)}
</span>
{/if}
<span class="ml-auto whitespace-nowrap text-[11px]"
>{$i18n.t(lines === 1 ? '{{count}} line' : '{{count}} lines', {
count: lines
})}</span
>
<span aria-hidden="true">{side === 'a' ? '−' : '+'}</span>
{side === 'a'
? $i18n.t(
lineChanges.removed === 1 ? '{{count}} removal' : '{{count}} removals',
{ count: lineChanges.removed }
)
: $i18n.t(
lineChanges.added === 1 ? '{{count}} addition' : '{{count}} additions',
{ count: lineChanges.added }
)}
</span>
{/if}
<span class="ml-auto whitespace-nowrap text-[11px]"
>{$i18n.t(lines === 1 ? '{{count}} line' : '{{count}} lines', {
count: lines
})}</span
>
<button
type="button"
class="shrink-0 rounded px-1 hover:text-gray-900 dark:hover:text-white"
aria-label={side === 'a'
? $i18n.t('Copy original to clipboard')
: $i18n.t('Copy proposed revision to clipboard')}
on:click={() => copyPane(side === 'a' ? 'a' : 'b')}
>
{copied?.side === side && copied.text === (side === 'a' ? working : proposal)
? $i18n.t('Copied')
: $i18n.t('Copy')}
</button>
</div>
{/each}
</div>
{#if comparing}
<div
class="merge-container min-h-0 flex-1"
style:--diff-line-digits={lineNumberDigits}
use:mountMerge
></div>
{:else}
<div class="grid min-h-0 flex-1 grid-cols-2 gap-4">
<FileCodeEditor
bind:value={working}
filePath={session.path}
ariaLabel={$i18n.t('Original')}
/>
<div class="min-h-0 min-w-0">
<FileCodeEditor
bind:value={proposal}
filePath={session.path}
ariaLabel={$i18n.t('Proposed revision')}
placeholder={$i18n.t('Paste a proposed revision here...')}
/>
</div>
<button
type="button"
class="shrink-0 rounded px-1 hover:text-gray-900 dark:hover:text-white"
aria-label={file
? $i18n.t('Copy {{name}} to clipboard', { name: file.name })
: side === 'a'
? $i18n.t('Copy original to clipboard')
: $i18n.t('Copy proposed revision to clipboard')}
on:click={() => copyPane(side === 'a' ? 'a' : 'b')}
>
{copied?.side === side && copied.text === (side === 'a' ? working : proposal)
? $i18n.t('Copied')
: $i18n.t('Copy')}
</button>
</div>
{/each}
</div>
{/if}
{#if comparing}
{#key editorRevision}
<div
class="merge-container min-h-0 flex-1"
style:--diff-line-digits={lineNumberDigits}
use:mountMerge
></div>
{/key}
{:else}
<div class="grid min-h-0 flex-1 grid-cols-2 gap-4">
<FileCodeEditor
bind:value={working}
filePath={session.path}
ariaLabel={$i18n.t('Original')}
/>
<div class="min-h-0 min-w-0">
<FileCodeEditor
bind:value={proposal}
filePath={session.path}
ariaLabel={$i18n.t('Proposed revision')}
placeholder={$i18n.t('Paste a proposed revision here...')}
/>
</div>
</div>
{/if}
</div>
</div>
{#if comparing}
<div
class="diff-overview"
role="slider"
tabindex={chunkCount ? 0 : -1}
aria-label={$i18n.t('Change overview')}
aria-orientation="vertical"
aria-valuemin={1}
aria-valuemax={Math.max(1, chunkCount)}
aria-valuenow={activeChunk + 1}
aria-valuetext={chunkCount
? $i18n.t('Change {{current}} of {{total}}', {
current: activeChunk + 1,
total: chunkCount
})
: $i18n.t('No differences')}
aria-disabled={!chunkCount}
on:click={navigateOverview}
on:keydown={overviewKeydown}
title={$i18n.t('Click to jump to a change. Use arrow keys to navigate.')}
>
<svg viewBox="0 0 20 1000" preserveAspectRatio="none" aria-hidden="true">
<rect class="overview-track" x="2" y="0" width="6" height="1000" rx="3" />
<rect class="overview-track" x="12" y="0" width="6" height="1000" rx="3" />
{#each overview as range, index}
<rect
class="overview-removed"
class:overview-active={index === activeChunk}
x="2"
y={Math.min(997, range.a.from * 1000)}
width="6"
height={Math.max(3, (range.a.to - range.a.from) * 1000)}
/>
<rect
class="overview-added"
class:overview-active={index === activeChunk}
x="12"
y={Math.min(997, range.b.from * 1000)}
width="6"
height={Math.max(3, (range.b.to - range.b.from) * 1000)}
/>
{/each}
</svg>
</div>
{/if}
</div>
{#if comparing}
<div
class="diff-overview"
role="slider"
tabindex={chunkCount ? 0 : -1}
aria-label={$i18n.t('Change overview')}
aria-orientation="vertical"
aria-valuemin={1}
aria-valuemax={Math.max(1, chunkCount)}
aria-valuenow={activeChunk + 1}
aria-valuetext={chunkCount
? $i18n.t('Change {{current}} of {{total}}', {
current: activeChunk + 1,
total: chunkCount
})
: $i18n.t('No differences')}
aria-disabled={!chunkCount}
on:click={navigateOverview}
on:keydown={overviewKeydown}
title={$i18n.t('Click to jump to a change. Use arrow keys to navigate.')}
{/if}
{#if session.mode === 'edit'}
<footer class="flex shrink-0 flex-wrap items-center gap-2 px-5 py-3">
<p class="mr-auto text-gray-500">
{applyDescription ??
$i18n.t('Apply uses the proposed revision on the right. Save your draft afterward.')}
</p>
<button type="button" class="secondary" on:click={requestClose}
>{$i18n.t('Cancel')}</button
>
<svg viewBox="0 0 20 1000" preserveAspectRatio="none" aria-hidden="true">
<rect class="overview-track" x="2" y="0" width="6" height="1000" rx="3" />
<rect class="overview-track" x="12" y="0" width="6" height="1000" rx="3" />
{#each overview as range, index}
<rect
class="overview-removed"
class:overview-active={index === activeChunk}
x="2"
y={Math.min(997, range.a.from * 1000)}
width="6"
height={Math.max(3, (range.a.to - range.a.from) * 1000)}
/>
<rect
class="overview-added"
class:overview-active={index === activeChunk}
x="12"
y={Math.min(997, range.b.from * 1000)}
width="6"
height={Math.max(3, (range.b.to - range.b.from) * 1000)}
/>
{/each}
</svg>
</div>
{/if}
</div>
<footer class="flex shrink-0 flex-wrap items-center gap-2 px-5 py-3">
<p class="mr-auto text-gray-500">
{applyDescription ??
$i18n.t('Apply uses the proposed revision on the right. Save your draft afterward.')}
</p>
<button type="button" class="secondary" on:click={requestClose}>{$i18n.t('Cancel')}</button>
<button type="button" class="primary" disabled={loading} on:click={apply}
>{applyLabel ?? $i18n.t('Apply to draft')}</button
>
</footer>
<button type="button" class="primary" disabled={loading} on:click={apply}
>{applyLabel ?? $i18n.t('Apply to draft')}</button
>
</footer>
{/if}
</div>
</Modal>
<ConfirmDialog
@@ -673,16 +793,33 @@
background: repeating-linear-gradient(135deg, transparent 0 3px, #94a3b814 3px 4px);
}
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedLine) {
background: #ef6b5520;
background: #3d1915;
color: #f4d0c7;
}
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedLine) {
background: #2dd4bf18;
background: #0d302a;
color: #a9e9dd;
}
:global(.dark) .merge-container :global(.cm-changedLine span) {
color: inherit;
}
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedText) {
background: #ef6b5555;
background: #77362c;
}
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedText) {
background: #2dd4bf45;
background: #176456;
}
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedLineGutter) {
background: #9b5549;
}
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedLineGutter) {
background: #3e9c89;
}
:global(.dark) .merge-container :global(.cm-editor) {
background: #101413;
}
:global(.dark) .merge-container :global(.cm-gutters) {
background: #101413 !important;
}
.merge-container :global(.cm-mergeViewEditors) {
gap: 1rem;