From 254e29b9af634145dde0450451a36f0dfbd8f421 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 8 Sep 2026 13:19:14 -0400 Subject: [PATCH] refac --- .../components/chat/Messages/CodeBlock.svelte | 35 +++- .../components/chat/Messages/DiffBlock.svelte | 197 ++++++++++++++++++ src/lib/components/chat/Messages/diff.ts | 113 ++++++++++ 3 files changed, 334 insertions(+), 11 deletions(-) create mode 100644 src/lib/components/chat/Messages/DiffBlock.svelte create mode 100644 src/lib/components/chat/Messages/diff.ts diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index cbd122c001..641ee265f1 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -2,6 +2,8 @@ import hljs from 'highlight.js'; import { toast } from 'svelte-sonner'; import { getContext, onMount, tick, onDestroy } from 'svelte'; + import type { Writable } from 'svelte/store'; + import type { i18n as i18nType } from 'i18next'; import { config, pyodideWorker as pyodideWorkerStore } from '$lib/stores'; import { createPyodideWorker } from '$lib/pyodide/createPyodideWorker'; @@ -18,6 +20,7 @@ import equal from 'fast-deep-equal'; import CodeEditor from '$lib/components/common/CodeEditor.svelte'; + import DiffBlock from './DiffBlock.svelte'; import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; @@ -26,7 +29,7 @@ import Cube from '$lib/components/icons/Cube.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; - const i18n = getContext('i18n'); + const i18n = getContext>('i18n'); export let id = ''; export let edit = true; @@ -52,13 +55,9 @@ let localPyodideWorker = null; let _code = ''; - $: if (code) { - updateCode(); - } - - const updateCode = () => { - _code = code; - }; + $: _code = code; + $: isDiff = ['diff', 'patch'].includes(lang.trim().toLowerCase()); + let editingDiff = false; let _token = null; @@ -469,6 +468,18 @@
+ {#if isDiff && edit} + + {/if}
{#if !collapsed} - {#if edit} + {#if isDiff && !(edit && editingDiff)} + + {:else if edit} { @@ -575,7 +588,7 @@ > {$i18n.t('{{COUNT}} hidden lines', { - COUNT: code.split('\n').length + COUNT: (isDiff ? _code : code).split('\n').length })} diff --git a/src/lib/components/chat/Messages/DiffBlock.svelte b/src/lib/components/chat/Messages/DiffBlock.svelte new file mode 100644 index 0000000000..fa4f8fa73f --- /dev/null +++ b/src/lib/components/chat/Messages/DiffBlock.svelte @@ -0,0 +1,197 @@ + + +
+
{#each rows as row, index}{#if showNumbers}{/if}{row.prefix}{#each row.segments as segment}{segment.text}{/each}{index < rows.length - 1 ? '\n' : ''}{/each}
+
+ + diff --git a/src/lib/components/chat/Messages/diff.ts b/src/lib/components/chat/Messages/diff.ts new file mode 100644 index 0000000000..de0c3624cd --- /dev/null +++ b/src/lib/components/chat/Messages/diff.ts @@ -0,0 +1,113 @@ +export type DiffSegment = { text: string; changed: boolean }; +export type DiffRow = { + type: 'file' | 'hunk' | 'addition' | 'deletion' | 'meta' | 'context'; + prefix: string; + content: string; + oldNumber: number | null; + newNumber: number | null; + segments: DiffSegment[]; +}; + +export function parseDiffRows(code: string): DiffRow[] { + let oldNumber = 0; + let newNumber = 0; + let oldRemaining = 0; + let newRemaining = 0; + const rows = code.split('\n').map((text): DiffRow => { + const row: DiffRow = { + type: 'context', + prefix: '', + content: text, + oldNumber: null, + newNumber: null, + segments: [] + }; + const hunk = text.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + const inHunk = oldRemaining > 0 || newRemaining > 0; + if (hunk) { + row.type = 'hunk'; + oldNumber = Number(hunk[1]); + newNumber = Number(hunk[3]); + oldRemaining = Number(hunk[2] ?? 1); + newRemaining = Number(hunk[4] ?? 1); + } else if ( + text.startsWith('diff ') || + (!inHunk && + /^(index |---(?:\s|$)|\+\+\+(?:\s|$)|(?:old|new|deleted file|new file) mode |(?:dis)?similarity index |(?:rename|copy) (?:from|to) )/.test( + text + )) + ) { + row.type = 'file'; + oldRemaining = newRemaining = 0; + } else if (text.startsWith('@@')) { + // Incomplete streaming headers must not inherit the preceding hunk's numbers. + row.type = 'hunk'; + oldRemaining = newRemaining = 0; + } else if (text.startsWith('\\')) { + row.type = 'meta'; + } else { + if (text.startsWith('+')) row.type = 'addition'; + if (text.startsWith('-')) row.type = 'deletion'; + if (/^[+\- ]/.test(text)) { + row.prefix = text[0]; + row.content = text.slice(1); + } + if (inHunk && (row.prefix || text === '')) { + if (row.type !== 'addition' && oldRemaining > 0) { + row.oldNumber = oldNumber++; + oldRemaining--; + } + if (row.type !== 'deletion' && newRemaining > 0) { + row.newNumber = newNumber++; + newRemaining--; + } + } + } + row.segments = [{ text: row.content, changed: false }]; + return row; + }); + + for (let i = 0; i < rows.length; ) { + const removed: DiffRow[] = []; + const added: DiffRow[] = []; + while (rows[i]?.type === 'deletion' || rows[i]?.type === 'addition') { + (rows[i].type === 'deletion' ? removed : added).push(rows[i++]); + } + if (removed.length === added.length) { + removed.forEach((oldRow, index) => emphasizePair(oldRow, added[index])); + } + i++; + } + return rows; +} + +function emphasizePair(oldRow: DiffRow, newRow: DiffRow) { + if ( + oldRow.content === newRow.content || + Math.max(oldRow.content.length, newRow.content.length) >= 1024 + ) + return; + const oldText = Array.from(oldRow.content); + const newText = Array.from(newRow.content); + let start = 0; + while (start < Math.min(oldText.length, newText.length) && oldText[start] === newText[start]) + start++; + let oldEnd = oldText.length; + let newEnd = newText.length; + while (oldEnd > start && newEnd > start && oldText[oldEnd - 1] === newText[newEnd - 1]) { + oldEnd--; + newEnd--; + } + // ponytail: like computer's Git view, emphasize one changed middle per paired line. + // Use a token diff if multiple independent edits need separate spans. + for (const [row, text, end] of [ + [oldRow, oldText, oldEnd], + [newRow, newText, newEnd] + ] as const) { + row.segments = [ + { text: text.slice(0, start).join(''), changed: false }, + { text: text.slice(start, end).join(''), changed: true }, + { text: text.slice(end).join(''), changed: false } + ].filter((segment) => segment.text.length > 0); + } +}