This commit is contained in:
Timothy Jaeryang Baek 2026-09-08 13:19:14 -04:00
parent ba34bee2d1
commit 254e29b9af
3 changed files with 334 additions and 11 deletions

View file

@ -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<Writable<i18nType>>('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 @@
</div>
<div class="flex items-center gap-0.5 shrink-0">
{#if isDiff && edit}
<button
class="bg-none border-none transition rounded-md px-1.5 py-0.5 bg-white dark:bg-black"
aria-pressed={editingDiff}
on:click={() => {
editingDiff = !editingDiff;
collapsed = false;
}}
>
{editingDiff ? $i18n.t('Done') : $i18n.t('Edit')}
</button>
{/if}
<button
class="flex gap-1 items-center bg-none border-none transition rounded-md px-1.5 py-0.5 bg-white dark:bg-black"
on:click={collapseCodeBlock}
@ -542,9 +553,11 @@
<div class=" pt-6.5 bg-white dark:bg-black"></div>
{#if !collapsed}
{#if edit}
{#if isDiff && !(edit && editingDiff)}
<DiffBlock code={_code} />
{:else if edit}
<CodeEditor
value={code}
value={isDiff ? _code : code}
{id}
{lang}
onSave={() => {
@ -575,7 +588,7 @@
>
<span class="text-gray-500 italic">
{$i18n.t('{{COUNT}} hidden lines', {
COUNT: code.split('\n').length
COUNT: (isDiff ? _code : code).split('\n').length
})}
</span>
</div>

View file

@ -0,0 +1,197 @@
<script lang="ts">
import { parseDiffRows } from './diff';
export let code = '';
$: rows = parseDiffRows(code);
$: showNumbers = rows.some((row) => row.oldNumber !== null || row.newNumber !== null);
$: gutterWidth = `${rows.reduce((width, row) => Math.max(width, String(Math.max(row.oldNumber ?? 0, row.newNumber ?? 0)).length + 1), 3)}ch`;
</script>
<div class="diff-block text-sm" dir="ltr" style:--diff-gutter-width={gutterWidth}>
<pre><code
>{#each rows as row, index}<span class="diff-line {row.type}" class:numbered={showNumbers}
>{#if showNumbers}<span
class="line-number old-number"
data-line-number={row.oldNumber ?? ''}
aria-hidden="true"></span><span
class="line-number new-number"
data-line-number={row.newNumber ?? ''}
aria-hidden="true"></span>{/if}<span class="line-prefix">{row.prefix}</span><span
class="line-content"
>{#each row.segments as segment}<span class:changed={segment.changed}
>{segment.text}</span
>{/each}{index < rows.length - 1 ? '\n' : ''}</span
></span
>{/each}</code
></pre>
</div>
<style>
.diff-block {
overflow-x: auto;
background: #ffffff;
color: #24292f;
}
pre {
margin: 0;
padding: 0.5rem 0;
min-width: 100%;
width: max-content;
border-radius: 0;
background: transparent;
color: inherit;
font-size: inherit;
line-height: 1.6;
tab-size: 4;
}
code {
display: block;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
font-family: var(--font-mono, ui-monospace, monospace);
font-weight: 400;
white-space: pre;
}
.diff-line {
display: grid;
grid-template-columns: 1.75rem minmax(0, 1fr);
min-height: 1.6em;
}
.diff-line.numbered {
grid-template-columns: var(--diff-gutter-width) var(--diff-gutter-width) 1.75rem minmax(0, 1fr);
}
.line-number {
padding: 0 0.5ch;
text-align: right;
color: #6e7781;
border-inline-end: 1px solid #80808020;
user-select: none;
}
.line-number::before {
content: attr(data-line-number);
}
.line-prefix {
text-align: center;
font-weight: 700;
}
.line-content {
padding: 0 1rem 0 0.25rem;
}
.addition {
background: #dafbe1;
color: #116329;
box-shadow: inset 3px 0 0 #2da44e;
}
.deletion {
background: #ffebe9;
color: #a40e26;
background-image: repeating-linear-gradient(
-45deg,
#cf222e 0,
#cf222e 1px,
transparent 1px,
transparent 3px
);
background-position: left top;
background-repeat: repeat-y;
background-size: 3px 3px;
}
.hunk {
background: #ddf4ff;
color: #0550ae;
}
.file {
background: #f6f8fa;
color: #57606a;
font-weight: 600;
}
.meta {
color: #6e7781;
font-style: italic;
}
:global(.dark) .diff-block {
background: #0d1117;
color: #c9d1d9;
}
:global(.dark) .addition {
background: #12261e;
color: #aff5b4;
box-shadow: inset 3px 0 0 #3fb950;
}
:global(.dark) .deletion {
background: #2d161b;
color: #ffdcd7;
background-image: repeating-linear-gradient(
-45deg,
#f85149 0,
#f85149 1px,
transparent 1px,
transparent 3px
);
background-position: left top;
background-repeat: repeat-y;
background-size: 3px 3px;
}
:global(.dark) .hunk {
background: #121f33;
color: #79c0ff;
}
:global(.dark) .file {
background: #161b22;
color: #8b949e;
}
:global(.dark) .meta {
color: #8b949e;
}
.changed {
border-radius: 2px;
font-weight: 700;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.addition .changed {
background: #16a34a40;
box-shadow: inset 0 -2px 0 #15803d80;
}
.deletion .changed {
background: #dc262630;
box-shadow: inset 0 -2px 0 #b91c1c60;
}
:global(.dark) .addition .changed {
background: #22c55e50;
box-shadow: inset 0 -2px 0 #86efac80;
}
:global(.dark) .deletion .changed {
background: #f8717150;
box-shadow: inset 0 -2px 0 #fca5a580;
}
:global(.dark) .line-number {
color: #8b949e;
}
</style>

View file

@ -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);
}
}