diff --git a/src/renderer/src/diff.ts b/src/renderer/src/diff.ts index 5806e0e..49b63ab 100644 --- a/src/renderer/src/diff.ts +++ b/src/renderer/src/diff.ts @@ -6,24 +6,100 @@ import type { Diff, DiffRow, GitStatus, SideLine, SplitRow } from './types' interface Op { t: 'same' | 'del' | 'add'; a?: number; b?: number } -export function buildDiff(origText: string, updText: string): Omit { - const a = origText === '' ? [] : origText.replace(/\n$/, '').split('\n') - const b = updText === '' ? [] : updText.replace(/\n$/, '').split('\n') - const n = a.length, m = b.length +/** Largest DP matrix we build: 4M cells = 16 MB of Int32. A region over the + * budget is split on unique anchor lines instead, because the full n*m matrix + * of a big file exhausts the heap and kills the renderer. */ +const MAX_CELLS = 4_000_000 + +type Pair = [number, number] + +/** Longest strictly increasing subsequence over the b-index of each pair. */ +function longestRun(pairs: Pair[]): Pair[] { + const tails: number[] = [] + const prev = new Int32Array(pairs.length).fill(-1) + for (let k = 0; k < pairs.length; k++) { + const v = pairs[k][1] + let lo = 0, hi = tails.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (pairs[tails[mid]][1] < v) lo = mid + 1 + else hi = mid + } + prev[k] = lo > 0 ? tails[lo - 1] : -1 + tails[lo] = k + } + const out: Pair[] = [] + let k = tails.length ? tails[tails.length - 1] : -1 + while (k >= 0) { out.push(pairs[k]); k = prev[k] } + return out.reverse() +} + +/** Lines that appear exactly once on each side, in an order both sides share. */ +function anchors(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number): Pair[] { + const countA = new Map() + for (let i = a0; i < a1; i++) countA.set(a[i], (countA.get(a[i]) ?? 0) + 1) + const countB = new Map(), atB = new Map() + for (let j = b0; j < b1; j++) { countB.set(b[j], (countB.get(b[j]) ?? 0) + 1); atB.set(b[j], j) } + const pairs: Pair[] = [] + for (let i = a0; i < a1; i++) { + if (countA.get(a[i]) !== 1 || countB.get(a[i]) !== 1) continue + pairs.push([i, atB.get(a[i])!]) + } + return longestRun(pairs) +} + +function dpOps(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number, ops: Op[]): void { + const n = a1 - a0, m = b1 - b0 const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1)) for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) - dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + dp[i][j] = a[a0 + i] === b[b0 + j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) - const ops: Op[] = [] let i = 0, j = 0 while (i < n && j < m) { - if (a[i] === b[j]) { ops.push({ t: 'same', a: i, b: j }); i++; j++ } - else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: 'del', a: i }); i++ } - else { ops.push({ t: 'add', b: j }); j++ } + if (a[a0 + i] === b[b0 + j]) { ops.push({ t: 'same', a: a0 + i, b: b0 + j }); i++; j++ } + else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: 'del', a: a0 + i }); i++ } + else { ops.push({ t: 'add', b: b0 + j }); j++ } } - while (i < n) { ops.push({ t: 'del', a: i++ }) } - while (j < m) { ops.push({ t: 'add', b: j++ }) } + while (i < n) ops.push({ t: 'del', a: a0 + i++ }) + while (j < m) ops.push({ t: 'add', b: b0 + j++ }) +} + +function region(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number, ops: Op[]): void { + while (a0 < a1 && b0 < b1 && a[a0] === b[b0]) { ops.push({ t: 'same', a: a0, b: b0 }); a0++; b0++ } + const tail: Op[] = [] + while (a1 > a0 && b1 > b0 && a[a1 - 1] === b[b1 - 1]) { a1--; b1--; tail.push({ t: 'same', a: a1, b: b1 }) } + + const n = a1 - a0, m = b1 - b0 + const replaceAll = (): void => { + for (let i = a0; i < a1; i++) ops.push({ t: 'del', a: i }) + for (let j = b0; j < b1; j++) ops.push({ t: 'add', b: j }) + } + + if (n === 0 || m === 0) replaceAll() + else if (n * m <= MAX_CELLS) dpOps(a, b, a0, a1, b0, b1, ops) + else { + const pins = anchors(a, b, a0, a1, b0, b1) + if (pins.length === 0) replaceAll() + else { + let ai = a0, bi = b0 + for (const [ax, bx] of pins) { + region(a, b, ai, ax, bi, bx, ops) + ops.push({ t: 'same', a: ax, b: bx }) + ai = ax + 1; bi = bx + 1 + } + region(a, b, ai, a1, bi, b1, ops) + } + } + for (let k = tail.length - 1; k >= 0; k--) ops.push(tail[k]) +} + +export function buildDiff(origText: string, updText: string): Omit { + const a = origText === '' ? [] : origText.replace(/\n$/, '').split('\n') + const b = updText === '' ? [] : updText.replace(/\n$/, '').split('\n') + + const ops: Op[] = [] + region(a, b, 0, a.length, 0, b.length, ops) const rows: DiffRow[] = [], left: SideLine[] = [], right: SideLine[] = [], split: SplitRow[] = [] const delSet = new Set(), addSet = new Set() diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx index 5a6e1c5..ccda496 100644 --- a/src/renderer/src/editor.tsx +++ b/src/renderer/src/editor.tsx @@ -6,6 +6,7 @@ import { useProject } from './project' import { HL } from './highlight' import { isKnownSymbol, useSymbols } from './symbols' import { renderMarkdown } from './markdown' +import { rlog } from './log' import { FileIcon, Icon } from './components' import type { OnContext } from './components' @@ -316,12 +317,30 @@ function NoOriginal(): React.ReactElement { ) } -/* Rendered-markdown preview: read-only, derived from the live buffer text. */ +/* Rendered-markdown preview: read-only, derived from the live buffer text. + * An image that the document names by a relative path is resolved against the + * document's own folder and then read over IPC, because the preview runs from + * the app's origin and cannot reach the project folder by itself. */ function MarkdownView({ path, text, onContext }: { path: string; text: string; onContext: OnContext }): React.ReactElement { - const html = useMemo(() => renderMarkdown(text), [text]) + const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '' + const html = useMemo(() => renderMarkdown(text, dir), [text, dir]) + const body = useRef(null) + useEffect(() => { + const root = body.current + const bridge = window.helder + if (!root || !bridge) return + let alive = true + for (const img of Array.from(root.querySelectorAll('img[data-src]'))) { + const rel = img.dataset.src ?? '' + bridge.fs.imageDataUrl(rel) + .then((url) => { if (alive && url) img.src = url }) + .catch((e) => rlog.warn('markdown', 'image read failed', { rel, err: String(e) })) + } + return () => { alive = false } + }, [html]) return (
{ e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}> -
+
) } diff --git a/src/renderer/src/markdown.ts b/src/renderer/src/markdown.ts index fa6590a..c42da82 100644 --- a/src/renderer/src/markdown.ts +++ b/src/renderer/src/markdown.ts @@ -28,8 +28,47 @@ function safeUrl(url: string): string { return u // bare relative (e.g. `images/x.png`) } +/** True when the URL names a file next to the document instead of a remote one. */ +function isLocalUrl(u: string): boolean { + return !/^(https?:|data:|mailto:|#)/i.test(u) +} + +/** + * Turn a link that a document writes into a project-relative path. The preview + * runs from the app's own origin, not from the document's folder, so a bare + * `img.png` resolves against the app and finds nothing. `baseDir` is the + * folder of the document, and a leading `/` means the project root. + */ +export function resolveRel(baseDir: string, url: string): string { + let u = url.split('#')[0].split('?')[0] + try { u = decodeURIComponent(u) } catch { /* a stray % stays literal */ } + const segs = (u.startsWith('/') ? u : baseDir + '/' + u).split('/') + const out: string[] = [] + for (const seg of segs) { + if (!seg || seg === '.') continue + if (seg === '..') { out.pop(); continue } + out.push(seg) + } + return out.join('/') +} + +/** Quote one attribute value. The text already passed escapeHtml, so `&`, `<` + * and `>` are gone and only the quote itself can still break out. */ +function attr(value: string): string { + return value.replace(/"/g, '"') +} + +// `(url)`, `(url "title")` or `( 'title')` — the title is not part of the URL. +const DEST_RE = /^\s*(?:<([^>]*)>|([^\s)]*))(?:\s+["'(]([^"')]*)["')])?\s*$/ +/** Split a link destination into its URL and its optional title. */ +function dest(raw: string): { url: string; title: string } { + const m = raw.match(DEST_RE) + if (!m) return { url: raw.trim(), title: '' } + return { url: (m[1] ?? m[2] ?? '').trim(), title: m[3] ?? '' } +} + /** Inline markdown on one already-untrusted text run. */ -function inline(src: string): string { +function inline(src: string, baseDir: string): string { // Pull code spans out first so their literal content is never re-processed. const codes: string[] = [] let s = src.replace(/`([^`]+)`/g, (_m, c) => { @@ -37,13 +76,21 @@ function inline(src: string): string { return SENT + (codes.length - 1) + SENT }) s = HL.escapeHtml(s) - s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => { + s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, raw) => { + const { url, title } = dest(raw) const u = safeUrl(url) - return u ? `${alt}` : alt + if (!u) return alt + const t = title ? ` title="${attr(title)}"` : '' + // A local image has no src yet: the view reads the file over IPC and fills it. + const ref = isLocalUrl(u) ? `data-src="${attr(resolveRel(baseDir, u))}"` : `src="${attr(u)}"` + return `${attr(alt)}` }) - s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, url) => { + s = s.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_m, text, raw) => { + const { url, title } = dest(raw) const u = safeUrl(url) - return u ? `${t}` : t + if (!u) return text + const t = title ? ` title="${attr(title)}"` : '' + return `${text}` }) s = s.replace(/\*\*([^*]+)\*\*/g, '$1') s = s.replace(/__([^_]+)__/g, '$1') @@ -83,17 +130,17 @@ function tableAligns(line: string): (string | null)[] | null { } /** One ``/``, with the column's alignment when the header set one. */ -function cell(tag: string, text: string, align: string | null): string { +function cell(tag: string, text: string, align: string | null, baseDir: string): string { const a = align ? ` style="text-align:${align}"` : '' - return `<${tag}${a}>` + inline(text) + `` + return `<${tag}${a}>` + inline(text, baseDir) + `` } -export function renderMarkdown(text: string): string { +export function renderMarkdown(text: string, baseDir = ''): string { const lines = text.replace(/\r\n?/g, '\n').split('\n') const out: string[] = [] let para: string[] = [] const flushPara = (): void => { - if (para.length) { out.push('

' + inline(para.join(' ')) + '

'); para = [] } + if (para.length) { out.push('

' + inline(para.join(' '), baseDir) + '

'); para = [] } } let i = 0 @@ -116,7 +163,7 @@ export function renderMarkdown(text: string): string { if (/^\s*$/.test(line)) { flushPara(); i++; continue } const h = line.match(/^(#{1,6})\s+(.*)$/) - if (h) { flushPara(); const n = h[1].length; out.push(`` + inline(h[2].trim()) + ``); i++; continue } + if (h) { flushPara(); const n = h[1].length; out.push(`` + inline(h[2].trim(), baseDir) + ``); i++; continue } if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('
'); i++; continue } @@ -132,10 +179,10 @@ export function renderMarkdown(text: string): string { while (i < lines.length && lines[i].includes('|') && !/^\s*$/.test(lines[i])) { rows.push(splitRow(lines[i])); i++ } - const body = rows.map((r) => '' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n])).join('') + '').join('') + const body = rows.map((r) => '' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n], baseDir)).join('') + '').join('') out.push( '' + - head.map((c, n) => cell('th', c, aligns[n])).join('') + + head.map((c, n) => cell('th', c, aligns[n], baseDir)).join('') + '' + (body ? '' + body + '' : '') + '
', ) continue @@ -146,7 +193,7 @@ export function renderMarkdown(text: string): string { flushPara() const buf: string[] = [] while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++ } - out.push('
' + renderMarkdown(buf.join('\n')) + '
') + out.push('
' + renderMarkdown(buf.join('\n'), baseDir) + '
') continue } @@ -154,7 +201,7 @@ export function renderMarkdown(text: string): string { flushPara() const items: string[] = [] while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, '')); i++ } - out.push('
    ' + items.map((it) => '
  • ' + inline(it) + '
  • ').join('') + '
') + out.push('
    ' + items.map((it) => '
  • ' + inline(it, baseDir) + '
  • ').join('') + '
') continue } @@ -162,7 +209,7 @@ export function renderMarkdown(text: string): string { flushPara() const items: string[] = [] while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+[.)]\s+/, '')); i++ } - out.push('
    ' + items.map((it) => '
  1. ' + inline(it) + '
  2. ').join('') + '
') + out.push('
    ' + items.map((it) => '
  1. ' + inline(it, baseDir) + '
  2. ').join('') + '
') continue } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 85fdda4..22927e6 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -432,6 +432,8 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c color:var(--accent-lite); font-style:italic; font-size:15px; } .md-body hr { border:0; border-top:1px solid var(--border); margin:1.6em 0; } .md-body img { max-width:100%; border-radius:var(--r-sm); } +/* A local image whose file could not be read keeps only its alt text. */ +.md-body img:not([src]) { color:var(--fg-3); font-family:var(--mono); font-size:12px; } .md-body code { font-family:var(--code-font); font-size:13px; color:var(--accent-lite); background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-sm); padding:1px 5px; } .md-body pre.md-code { background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-sm); padding:14px 16px; overflow:auto; margin:1.2em 0; } diff --git a/test/diff.test.ts b/test/diff.test.ts index a649f31..070e56c 100644 --- a/test/diff.test.ts +++ b/test/diff.test.ts @@ -69,3 +69,33 @@ describe('makeDiff', () => { expect(deleted.added).toBe(false) }) }) + +describe('buildDiff on large files', () => { + it('diffs a 60k-line file without exhausting the heap', () => { + const base = Array.from({ length: 60_000 }, (_, i) => `line ${i}`) + const changed = base.slice() + changed[30_000] = 'line 30000 edited' + const d = buildDiff(base.join('\n'), changed.join('\n')) + expect(d.add).toBe(1) + expect(d.del).toBe(1) + expect(d.right[30_000].mark).toBe('add') + }) + + it('survives a full rewrite of a large file with no shared lines', () => { + const a = Array.from({ length: 40_000 }, (_, i) => `old ${i}`).join('\n') + const b = Array.from({ length: 40_000 }, (_, i) => `new ${i}`).join('\n') + const d = buildDiff(a, b) + expect(d.del).toBe(40_000) + expect(d.add).toBe(40_000) + }) + + it('keeps the unchanged head and tail of a large, heavily edited file', () => { + const head = Array.from({ length: 5_000 }, (_, i) => `head ${i}`) + const tail = Array.from({ length: 5_000 }, (_, i) => `tail ${i}`) + const mid = (p: string): string[] => Array.from({ length: 30_000 }, (_, i) => `${p} ${i % 7}`) + const d = buildDiff([...head, ...mid('x'), ...tail].join('\n'), [...head, ...mid('y'), ...tail].join('\n')) + expect(d.left.slice(0, 5_000).every((l) => l.mark === null)).toBe(true) + expect(d.left.slice(-5_000).every((l) => l.mark === null)).toBe(true) + expect(d.add).toBe(30_000) + }) +}) diff --git a/test/markdown.test.ts b/test/markdown.test.ts index b5c829e..0bf6a21 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -70,4 +70,31 @@ describe('renderMarkdown', () => { expect(html).not.toContain('a | b not a table

') }) + it('resolves a relative image against the document folder', () => { + const html = renderMarkdown('![Forge](forge-app-config.png)', 'docs/how-to') + expect(html).toContain('data-src="docs/how-to/forge-app-config.png"') + expect(html).not.toContain(' src=') + }) + + it('keeps the image title out of the path and walks up a parent folder', () => { + const html = renderMarkdown('![c](../img/a%20b.png "A title")', 'docs/how-to') + expect(html).toContain('data-src="docs/img/a b.png"') + expect(html).toContain('title="A title"') + }) + + it('reads a leading slash as the project root', () => { + expect(renderMarkdown('![c](/img/a.png)', 'docs/how-to')).toContain('data-src="img/a.png"') + }) + + it('leaves a remote image on src', () => { + const html = renderMarkdown('![c](https://x.com/a.png)', 'docs') + expect(html).toContain('src="https://x.com/a.png"') + expect(html).not.toContain('data-src') + }) + + it('keeps a link title out of the href', () => { + const html = renderMarkdown('[a](https://x.com "Home")') + expect(html).toContain('href="https://x.com"') + expect(html).toContain('title="Home"') + }) })