This commit is contained in:
Timothy Jaeryang Baek 2026-10-14 13:55:08 +04:00
parent 5060923c63
commit ab518a4321
4 changed files with 671 additions and 34 deletions

View file

@ -0,0 +1,536 @@
<script lang="ts">
import '$lib/utils/codemirror';
import { getContext } from 'svelte';
import { basicSetup, EditorView } from 'codemirror';
import { Compartment } from '@codemirror/state';
import { keymap, type ViewUpdate } from '@codemirror/view';
import { indentWithTab, isolateHistory } from '@codemirror/commands';
import { LanguageDescription, indentUnit } from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { oneDark } from '@codemirror/theme-one-dark';
import type { MergeView } from '@codemirror/merge';
import Modal from './Modal.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import FileCodeEditor from '$lib/components/chat/FileNav/FileCodeEditor.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import {
copyChunk,
selectedChunk,
chunkOverview,
nearestOverviewChunk,
diffEditorConfig,
type MergeDirection
} from './diffEditor';
export let show = false;
export let initialContent = '';
export let proposedContent: string | undefined = undefined;
export let filePath: string | null = null;
export let title = '';
export let onApply: (content: string) => void = () => {};
export let onClose: () => void = () => {};
const i18n = getContext<any>('i18n');
let session: {
original: string;
proposal: string;
path: string | null;
title: string;
} | null = null;
let working = '';
let proposal = '';
let comparing = false;
let merge: MergeView | null = null;
let activeChunk = 0;
let overview: ReturnType<typeof chunkOverview> = [];
let chunkCount = 0;
let approximate = false;
let loading = false;
let error = '';
let confirmDiscard = false;
const begin = () => {
session = { original: initialContent, proposal: proposedContent ?? '', path: filePath, title };
working = session.original;
proposal = session.proposal;
comparing = proposedContent !== undefined;
activeChunk = chunkCount = 0;
overview = [];
loading = approximate = false;
error = '';
confirmDiscard = false;
};
$: if (show && !session) begin();
$: if (!show && session) {
session = null;
confirmDiscard = false;
}
const close = () => {
show = false;
onClose();
};
const requestClose = () => {
if (session && (working !== session.original || proposal !== session.proposal))
confirmDiscard = true;
else close();
};
const apply = () => {
try {
onApply(working);
close();
} catch (cause) {
error = cause instanceof Error ? cause.message : String(cause);
}
};
const preventSave = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
event.stopPropagation();
}
};
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();
chunkCount = merge.chunks.length;
overview = chunkOverview(merge.a.state.doc, merge.b.state.doc, merge.chunks);
approximate = merge.chunks.some((chunk) => !chunk.precise);
}
if (update?.selectionSet) {
const index = selectedChunk(merge.chunks, side, update.state.selection.main.head);
if (index >= 0) activeChunk = index;
}
activeChunk = Math.max(0, Math.min(activeChunk, chunkCount - 1));
};
const reveal = (index: number) => {
if (!merge || !chunkCount) return;
activeChunk = Math.max(0, Math.min(index, chunkCount - 1));
const chunk = merge.chunks[activeChunk];
for (const [view, start] of [
[merge.a, chunk.fromA],
[merge.b, chunk.fromB]
] as const) {
const position = Math.min(start, view.state.doc.length);
view.dispatch({
selection: { anchor: position },
effects: EditorView.scrollIntoView(position, { y: 'center' })
});
}
};
const navigateOverview = (event: MouseEvent) => {
const bounds =
event.currentTarget instanceof HTMLElement && event.currentTarget.getBoundingClientRect();
if (!bounds || !chunkCount) return;
const side = event.clientX - bounds.left < bounds.width / 2 ? 'a' : 'b';
reveal(nearestOverviewChunk(overview, side, (event.clientY - bounds.top) / bounds.height));
};
const overviewKeydown = (event: KeyboardEvent) => {
const positions: Record<string, number> = {
ArrowUp: activeChunk - 1,
ArrowDown: activeChunk + 1,
Home: 0,
End: chunkCount - 1
};
const index = positions[event.key];
if (index === undefined) return;
event.preventDefault();
event.stopPropagation();
reveal(index);
};
const copy = (direction: MergeDirection) => {
if (!merge || !merge.chunks[activeChunk]) return;
const destination = direction === 'to-working' ? merge.a : merge.b;
destination.dispatch({
changes: copyChunk(
merge.a.state.doc,
merge.b.state.doc,
merge.chunks[activeChunk],
direction
),
annotations: isolateHistory.of('full'),
userEvent: 'input.merge'
});
refresh();
};
const mountMerge = (node: HTMLElement) => {
let disposed = false;
let view: MergeView | null = null;
let observer: MutationObserver | undefined;
const themes = [new Compartment(), new Compartment()];
const language = [new Compartment(), new Compartment()];
const path = session?.path;
loading = true;
const extensions = (side: 'a' | 'b', index: number) => [
basicSetup,
keymap.of([indentWithTab, { key: 'Mod-s', run: () => true }]),
indentUnit.of(' '),
EditorView.lineWrapping,
EditorView.contentAttributes.of({
'aria-label': side === 'a' ? $i18n.t('Working copy') : $i18n.t('Proposed revision')
}),
themes[index].of(document.documentElement.classList.contains('dark') ? oneDark : []),
language[index].of([]),
EditorView.updateListener.of((update) => {
if (update.docChanged || update.selectionSet) refresh(update, side);
}),
EditorView.theme({
'&': { fontSize: '0.75rem' },
'.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
'&.cm-focused': { outline: 'none' }
})
];
void (async () => {
try {
const { MergeView } = await import('@codemirror/merge');
if (disposed) return;
view = new MergeView({
a: { doc: working, extensions: extensions('a', 0) },
b: { doc: proposal, extensions: extensions('b', 1) },
parent: node,
diffConfig: diffEditorConfig
});
merge = view;
refresh();
loading = false;
observer = new MutationObserver(() => {
const theme = document.documentElement.classList.contains('dark') ? oneDark : [];
view?.a.dispatch({ effects: themes[0].reconfigure(theme) });
view?.b.dispatch({ effects: themes[1].reconfigure(theme) });
});
observer.observe(document.documentElement, {
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) });
}
} catch (cause) {
if (!disposed) {
error = cause instanceof Error ? cause.message : String(cause);
loading = false;
}
}
})();
return {
destroy: () => {
disposed = true;
observer?.disconnect();
view?.destroy();
if (merge === view) merge = null;
}
};
};
</script>
{#if show && session}
<Modal
{show}
ariaLabel={session.title || $i18n.t('Compare revision')}
size="full"
containerClassName="p-2 sm:p-4"
className="h-[calc(100dvh-2rem)] overflow-hidden rounded-2xl bg-white dark:bg-gray-900 !border-0"
onRequestClose={requestClose}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="diff-editor flex h-full min-h-0 flex-col text-xs" on:keydown|capture={preventSave}>
<header class="flex shrink-0 flex-wrap items-center gap-3 px-5 pt-4 pb-2">
<div class="min-w-0 flex-1">
<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}
<button type="button" class="primary" on:click={() => (comparing = true)}
>{$i18n.t('Compare')}</button
>
{/if}
{#if comparing}
<div class="comparison-controls flex flex-wrap items-center gap-2">
<span class="whitespace-nowrap text-gray-500" role="status" aria-live="polite"
>{loading
? $i18n.t('Loading...')
: chunkCount
? $i18n.t('Change {{current}} of {{total}}', {
current: activeChunk + 1,
total: chunkCount
})
: $i18n.t('No differences')}</span
>
{#if approximate}<span
role="img"
class="text-gray-500"
aria-label={$i18n.t('Approximate comparison')}
title={$i18n.t(
'Comparison reached its time limit; some changes are grouped together.'
)}>≈</span
>{/if}
<button
type="button"
class="secondary"
aria-label={$i18n.t('Previous change')}
title={$i18n.t('Previous change')}
disabled={!chunkCount || activeChunk === 0}
on:click={() => reveal(activeChunk - 1)}><span aria-hidden="true">↑</span></button
>
<button
type="button"
class="secondary"
aria-label={$i18n.t('Next change')}
title={$i18n.t('Next change')}
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 working copy')}
title={$i18n.t('Copy change to working copy')}
on:click={() => copy('to-working')}>← {$i18n.t('Working copy')}</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
>
</div>
{/if}
<button
type="button"
class="comparison-close rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label={$i18n.t('Close')}
on:click={requestClose}><XMark className="size-4" /></button
>
</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">
<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>
</div>
{#if comparing}
<div class="merge-container min-h-0 flex-1" 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('Working copy')}
/>
<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>
<footer class="flex shrink-0 flex-wrap items-center gap-2 px-5 py-3">
<p class="mr-auto text-gray-500">
{$i18n.t('Apply uses the left working copy. 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}
>{$i18n.t('Apply to draft')}</button
>
</footer>
</div>
</Modal>
<ConfirmDialog
bind:show={confirmDiscard}
title={$i18n.t('Discard comparison changes?')}
message={$i18n.t(
'Your changes in this comparison will be discarded. The original draft will stay unchanged.'
)}
confirmLabel={$i18n.t('Discard')}
onConfirm={close}
/>
{/if}
<style>
.comparison-controls {
margin-left: auto;
}
.diff-overview {
flex: 0 0 20px;
margin: 2.75rem 6px 0 -10px;
cursor: pointer;
border-radius: 4px;
}
.diff-overview:focus-visible {
outline: 2px solid var(--color-gray-500);
outline-offset: 2px;
}
.diff-overview svg {
width: 100%;
height: 100%;
display: block;
}
.overview-track {
fill: #94a3b81a;
}
.overview-removed {
fill: #f0a394;
}
.overview-added {
fill: #7dd3c5;
}
.overview-active.overview-removed {
fill: #e4654c;
}
.overview-active.overview-added {
fill: #0d9488;
}
@media (max-width: 900px) {
.comparison-controls {
order: 3;
width: 100%;
}
.comparison-close {
order: 2;
}
}
.secondary,
.primary {
border-radius: 0.5rem;
padding: 0.375rem 0.625rem;
}
.secondary {
background: var(--color-gray-100);
color: var(--color-gray-700);
}
.primary {
background: var(--color-gray-900);
color: white;
}
button:disabled {
opacity: 0.4;
}
button:focus-visible {
outline: 2px solid var(--color-gray-500);
outline-offset: 2px;
}
:global(.dark) .secondary {
background: var(--color-gray-800);
color: var(--color-gray-200);
}
:global(.dark) .primary {
background: var(--color-gray-100);
color: var(--color-gray-900);
}
.merge-container :global(.cm-mergeView) {
height: 100%;
overflow: auto;
}
.merge-container :global(.cm-mergeViewEditor) {
min-width: 0;
}
.merge-container :global(.cm-merge-a .cm-changedLine) {
background: #fce9e6;
}
.merge-container :global(.cm-merge-b .cm-changedLine) {
background: #e0f5f1;
}
.merge-container :global(.cm-merge-a .cm-changedText) {
background: #f9b9ad;
}
.merge-container :global(.cm-merge-b .cm-changedText) {
background: #a3e3d8;
}
.merge-container :global(.cm-changedText) {
border-radius: 3px;
}
.merge-container :global(.cm-mergeSpacer) {
background: repeating-linear-gradient(135deg, transparent 0 3px, #94a3b814 3px 4px);
}
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedLine) {
background: #ef6b5520;
}
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedLine) {
background: #2dd4bf18;
}
:global(.dark) .merge-container :global(.cm-merge-a .cm-changedText) {
background: #ef6b5555;
}
:global(.dark) .merge-container :global(.cm-merge-b .cm-changedText) {
background: #2dd4bf45;
}
.merge-container :global(.cm-mergeViewEditors) {
gap: 1rem;
}
.diff-editor :global(.cm-gutters) {
border: 0;
background: transparent;
}
</style>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy } from 'svelte';
import { fade } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
@ -10,15 +10,18 @@
export let size = 'md';
export let containerClassName = 'p-3';
export let className = 'bg-white dark:bg-gray-900 rounded-4xl';
export let onRequestClose: (() => void) | null = null;
export let ariaLabel: string | undefined = undefined;
const requestClose = () => (onRequestClose ? onRequestClose() : (show = false));
let modalElement = null;
let mounted = false;
let modalElement: HTMLDivElement | null = null;
let previousOverflow: string | null = null;
// Create focus trap to trap user tabs inside modal
// https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html
// https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html
let focusTrap: FocusTrap.FocusTrap | null = null;
const sizeToWidth = (size) => {
const sizeToWidth = (size: string) => {
if (size === 'full') {
return 'w-full';
}
@ -48,8 +51,7 @@
matchKeybinding(event) === Shortcut.CLOSE_MODAL)) &&
isTopModal()
) {
console.log('Escape');
show = false;
requestClose();
}
};
@ -58,20 +60,31 @@
return modals.length && modals[modals.length - 1] === modalElement;
};
onMount(() => {
mounted = true;
});
let handleOutsidePointerDown;
let handleModalFocusIn;
let handleOutsidePointerDown: ((event: PointerEvent) => void) | undefined;
let handleModalFocusIn: (() => void) | undefined;
const release = () => {
if (typeof document === 'undefined') return;
focusTrap?.deactivate();
focusTrap = null;
if (handleOutsidePointerDown)
document.removeEventListener('pointerdown', handleOutsidePointerDown, true);
if (handleModalFocusIn) modalElement?.removeEventListener('focusin', handleModalFocusIn);
window.removeEventListener('keydown', handleKeyDown);
if (previousOverflow !== null) {
document.body.style.overflow = previousOverflow;
previousOverflow = null;
}
};
$: if (show && modalElement) {
document.body.appendChild(modalElement);
focusTrap = FocusTrap.createFocusTrap(modalElement, {
escapeDeactivates: false,
allowOutsideClick: (e) => {
return (
e.target.closest('[data-sonner-toast]') !== null ||
e.target.closest('.modal-content') === null
e.target instanceof Element &&
(e.target.closest('[data-sonner-toast]') !== null ||
e.target.closest('.modal-content') === null)
);
}
});
@ -79,7 +92,12 @@
// Auto-pause focus trap when interacting with portaled content (e.g. Dropdown)
handleOutsidePointerDown = (e) => {
if (focusTrap && modalElement && !modalElement.contains(e.target)) {
if (
focusTrap &&
modalElement &&
e.target instanceof Node &&
!modalElement.contains(e.target)
) {
focusTrap.pause();
}
};
@ -92,28 +110,16 @@
modalElement.addEventListener('focusin', handleModalFocusIn);
window.addEventListener('keydown', handleKeyDown);
previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
} else if (modalElement) {
if (focusTrap) {
focusTrap.deactivate();
focusTrap = null;
}
if (handleOutsidePointerDown) {
document.removeEventListener('pointerdown', handleOutsidePointerDown, true);
}
if (handleModalFocusIn) {
modalElement.removeEventListener('focusin', handleModalFocusIn);
}
window.removeEventListener('keydown', handleKeyDown);
document.body.removeChild(modalElement);
document.body.style.overflow = 'unset';
release();
modalElement.remove();
}
onDestroy(() => {
show = false;
if (focusTrap) {
focusTrap.deactivate();
}
release();
if (modalElement && modalElement.parentNode === document.body) {
document.body.removeChild(modalElement);
}
@ -127,13 +133,13 @@
<div
bind:this={modalElement}
aria-modal="true"
aria-label={ariaLabel}
role="dialog"
tabindex="-1"
class="modal fixed top-0 right-0 left-0 bottom-0 bg-black/45 dark:bg-black/60 w-full h-screen max-h-[100dvh] {containerClassName} flex justify-center z-9999 overflow-y-auto overscroll-contain"
style="scrollbar-gutter: stable;"
in:fade={{ duration: 10 }}
on:mousedown={() => {
show = false;
}}
on:mousedown={requestClose}
>
<div
class="m-auto max-w-full {sizeToWidth(size)} {size !== 'full'

View file

@ -0,0 +1,56 @@
import type { ChangeSpec, Text } from '@codemirror/state';
import type { Chunk, DiffConfig } from '@codemirror/merge';
// A scanLimit also gives up based on document length, even for sparse, tiny edits.
export const diffEditorConfig: DiffConfig = { timeout: 100 };
export type MergeDirection = 'to-working' | 'to-proposal';
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,
to: doc.lineAt(Math.min(Math.max(from, to - 1), doc.length)).number / doc.lines
});
return chunks.map((chunk) => ({
a: range(a, chunk.fromA, chunk.toA),
b: range(b, chunk.fromB, chunk.toB)
}));
}
export function nearestOverviewChunk(
ranges: ReturnType<typeof chunkOverview>,
side: 'a' | 'b',
position: number
) {
let nearest = -1;
let distance = Infinity;
ranges.forEach((range, index) => {
const delta = Math.max(range[side].from - position, position - range[side].to, 0);
if (delta < distance) {
nearest = index;
distance = delta;
}
});
return nearest;
}
export function copyChunk(a: Text, b: Text, chunk: Chunk, direction: MergeDirection): ChangeSpec {
const toWorking = direction === 'to-working';
const source = toWorking ? b : a;
const destination = toWorking ? a : b;
const [from, to, sourceFrom, sourceTo] = toWorking
? [chunk.fromA, chunk.toA, chunk.fromB, chunk.toB]
: [chunk.fromB, chunk.toB, chunk.fromA, chunk.toA];
// Chunk ends include a virtual newline at EOF; only insert it between actual lines.
let insert = source.sliceString(sourceFrom, Math.max(sourceFrom, sourceTo - 1));
if (sourceFrom !== sourceTo && to <= destination.length) insert += '\n';
return { from, to: Math.min(to, destination.length), insert };
}
export function selectedChunk(chunks: readonly Chunk[], side: 'a' | 'b', position: number) {
return chunks.findIndex((chunk) => {
const from = side === 'a' ? chunk.fromA : chunk.fromB;
const to = side === 'a' ? chunk.toA : chunk.toB;
return position >= from && (position < to || position === from);
});
}

View file

@ -22,6 +22,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import FileCodeEditor from '$lib/components/chat/FileNav/FileCodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import DiffEditorModal from '$lib/components/common/DiffEditorModal.svelte';
import {
getSkillFiles,
getSkillFile,
@ -61,6 +62,8 @@
let showFileDrawer = false;
let sidebarWidth = 256;
let codeEditor: FileCodeEditor | undefined;
let showComparison = false;
let comparison: { path: string; content: string; key: string; request: number } | null = null;
let value = '';
let loadedValue = '';
let loadedPath = '';
@ -150,6 +153,27 @@
dirty = true;
}
};
const compareRevision = () => {
if (readOnly || loading || loadError || !loadedPath || objectUrl) return;
flush();
comparison = { path: loadedPath, content: value, key: loadKey, request: requestId };
preview = false;
showComparison = true;
};
const applyComparison = (content: string) => {
if (
readOnly ||
!comparison ||
comparison.path !== loadedPath ||
comparison.key !== loadKey ||
comparison.request !== requestId ||
!files.some((file) => file.path === comparison?.path)
)
throw new Error($i18n.t('The file has changed. Reopen the comparison before applying.'));
codeEditor?.setValue(content);
value = content;
flush();
};
const fileDrawer = (node: HTMLElement) => {
const trap = createFocusTrap(node, { escapeDeactivates: false });
trap.activate();
@ -480,6 +504,14 @@
});
</script>
<DiffEditorModal
bind:show={showComparison}
initialContent={comparison?.content ?? ''}
filePath={comparison?.path ?? null}
onApply={applyComparison}
onClose={() => (comparison = null)}
/>
<ConfirmDialog
bind:show={dialog.show}
title={dialog.title}
@ -564,6 +596,13 @@
</button>
{/if}
<span class="min-w-0 flex-1 truncate text-gray-500" title={selected}>{selected}</span>
{#if !readOnly && loadedPath && !objectUrl && !loading && !loadError}
<button
type="button"
class="rounded-lg px-1.5 py-0.5 text-gray-500 transition hover:text-gray-900 dark:hover:text-gray-100"
on:click={compareRevision}>{$i18n.t('Compare revision')}</button
>
{/if}
{#if /\.md$/i.test(selected) && !loading}<button
type="button"
class="rounded-lg px-1.5 py-0.5 text-gray-500 transition hover:text-gray-900 dark:hover:text-gray-100"