This commit is contained in:
Timothy Jaeryang Baek 2026-10-14 13:58:46 +04:00
parent ab518a4321
commit b374e0bb26
2 changed files with 85 additions and 10 deletions

View file

@ -1,6 +1,7 @@
<script lang="ts">
import '$lib/utils/codemirror';
import { getContext } from 'svelte';
import { copyToClipboard } from '$lib/utils';
import { basicSetup, EditorView } from 'codemirror';
import { Compartment } from '@codemirror/state';
import { keymap, type ViewUpdate } from '@codemirror/view';
@ -17,6 +18,7 @@
copyChunk,
selectedChunk,
chunkOverview,
changedLineCounts,
nearestOverviewChunk,
diffEditorConfig,
type MergeDirection
@ -44,6 +46,8 @@
let activeChunk = 0;
let overview: ReturnType<typeof chunkOverview> = [];
let chunkCount = 0;
let lineChanges = { removed: 0, added: 0 };
let copied: { side: 'a' | 'b'; text: string } | null = null;
let approximate = false;
let loading = false;
let error = '';
@ -56,6 +60,8 @@
comparing = proposedContent !== undefined;
activeChunk = chunkCount = 0;
overview = [];
lineChanges = { removed: 0, added: 0 };
copied = null;
loading = approximate = false;
error = '';
confirmDiscard = false;
@ -83,6 +89,16 @@
error = cause instanceof Error ? cause.message : String(cause);
}
};
const copyPane = async (side: 'a' | 'b') => {
const text = side === 'a' ? working : proposal;
const currentSession = session;
const success = await copyToClipboard(text);
if (session !== currentSession) return;
if (success) {
copied = { side, text };
error = '';
} else error = $i18n.t('Could not copy to clipboard. Please try again.');
};
const preventSave = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
@ -95,6 +111,7 @@
working = merge.a.state.doc.toString();
proposal = merge.b.state.doc.toString();
chunkCount = merge.chunks.length;
lineChanges = changedLineCounts(merge.a.state.doc, merge.b.state.doc, merge.chunks);
overview = chunkOverview(merge.a.state.doc, merge.b.state.doc, merge.chunks);
approximate = merge.chunks.some((chunk) => !chunk.precise);
}
@ -317,16 +334,54 @@
<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">
<div
class="rounded-lg bg-gray-50/70 px-3 py-2 font-medium text-gray-500 dark:bg-white/[0.03]"
>
{$i18n.t('Working copy')}
</div>
<div
class="rounded-lg bg-gray-50/70 px-3 py-2 font-medium text-gray-500 dark:bg-white/[0.03]"
>
{$i18n.t('Proposed revision')}
</div>
{#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('Working copy') : $i18n.t('Proposed revision')}</span
>
{#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
>
<button
type="button"
class="shrink-0 rounded px-1 hover:text-gray-900 dark:hover:text-white"
aria-label={side === 'a'
? $i18n.t('Copy working copy 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" use:mountMerge></div>

View file

@ -6,6 +6,26 @@ export const diffEditorConfig: DiffConfig = { timeout: 100 };
export type MergeDirection = 'to-working' | 'to-proposal';
export function changedLineCounts(a: Text, b: Text, chunks: readonly Chunk[]) {
const count = (doc: Text, side: 'A' | 'B') => {
let total = 0;
let lastLine = 0;
for (const chunk of chunks) {
for (const change of chunk.changes) {
const from = chunk[`from${side}`] + change[`from${side}`];
const to = chunk[`from${side}`] + change[`to${side}`];
if (from === to) continue;
const first = doc.lineAt(from).number;
const last = doc.lineAt(to - 1).number;
total += Math.max(0, last - Math.max(first - 1, lastLine));
lastLine = last;
}
}
return total;
};
return { removed: count(a, 'A'), added: count(b, 'B') };
}
export function chunkOverview(a: Text, b: Text, chunks: readonly Chunk[]) {
const range = (doc: Text, from: number, to: number) => ({
from: (doc.lineAt(Math.min(from, doc.length)).number - 1) / doc.lines,