diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 3ed8de5322..a8b0ff19ad 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -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 => { + 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; +}; diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 570582f995..e2d9aed0af 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -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 @@ {/if} - {#if previewPort === null} + {#if previewPort === null && !comparePaths} 0 && !isSearching} { + 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} + (comparePaths = null)} + /> + {:else if previewPort !== null} 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>('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 @@
- + {$i18n.t('{{count}} selected', { count })} + + + + + +
+ {$i18n.t('Compare')} + + + + + + + + + + +
+ + {#each ['unified', 'split'] as value} + + {/each} +
+ +
+
+
+ +
+ {#each [{ label: 'Original', path: original }, { label: 'Revised', path: revised }] as file} +
+
{$i18n.t(file.label)}
+
{name(file.path)}
+
+ {/each} +
+ {#if loading} +
+ {$i18n.t('Extracting text and comparing…')} +
+ {:else if error} + + {:else if result} +
+ +{result.additions} + −{result.deletions} + {$i18n.t('Extracted text lines')} +
+ {#if result.hunks.length === 0} +

{$i18n.t('No text differences')}

+ {:else} + + +
+
+ {#each result.hunks as hunk} +
+ {hunk.header} +
+ {#if mode === 'split'} + {#each splitRows(hunk.lines) as row} +
+ {#each ['original', 'revised'] as side} + {@const line = row[side as 'original' | 'revised']} +
+ {(side === 'original' ? line?.oldNumber : line?.newNumber) ?? ''} + + {#if line}{#each line.segments as segment}{segment.text}{/each}{:else}{' '}{/if} +
+ {/each} +
+ {/each} + {:else} + {#each hunk.lines as line} +
+ {line.oldNumber ?? ''}{line.newNumber ?? ''} + + {#each line.segments as segment}{segment.text}{/each} +
+ {/each} + {/if} + {/each} +
+
+ {/if} + {/if} + + +