This commit is contained in:
@@ -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<Diff, 'deleted' | 'added' | 'original' | 'updated'> {
|
||||
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<string, number>()
|
||||
for (let i = a0; i < a1; i++) countA.set(a[i], (countA.get(a[i]) ?? 0) + 1)
|
||||
const countB = new Map<string, number>(), atB = new Map<string, number>()
|
||||
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<Diff, 'deleted' | 'added' | 'original' | 'updated'> {
|
||||
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<number>(), addSet = new Set<number>()
|
||||
|
||||
@@ -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<HTMLDivElement>(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<HTMLImageElement>('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 (
|
||||
<div className="md-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
|
||||
<div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<div className="md-body" ref={body} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 `(<url> '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 ? `<img alt="${alt}" src="${u}" />` : 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 `<img alt="${attr(alt)}" ${ref}${t} />`
|
||||
})
|
||||
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 ? `<a href="${u}" target="_blank" rel="noreferrer">${t}</a>` : t
|
||||
if (!u) return text
|
||||
const t = title ? ` title="${attr(title)}"` : ''
|
||||
return `<a href="${attr(u)}" target="_blank" rel="noreferrer"${t}>${text}</a>`
|
||||
})
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
||||
@@ -83,17 +130,17 @@ function tableAligns(line: string): (string | null)[] | null {
|
||||
}
|
||||
|
||||
/** One `<td>`/`<th>`, 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) + `</${tag}>`
|
||||
return `<${tag}${a}>` + inline(text, baseDir) + `</${tag}>`
|
||||
}
|
||||
|
||||
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('<p>' + inline(para.join(' ')) + '</p>'); para = [] }
|
||||
if (para.length) { out.push('<p>' + inline(para.join(' '), baseDir) + '</p>'); 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(`<h${n}>` + inline(h[2].trim()) + `</h${n}>`); i++; continue }
|
||||
if (h) { flushPara(); const n = h[1].length; out.push(`<h${n}>` + inline(h[2].trim(), baseDir) + `</h${n}>`); i++; continue }
|
||||
|
||||
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('<hr />'); 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) => '<tr>' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n])).join('') + '</tr>').join('')
|
||||
const body = rows.map((r) => '<tr>' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n], baseDir)).join('') + '</tr>').join('')
|
||||
out.push(
|
||||
'<table class="md-table"><thead><tr>' +
|
||||
head.map((c, n) => cell('th', c, aligns[n])).join('') +
|
||||
head.map((c, n) => cell('th', c, aligns[n], baseDir)).join('') +
|
||||
'</tr></thead>' + (body ? '<tbody>' + body + '</tbody>' : '') + '</table>',
|
||||
)
|
||||
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('<blockquote>' + renderMarkdown(buf.join('\n')) + '</blockquote>')
|
||||
out.push('<blockquote>' + renderMarkdown(buf.join('\n'), baseDir) + '</blockquote>')
|
||||
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('<ul>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</ul>')
|
||||
out.push('<ul>' + items.map((it) => '<li>' + inline(it, baseDir) + '</li>').join('') + '</ul>')
|
||||
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('<ol>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</ol>')
|
||||
out.push('<ol>' + items.map((it) => '<li>' + inline(it, baseDir) + '</li>').join('') + '</ol>')
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,4 +70,31 @@ describe('renderMarkdown', () => {
|
||||
expect(html).not.toContain('<table')
|
||||
expect(html).toContain('<p>a | b not a table</p>')
|
||||
})
|
||||
it('resolves a relative image against the document folder', () => {
|
||||
const html = renderMarkdown('', '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('', '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('', 'docs/how-to')).toContain('data-src="img/a.png"')
|
||||
})
|
||||
|
||||
it('leaves a remote image on src', () => {
|
||||
const html = renderMarkdown('', '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"')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user