This commit is contained in:
Timothy Jaeryang Baek 2026-09-08 20:34:42 -04:00
parent 674760bfc1
commit 8556033c6b
4 changed files with 556 additions and 7 deletions

View file

@ -631,3 +631,59 @@ export const stopNotebookSession = async (
}).catch(() => null);
return res?.ok ?? false;
};
export type TerminalDiffLine = {
type: 'added' | 'removed' | 'context';
oldNumber: number | null;
newNumber: number | null;
content: string;
revisedContent?: string;
segments: { text: string; changed: boolean }[];
};
export type TerminalComparisonRequest = {
original: string;
revised: string;
ignore_whitespace: boolean;
};
export type TerminalComparison = {
original: { name: string; path: string; notices: string[] };
revised: { name: string; path: string; notices: string[] };
additions: number;
deletions: number;
hunks: { header: string; lines: TerminalDiffLine[] }[];
};
export const compareFiles = async (
baseUrl: string,
apiKey: string,
request: TerminalComparisonRequest,
sessionId?: string,
signal?: AbortSignal
): Promise<TerminalComparison> => {
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/files/compare`, {
method: 'POST',
signal,
headers: {
...bearerHeaders(apiKey),
'Content-Type': 'application/json',
...(sessionId ? { 'X-Session-Id': sessionId } : {})
},
body: JSON.stringify(request)
});
const body = await response.json().catch(() => null);
if (
response.status === 405 ||
(response.status === 404 && ((!body?.detail && !body?.error) || body.detail === 'Not Found'))
) {
throw new Error(
'File comparison is not available on this terminal. Update Open Terminal to use Compare.'
);
}
if (!response.ok)
throw new Error(
typeof body?.detail === 'string'
? body.detail
: (body?.error ?? `Comparison failed (${response.status})`)
);
return body;
};

View file

@ -50,6 +50,7 @@
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
import FilePreview from './FileNav/FilePreview.svelte';
import FileCompare from './FileNav/FileCompare.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte';
import Icon from './FileNav/Icon.svelte';
import FileTypeIcon from './FileNav/FileTypeIcon.svelte';
@ -69,6 +70,7 @@
let isDraggingHandle = false;
let containerEl: HTMLElement;
let terminalEnabled = true;
let comparePaths: [string, string] | null = null;
const onHandleMouseDown = (e: MouseEvent) => {
e.preventDefault();
@ -397,6 +399,8 @@
const terminalChanged = terminal && terminal.url !== prevTerminalUrl;
if (terminalChanged) prevTerminalUrl = terminal.url;
if (chatChanged || terminalChanged || !terminal) comparePaths = null;
if (mounted && terminal) {
if (chatChanged && chatId && !oldChatId) {
// Chat just got created (null → real ID): persist the current
@ -709,6 +713,7 @@
) => {
const terminal = selectedTerminal;
if (!terminal) return;
comparePaths = null;
const directory = clampToFileRoot(path);
if (options.restoreTree) {
restoreTreeState(directory);
@ -1275,6 +1280,11 @@
let selectionMode = false;
$: selectedCount = selectedEntries.size;
$: comparisonEntries = visibleEntries.filter((entry) => selectedEntries.has(entry.fullPath));
$: canCompare =
selectedCount === 2 &&
comparisonEntries.length === 2 &&
comparisonEntries.every((entry) => entry.type === 'file');
$: selectedEntriesWritable =
currentWritable &&
[...selectedEntries].every((path) => {
@ -1370,6 +1380,7 @@
// Escape to clear selection
const handleKeydown = (e: KeyboardEvent) => {
if (comparePaths) return;
if (e.key === 'Escape' && selectedCount > 0) {
e.preventDefault();
clearSelection();
@ -1379,7 +1390,12 @@
// Click outside panel to clear selection
const handleWindowClick = (e: MouseEvent) => {
if (directoryMenu) directoryMenu = null;
if (selectedCount > 0 && containerEl && !containerEl.contains(e.target as Node)) {
if (
!comparePaths &&
selectedCount > 0 &&
containerEl &&
!containerEl.contains(e.target as Node)
) {
clearSelection();
}
};
@ -1478,6 +1494,7 @@
const onVisibilityChange = () => {
if (
document.visibilityState === 'visible' &&
!comparePaths &&
!selectedFile &&
selectedTerminal &&
!terminalChatContextPending &&
@ -1576,7 +1593,7 @@
</div>
{/if}
{#if previewPort === null}
{#if previewPort === null && !comparePaths}
<FileNavToolbar
breadcrumbs={buildBreadcrumbs(currentPath)}
{selectedFile}
@ -1766,6 +1783,11 @@
{#if selectedCount > 0 && !isSearching}
<BulkActionBar
count={selectedCount}
{canCompare}
onCompare={() => {
if (canCompare)
comparePaths = [comparisonEntries[0].fullPath, comparisonEntries[1].fullPath];
}}
canDelete={selectedEntriesWritable}
onDelete={() => {
deleteTarget = { path: '__bulk__', name: `${selectedCount} items` };
@ -1783,16 +1805,24 @@
class="flex-1 overflow-y-auto min-h-0 min-w-0"
on:click={(e) => {
closeDirectoryMenu();
if (e.target === e.currentTarget && selectedCount > 0) clearSelection();
if (!comparePaths && e.target === e.currentTarget && selectedCount > 0) clearSelection();
}}
on:contextmenu={(e) => {
if (selectedFile || previewPort !== null || isSearching) return;
if (comparePaths || selectedFile || previewPort !== null || isSearching) return;
if ((e.target as HTMLElement)?.closest('[data-file-row]')) return;
e.preventDefault();
directoryMenu = { x: e.clientX, y: e.clientY };
}}
>
{#if previewPort !== null}
{#if comparePaths && selectedTerminal}
<FileCompare
paths={comparePaths}
baseUrl={selectedTerminal.url}
apiKey={selectedTerminal.key}
{chatId}
onBack={() => (comparePaths = null)}
/>
{:else if previewPort !== null}
<PortPreview
baseUrl={selectedTerminal?.url ?? ''}
port={previewPort}

View file

@ -1,12 +1,16 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import type { i18n as I18n } from 'i18next';
import Tooltip from '../../common/Tooltip.svelte';
import Icon from './Icon.svelte';
const i18n = getContext('i18n');
const i18n = getContext<Readable<I18n>>('i18n');
export let count: number = 0;
export let canDelete = true;
export let canCompare = false;
export let onCompare: () => void = () => {};
export let onDelete: () => void = () => {};
export let onDownload: () => void = () => {};
@ -17,10 +21,21 @@
<div
class="flex h-8 items-center gap-2 px-2 bg-gray-50 dark:bg-white/[0.03] shrink-0 border-t border-gray-50 dark:border-gray-850/30"
>
<span class="text-[0.6875rem] font-medium text-gray-600 dark:text-gray-400 flex-1 truncate">
<span class="text-[0.6875rem] font-normal text-gray-600 dark:text-gray-400 flex-1 truncate">
{$i18n.t('{{count}} selected', { count })}
</span>
<Tooltip content={$i18n.t('Compare')}>
<button
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30"
disabled={!canCompare}
on:click={onCompare}
aria-label={$i18n.t('Compare')}
>
<Icon name="split-horizontal" size={12} strokeWidth={1.4} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Select All')}>
<button
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"

View file

@ -0,0 +1,448 @@
<script lang="ts">
import { getContext, onMount, onDestroy } from 'svelte';
import type { Readable } from 'svelte/store';
import type { i18n as I18n } from 'i18next';
import { compareFiles, type TerminalComparison, type TerminalDiffLine } from '$lib/apis/terminal';
import Icon from './Icon.svelte';
import Spinner from '../../common/Spinner.svelte';
import Tooltip from '../../common/Tooltip.svelte';
import Dropdown from '../../common/Dropdown.svelte';
import DropdownMenu from '../../common/DropdownMenu.svelte';
export let baseUrl: string;
export let apiKey: string;
export let chatId: string | null = null;
export let paths: [string, string];
export let onBack: () => void;
const i18n = getContext<Readable<I18n>>('i18n');
let original = paths[0];
let revised = paths[1];
let mode: 'unified' | 'split' = 'unified';
let ignoreWhitespace = false;
let result: TerminalComparison | null = null;
let loading = true;
let error = '';
let controller: AbortController | null = null;
let menu: Dropdown;
let menuOpen = false;
const name = (path: string) => path.split(/[\\/]/).pop() ?? path;
async function run() {
controller?.abort();
const request = new AbortController();
controller = request;
loading = true;
error = '';
result = null;
try {
const response = await compareFiles(
baseUrl,
apiKey,
{ original, revised, ignore_whitespace: ignoreWhitespace },
chatId ?? undefined,
request.signal
);
if (!request.signal.aborted) result = response;
} catch (err) {
if (!request.signal.aborted) error = err instanceof Error ? err.message : String(err);
} finally {
if (!request.signal.aborted) loading = false;
}
}
function setMode(value: 'unified' | 'split') {
mode = value;
try {
localStorage.setItem('fileCompare:mode', value);
} catch {
/* Storage can be disabled. */
}
menu.close();
}
function whitespaceChanged() {
try {
localStorage.setItem('fileCompare:ignoreWhitespace', String(ignoreWhitespace));
} catch {
/* Storage can be disabled. */
}
void run();
}
function splitRows(lines: TerminalDiffLine[]) {
const rows: { original: TerminalDiffLine | null; revised: TerminalDiffLine | null }[] = [];
let index = 0;
while (index < lines.length) {
if (lines[index].type === 'context') {
const line = lines[index++];
rows.push({
original: line,
revised: {
...line,
content: line.revisedContent ?? line.content,
segments: [{ text: line.revisedContent ?? line.content, changed: false }]
}
});
continue;
}
const removed: TerminalDiffLine[] = [];
const added: TerminalDiffLine[] = [];
while (index < lines.length && lines[index].type !== 'context') {
const line = lines[index++];
(line.type === 'removed' ? removed : added).push(line);
}
for (let i = 0; i < Math.max(removed.length, added.length); i++) {
rows.push({ original: removed[i] ?? null, revised: added[i] ?? null });
}
}
return rows;
}
onMount(() => {
try {
mode = localStorage.getItem('fileCompare:mode') === 'split' ? 'split' : 'unified';
ignoreWhitespace = localStorage.getItem('fileCompare:ignoreWhitespace') === 'true';
} catch {
/* Use defaults when storage is disabled. */
}
void run();
});
onDestroy(() => controller?.abort());
</script>
<svelte:window
on:keydown|capture={(event) => {
if (event.key === 'Escape' && menuOpen) {
event.stopPropagation();
menu.close();
}
}}
/>
<div
class="flex h-full min-h-0 flex-col bg-white text-[0.75em] font-normal text-gray-600 dark:bg-black dark:text-gray-400"
aria-label={$i18n.t('Compare')}
>
<div
class="m-0 flex shrink-0 items-center gap-1 border-b border-gray-50 px-1 pt-0 pb-1.5 dark:border-gray-850/30"
>
<div class="flex shrink-0 items-center gap-0.5 px-1">
<Tooltip content={$i18n.t('Back')}>
<button
class="flex h-5 min-w-6 shrink-0 items-center justify-center rounded px-1.5 text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
on:click|stopPropagation={onBack}
aria-label={$i18n.t('Back')}
><Icon name="chevron-left" size={11} strokeWidth={1.5} /></button
>
</Tooltip>
</div>
<span class="min-w-0 flex-1 truncate font-normal">{$i18n.t('Compare')}</span>
<Tooltip content={$i18n.t('Swap')}>
<button
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
on:click={() => {
[original, revised] = [revised, original];
void run();
}}
aria-label={$i18n.t('Swap')}><Icon name="swap" size={11} strokeWidth={1.4} /></button
>
</Tooltip>
<Tooltip content={$i18n.t('Refresh')}>
<button
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
on:click={run}
disabled={loading}
aria-label={$i18n.t('Refresh')}
><Icon
name="refresh"
size={11}
strokeWidth={1.4}
class={loading ? 'animate-spin' : ''}
/></button
>
</Tooltip>
<Dropdown bind:this={menu} bind:show={menuOpen} align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Diff settings')}>
<button
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-400 transition-colors duration-100 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
aria-label={$i18n.t('Diff settings')}
><Icon
name={mode === 'unified' ? 'list' : 'split-horizontal'}
size={11}
strokeWidth={1.4}
/></button
>
</Tooltip>
<div slot="content">
<DropdownMenu className="min-w-[9.375rem] z-[9999999] font-normal">
{#each ['unified', 'split'] as value}
<button
type="button"
class="select-none font-normal transition"
aria-pressed={mode === value}
on:click={() => setMode(value as 'unified' | 'split')}
>
<Icon
name={value === 'unified' ? 'list' : 'split-horizontal'}
size={12}
strokeWidth={1.4}
/>
<span class="flex-1 text-left"
>{$i18n.t(value === 'unified' ? 'Unified' : 'Split')}</span
>
{#if mode === value}<Icon name="check" size={12} strokeWidth={1.4} />{/if}
</button>
{/each}
<hr class="border-gray-100 dark:border-gray-800" />
<button
type="button"
class="select-none font-normal transition"
aria-pressed={ignoreWhitespace}
on:click={() => {
ignoreWhitespace = !ignoreWhitespace;
whitespaceChanged();
}}
>
<span class="flex-1 text-left">{$i18n.t('Hide whitespace')}</span>
{#if ignoreWhitespace}<Icon name="check" size={12} strokeWidth={1.4} />{/if}
</button>
</DropdownMenu>
</div>
</Dropdown>
</div>
<div class="grid shrink-0 grid-cols-2 border-b border-black/5 dark:border-white/5">
{#each [{ label: 'Original', path: original }, { label: 'Revised', path: revised }] as file}
<div class="min-w-0 px-3 py-2" title={file.path}>
<div class="text-[0.916667em] text-gray-400 dark:text-gray-500">{$i18n.t(file.label)}</div>
<div class="truncate font-normal text-gray-700 dark:text-gray-300">{name(file.path)}</div>
</div>
{/each}
</div>
{#if loading}
<div class="flex items-center gap-2 p-4" role="status">
<Spinner className="size-3" />{$i18n.t('Extracting text and comparing…')}
</div>
{:else if error}
<div class="p-4" role="alert">
<p class="break-words">{error}</p>
<button class="control mt-2" on:click={run}>{$i18n.t('Retry')}</button>
</div>
{:else if result}
<div
class="flex shrink-0 items-center gap-2 border-b border-black/5 px-3 py-1 dark:border-white/5"
aria-live="polite"
>
<span
class="text-green-600 dark:text-green-400"
aria-label={$i18n.t('{{count}} added lines', { count: result.additions })}
>+{result.additions}</span
>
<span
class="text-red-500 dark:text-red-400"
aria-label={$i18n.t('{{count}} removed lines', { count: result.deletions })}
>{result.deletions}</span
>
<span class="ml-auto text-[0.916667em] text-gray-400">{$i18n.t('Extracted text lines')}</span>
</div>
{#if result.hunks.length === 0}
<p class="p-4" role="status">{$i18n.t('No text differences')}</p>
{:else}
<!-- Keyboard focus lets users scroll long comparisons without a pointer. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="diff-scroll min-h-0 flex-1 overflow-auto"
tabindex="0"
role="region"
aria-label={$i18n.t('File differences')}
>
<div class="diff-content" class:split={mode === 'split'}>
{#each result.hunks as hunk}
<div
class="hunk-header select-none bg-black/[0.025] px-3 text-gray-400 dark:bg-white/[0.025] dark:text-gray-600"
>
{hunk.header}
</div>
{#if mode === 'split'}
{#each splitRows(hunk.lines) as row}
<div class="split-row">
{#each ['original', 'revised'] as side}
{@const line = row[side as 'original' | 'revised']}
<div class="split-cell {line?.type ?? ''}">
<span class="number"
>{(side === 'original' ? line?.oldNumber : line?.newNumber) ?? ''}</span
>
<span class="marker" aria-hidden="true"
>{line?.type === 'added' ? '+' : line?.type === 'removed' ? '' : ' '}</span
>
<span class="code"
>{#if line}{#each line.segments as segment}<span
class:changed={segment.changed}>{segment.text}</span
>{/each}{:else}{' '}{/if}</span
>
</div>
{/each}
</div>
{/each}
{:else}
{#each hunk.lines as line}
<div class="unified-row {line.type}">
<span class="number">{line.oldNumber ?? ''}</span><span class="number"
>{line.newNumber ?? ''}</span
>
<span class="marker" aria-hidden="true"
>{line.type === 'added' ? '+' : line.type === 'removed' ? '' : ' '}</span
>
<span class="code"
>{#each line.segments as segment}<span class:changed={segment.changed}
>{segment.text}</span
>{/each}</span
>
</div>
{/each}
{/if}
{/each}
</div>
</div>
{/if}
{/if}
</div>
<style>
.control {
display: inline-flex;
min-height: 1.5rem;
align-items: center;
gap: 0.25rem;
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 1em;
}
.control:hover {
background: rgb(0 0 0 / 0.05);
}
:global(.dark) .control:hover {
background: rgb(255 255 255 / 0.06);
}
.control:disabled {
opacity: 0.4;
}
.control:focus-visible,
summary:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.diff-scroll {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.916667em;
line-height: 1.636364;
}
.diff-content {
width: max-content;
min-width: 100%;
}
.diff-content.split {
width: 100%;
}
.unified-row {
display: grid;
grid-template-columns: 2.75rem 2.75rem 1.25rem auto;
min-height: 1.636364em;
}
.split-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.split-cell {
display: grid;
grid-template-columns: 2.75rem 1.25rem minmax(0, 1fr);
min-width: 0;
min-height: 1.636364em;
}
.split-cell + .split-cell {
border-left: 1px solid rgb(0 0 0 / 0.05);
}
.number {
user-select: none;
color: var(--color-gray-400);
padding: 0 0.5rem;
text-align: right;
border-right: 1px solid rgb(0 0 0 / 0.05);
}
.marker {
user-select: none;
text-align: center;
white-space: pre;
}
.code {
white-space: pre;
padding-right: 0.5rem;
}
.split .code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.added {
background-color: var(--color-green-100);
color: var(--color-green-900);
box-shadow: inset 0.1875rem 0 0 #22c55e;
}
.removed {
background-color: var(--color-red-100);
color: var(--color-red-900);
background-image: repeating-linear-gradient(
-45deg,
#ef4444 0,
#ef4444 1px,
transparent 1px,
transparent 0.1875rem
);
background-position: left top;
background-repeat: repeat-y;
background-size: 0.1875rem 0.1875rem;
}
.added .marker {
color: var(--color-green-600);
}
.removed .marker {
color: var(--color-red-500);
}
.added .changed {
background: rgb(22 163 74 / 0.38);
box-shadow: inset 0 -0.125rem 0 rgb(21 128 61 / 0.42);
}
.removed .changed {
background: rgb(220 38 38 / 0.32);
box-shadow: inset 0 -0.125rem 0 rgb(185 28 28 / 0.38);
}
:global(.dark) .added {
background-color: color-mix(in oklab, var(--color-green-500) 15%, transparent);
color: var(--color-green-300);
box-shadow: inset 0.1875rem 0 0 #4ade80;
}
:global(.dark) .removed {
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
color: var(--color-red-300);
}
:global(.dark) .added .marker {
color: var(--color-green-400);
}
:global(.dark) .removed .marker {
color: var(--color-red-400);
}
:global(.dark) .number {
color: var(--color-gray-600);
border-color: rgb(255 255 255 / 0.04);
}
:global(.dark) .split-cell + .split-cell {
border-color: rgb(255 255 255 / 0.04);
}
:global(.dark) .added .changed {
background: rgb(34 197 94 / 0.42);
box-shadow: inset 0 -0.125rem 0 rgb(134 239 172 / 0.5);
}
:global(.dark) .removed .changed {
background: rgb(248 113 113 / 0.4);
box-shadow: inset 0 -0.125rem 0 rgb(252 165 165 / 0.48);
}
</style>