Compare commits
2 Commits
1ebb9a0e74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2d1c5df83 | |||
| f6a551d7c4 |
@@ -47,9 +47,9 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for
|
||||
- **Prism PHP load order:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text.
|
||||
- **Preload must be CommonJS `index.cjs`** and `main` must load `../preload/index.cjs` (see `electron.vite.config.ts` preload `rollupOptions.output`). If they mismatch (or you let it build as `.mjs`), Electron silently loads no preload, `window.helder` is undefined, and the renderer silently falls back to mock data + the "terminal not available" message. Keep the filename and the `main` path in sync.
|
||||
- **chokidar is pinned to v3 on purpose — do NOT bump to v4/v5.** chokidar ≥4 dropped the `fsevents` addon and watches recursively via libuv's native `fs.watch({recursive:true})`. On macOS that recursive watcher poisons the process's file descriptors, so every later `child_process.spawn` (i.e. every `git` call) fails with `spawn EBADF` (errno -9) and the git column silently stops updating. v3 uses the `fsevents` native addon instead and has no such conflict. If you must move to v4+, switch the main project watcher to `usePolling: true` (the only other config proven to avoid the EBADF here).
|
||||
- **Word wrap swaps the buffer's layout, it is not just a CSS switch** (`editor.wordWrap`: `markdown` default / `on` / `off`). Unwrapped, `CodeEditor` renders one highlighted blob and a separate gutter column that follows the scroll. Wrapped, a fixed 20px-per-line gutter no longer lines up, so each line becomes a `.ce-line` block and the number is a CSS counter on `::before` — that keeps it on the first visual row and leaves folded rows blank. Two things bite here: `.ce-inner` must drop `width:max-content` (and `.editor` its `max-content` grid track), or a folded line still measures its full unfolded width and never breaks; and the textarea and the `<pre>` must fold identically — same width, padding, font, `white-space:pre-wrap`, `overflow-wrap:break-word` — or the caret drifts off the text. The full-screen Diff never wraps on purpose: its two panes align row by row. The hover original panel must fold at the same points as the editor, or the old line does not stay level with the current line.
|
||||
- **Word wrap swaps the buffer's layout, it is not just a CSS switch** (`editor.wordWrap`: `markdown` default / `on` / `off`). Unwrapped, `CodeEditor` renders one highlighted blob and a separate gutter column that follows the scroll. Wrapped, a fixed 20px-per-line gutter no longer lines up, so each line becomes a `.ce-line` block and the number is a CSS counter on `::before` — that keeps it on the first visual row and leaves folded rows blank. Two things bite here: `.ce-inner` must drop `width:max-content` (and `.editor` its `max-content` grid track), or a folded line still measures its full unfolded width and never breaks; and the textarea and the `<pre>` must fold identically — same width, padding, font, `white-space:pre-wrap`, `overflow-wrap:break-word` — or the caret drifts off the text. The full-screen Diff never wraps on purpose: its two panes align row by row. The original panel must fold at the same points as the editor, or the old line does not stay level with the current line.
|
||||
- **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending.
|
||||
- **The three view modes (Actual / Original / Diff), plus Preview for markdown, all derive from one original-text + updated-text pair per changed file.** The prototype computes the pair with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output. **Actual is the writable buffer, and it marks the changed lines in place.** A marked row takes the teal `--add` tint, a 2px `--add` left rule, and a teal line number. There is no `+` glyph, no sign column, and no second row. The removed lines appear on hover: `mouseenter` on a marked line opens a 496px panel with a 2px `--del` left border over the agent + terminal column, and `mouseleave` closes it. The panel holds the whole original file and scrolls so the previous version of the hovered line sits level with the hovered line. There is no click, no pin, and no animation. A pure deletion has no current line to mark, so the neighbouring current line takes a 2px `--del` rule on its edge and opens the same panel. **Diff is the full-screen side-by-side view**: it covers the whole application, original left, updated right, lines aligned, and `Esc` returns to the previous mode. The old separate **Split** button is gone, and the `Diff` segment opens that view instead. `git.defaultDiffMode` defaults to `'updated'` (Actual), so a click on a changed git row opens the file in Actual. The git context menu's **Open diff** is the explicit way into the full-screen view. The colour language is token-based everywhere: **teal `--add` is what the file holds now, amber-deep `--del` is what it held before** — never green, never red. Syntax highlighting stays on in all modes. The design source for the marked lines and the hover panel is `design_handoff_helder_inline_diff/` (README.md + `03b-in-pane-diff.html`).
|
||||
- **The three view modes (Actual / Original / Diff), plus Preview for markdown, all derive from one original-text + updated-text pair per changed file.** The prototype computes the pair with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output. **Actual is the writable buffer, and it marks the changed lines in place.** A marked row takes the teal `--add` tint, a 2px `--add` left rule, and a teal line number. There is no `+` glyph, no sign column, and no second row. The removed lines appear on a click: a click on a marked line opens a 496px panel with a 2px `--del` left border over the agent + terminal column. The panel holds the whole original file and scrolls so the previous version of the picked line sits level with the picked line. A second click on the same line, or a click anywhere off the code, closes it. Hover does nothing, and there is no animation. A pure deletion has no current line to mark, so the neighbouring current line takes a 2px `--del` rule on its edge and opens the same panel. **Diff is the full-screen side-by-side view**: it covers the whole application, original left, updated right, lines aligned, and `Esc` returns to the previous mode. The old separate **Split** button is gone, and the `Diff` segment opens that view instead. `git.defaultDiffMode` defaults to `'updated'` (Actual), so a click on a changed git row opens the file in Actual. The git context menu's **Open diff** is the explicit way into the full-screen view. The colour language is token-based everywhere: **teal `--add` is what the file holds now, amber-deep `--del` is what it held before** — never green, never red. Syntax highlighting stays on in all modes. The design source for the marked lines and the original panel is `design_handoff_helder_inline_diff/` (README.md + `03b-in-pane-diff.html`).
|
||||
- **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery.
|
||||
- **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal.
|
||||
- **`console.*` is not a log — use the logger.** Helder runs one process per project window, and every window past the first is spawned by `spawnInstance()` with `stdio: 'ignore'`; launched from Finder there's no terminal either. Console output is therefore discarded in real use. Log through `src/main/logger.ts` (main) or `src/renderer/src/log.ts` → `rlog` (renderer, forwarded over IPC to the same file). Never add a bare `catch {}` on an IPC/FS/git path: log the cause, then handle it.
|
||||
@@ -104,7 +104,7 @@ The older `design_handoff_helder_workbench/` prototype is superseded by `docs/de
|
||||
1. Electron shell + frameless dark window; port tokens to CSS vars; bundle IBM Plex.
|
||||
2. Static layout: four resizable columns + title/status bars.
|
||||
3. Real file tree + open files into tabs (read-only) with Prism highlighting.
|
||||
4. Git panel from `git status` (read-only) → staging + commit → the three view modes, the hover original panel, and the full-screen Diff.
|
||||
4. Git panel from `git status` (read-only) → staging + commit → the three view modes, the original panel, and the full-screen Diff.
|
||||
5. Search (ripgrep + fuzzy).
|
||||
6. Terminals via node-pty + xterm.js; run `claude` in the agent pane.
|
||||
7. Copy reference + Pass-on-to-Agent (clipboard + bracketed-paste into the agent PTY).
|
||||
|
||||
@@ -136,7 +136,7 @@ All are presentations of the same change set for the file:
|
||||
3. **Diff**: the editor expands to full screen, covering the other columns. The original file is on the left and the updated file is on the right, lines aligned. `Esc`, or a collapse control in the corner, returns to the normal layout and the previously active mode.
|
||||
4. **Preview**: markdown files only. It shows the rendered document instead of the source.
|
||||
|
||||
**In Actual, the removed lines appear on hover.** The pointer enters a marked line, and a panel opens over the agent and terminal column: 496px wide, with a 2px amber-deep left border. The panel holds the whole original file, and it scrolls so that the previous version of the hovered line sits level with the hovered line. The pointer leaves the line, and the panel closes. There is no click, no pin, and no animation.
|
||||
**In Actual, the removed lines appear on a click.** The user clicks a marked line, and a panel opens over the agent and terminal column: 496px wide, with a 2px amber-deep left border. The panel holds the whole original file, and it scrolls so that the previous version of the picked line sits level with the picked line. A second click on the same line closes the panel, and so does a click anywhere off the code. Hover does nothing, and there is no animation.
|
||||
|
||||
Shared rules: teal is what the file holds now, amber-deep is what it held before. There is no green and no red anywhere. Syntax highlighting stays on in all modes. Line numbers follow `editor.lineNumbers` (default absolute).
|
||||
|
||||
@@ -144,7 +144,7 @@ Shared rules: teal is what the file holds now, amber-deep is what it held before
|
||||
|
||||
- A file with no changes opens directly in a normal editable view with no view-mode control, since there is nothing to diff.
|
||||
- Editing follows the editor settings: indentation, indent width, trim trailing whitespace, insert final newline, word wrap, indent guides, whitespace rendering, and active-line highlight, all read from configuration (section 10).
|
||||
- **Word wrap** follows `editor.wordWrap`: `markdown` (default), `on`, or `off`. When it is on, a line too wide for the pane folds onto the next row instead of scrolling sideways. Only the first row of a folded line carries a line number; the rows below it stay blank. The hover panel folds at the same points as the editor, so each old line stays level with its current line. Diff never wraps, because its two panes align row by row.
|
||||
- **Word wrap** follows `editor.wordWrap`: `markdown` (default), `on`, or `off`. When it is on, a line too wide for the pane folds onto the next row instead of scrolling sideways. Only the first row of a folded line carries a line number; the rows below it stay blank. The original panel folds at the same points as the editor, so each old line stays level with its current line. Diff never wraps, because its two panes align row by row.
|
||||
|
||||
### Right-click in code
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -82,7 +83,7 @@ const PAD_TOP = 10
|
||||
* the number is a CSS counter on `::before`, which keeps it on the first
|
||||
* visual row and leaves the folded rows unnumbered. Highlighting is then per
|
||||
* line (like the diff views), so multi-line tokens don't carry over. */
|
||||
function CodeEditor({ path, text, lang, wrap, flash, marks, onHover, onChange, onContext, onSymbol }: {
|
||||
function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, onContext, onSymbol }: {
|
||||
path: string
|
||||
text: string
|
||||
lang: string | null
|
||||
@@ -92,7 +93,7 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onHover, onChange, o
|
||||
/** Changed lines of this file, from the HEAD-vs-disk diff. */
|
||||
marks: ChangeMark[]
|
||||
/** The marked line under the pointer, with its top in the pane's viewport. */
|
||||
onHover: (h: { line: number; top: number } | null) => void
|
||||
onPick: (h: { line: number; top: number } | null) => void
|
||||
onChange: (text: string) => void
|
||||
onContext: OnContext
|
||||
onSymbol: OnSymbol
|
||||
@@ -158,31 +159,32 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onHover, onChange, o
|
||||
}))
|
||||
}, [marks, wrap, text, paneW])
|
||||
|
||||
/* The hover reveal. The pointer lands on the textarea rather than on a row, so
|
||||
* the line comes from the bands — the same geometry that drew the tint. */
|
||||
const hoverRef = useRef<number | null>(null)
|
||||
/* The reveal of the old lines. A click pins it, a second click on the same
|
||||
* band or a click off every band drops it. The click lands on the textarea
|
||||
* rather than on a row, so the line comes from the bands — the same geometry
|
||||
* that drew the tint. */
|
||||
const pickRef = useRef<number | null>(null)
|
||||
function report(list: Band[], line: number | null): void {
|
||||
const b = line == null ? undefined : list.find((x) => x.line === line)
|
||||
hoverRef.current = b ? b.line : null
|
||||
onHover(b && scrollRef.current ? { line: b.line, top: b.top - scrollRef.current.scrollTop } : null)
|
||||
pickRef.current = b ? b.line : null
|
||||
onPick(b && scrollRef.current ? { line: b.line, top: b.top - scrollRef.current.scrollTop } : null)
|
||||
}
|
||||
function onMove(e: React.MouseEvent): void {
|
||||
if (!bands.length && hoverRef.current == null) return
|
||||
function pickAt(e: React.MouseEvent): void {
|
||||
if (!bands.length && pickRef.current == null) return
|
||||
const box = innerRef.current?.getBoundingClientRect()
|
||||
if (!box) return
|
||||
const y = e.clientY - box.top
|
||||
const hit = bands.find((b) => y >= b.top && y < b.top + b.height)
|
||||
const line = hit ? hit.line : null
|
||||
if (line !== hoverRef.current) report(bands, line)
|
||||
report(bands, hit && hit.line !== pickRef.current ? hit.line : null)
|
||||
}
|
||||
// An edit moves the bands under a held pointer; the panel follows them.
|
||||
// An edit moves the bands under the pinned line; the panel follows them.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useLayoutEffect(() => { if (hoverRef.current != null) report(bands, hoverRef.current) }, [bands])
|
||||
useLayoutEffect(() => { if (pickRef.current != null) report(bands, pickRef.current) }, [bands])
|
||||
|
||||
function onScroll(): void {
|
||||
const s = scrollRef.current
|
||||
if (s && gutterRef.current) gutterRef.current.style.transform = `translateY(${-s.scrollTop}px)`
|
||||
if (hoverRef.current != null) report(bands, hoverRef.current)
|
||||
if (pickRef.current != null) report(bands, pickRef.current)
|
||||
}
|
||||
// The textarea is overflow-hidden under the scroller, so keep the caret line
|
||||
// in view by scrolling the container ourselves (6px top pad, 20px line-height).
|
||||
@@ -279,10 +281,9 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onHover, onChange, o
|
||||
<div className="ce-gutter" ref={gutterRef} dangerouslySetInnerHTML={{ __html: gutter }} />
|
||||
</div>
|
||||
)}
|
||||
{/* No marks, no hover: an unchanged file pays nothing for the reveal. */}
|
||||
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}
|
||||
onMouseMove={marks.length ? onMove : undefined}
|
||||
onMouseLeave={marks.length ? () => { if (hoverRef.current != null) report(bands, null) } : undefined}>
|
||||
{/* No marks, no pick: an unchanged file pays nothing for the reveal. */}
|
||||
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}
|
||||
onClick={(e) => { if (e.metaKey || e.ctrlKey) handleTokenClick(e); else if (marks.length) pickAt(e) }}>
|
||||
<div className="ce-inner" ref={innerRef}>
|
||||
{bands.map((b) => (
|
||||
<Fragment key={b.line}>
|
||||
@@ -305,7 +306,7 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onHover, onChange, o
|
||||
)
|
||||
}
|
||||
|
||||
/* The file has no HEAD version. Shown by Original mode and by the hover
|
||||
/* The file has no HEAD version. Shown by Original mode and by the picked
|
||||
* overlay, so both say the same thing. */
|
||||
function NoOriginal(): React.ReactElement {
|
||||
return (
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -474,7 +493,7 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
|
||||
}
|
||||
|
||||
/** The original lines behind one current line: the range to mark amber, and the
|
||||
* one to line up with the hovered row. */
|
||||
* one to line up with the picked row. */
|
||||
interface PeekTarget {
|
||||
anchor: number
|
||||
from: number | null
|
||||
@@ -484,7 +503,7 @@ interface PeekTarget {
|
||||
/* Actual: the current file with the changed lines marked in place. Removals own
|
||||
* no line here, so a run of them puts its rule on the edge of the line that took
|
||||
* its place — unless that line is itself an added one, which already says the
|
||||
* same thing in teal. The map is what the hover overlay reads. */
|
||||
* same thing in teal. The map is what the overlay reads. */
|
||||
export function buildDiffView(diff: Diff): { lines: ViewLine[]; peek: Map<number, PeekTarget> } {
|
||||
const peek = new Map<number, PeekTarget>()
|
||||
const above = new Set<number>()
|
||||
@@ -628,14 +647,25 @@ export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor,
|
||||
// Editable in the live-buffer modes; Original/Preview stay read-only views.
|
||||
const editable = paneMode === 'code' || paneMode === 'updated'
|
||||
|
||||
/* The hover reveal of Actual. CodeEditor owns the geometry, because over a
|
||||
* textarea there is no row to read the pointer off; `top` is the hovered
|
||||
/* The pinned reveal of Actual. CodeEditor owns the geometry, because over a
|
||||
* textarea there is no row to read the pointer off; `top` is the picked
|
||||
* line's offset inside the pane viewport, and the overlay aligns on it. */
|
||||
const [hover, setHover] = useState<{ line: number; top: number } | null>(null)
|
||||
const onHoverLine = useCallback((h: { line: number; top: number } | null) => setHover(h), [])
|
||||
useEffect(() => { setHover(null) }, [active, effMode, side])
|
||||
const [pick, setPick] = useState<{ line: number; top: number } | null>(null)
|
||||
const onPickLine = useCallback((h: { line: number; top: number } | null) => setPick(h), [])
|
||||
useEffect(() => { setPick(null) }, [active, effMode, side])
|
||||
/* A click anywhere but the code drops the panel. The editor's own clicks are
|
||||
* already handled there, and the panel takes no pointer, so a click on it
|
||||
* lands on the column below and counts as "somewhere else". */
|
||||
useEffect(() => {
|
||||
if (!pick) return
|
||||
function away(e: MouseEvent): void {
|
||||
if (!(e.target as HTMLElement | null)?.closest?.('.ce-scroll')) setPick(null)
|
||||
}
|
||||
document.addEventListener('mousedown', away, true)
|
||||
return () => document.removeEventListener('mousedown', away, true)
|
||||
}, [pick])
|
||||
|
||||
const target = hover && diffView ? diffView.peek.get(hover.line) ?? null : null
|
||||
const target = pick && diffView ? diffView.peek.get(pick.line) ?? null : null
|
||||
const peekLines = useMemo<ViewLine[]>(() => (target && diff
|
||||
? diff.left.map((l) => ({
|
||||
no: l.no,
|
||||
@@ -644,10 +674,10 @@ export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor,
|
||||
}))
|
||||
: []), [diff, target])
|
||||
useEffect(() => {
|
||||
onPeek(active && target && hover
|
||||
? { path: active, lines: peekLines, lang, wrap, anchor: target.anchor, top: hover.top, empty: peekLines.length === 0 }
|
||||
onPeek(active && target && pick
|
||||
? { path: active, lines: peekLines, lang, wrap, anchor: target.anchor, top: pick.top, empty: peekLines.length === 0 }
|
||||
: null)
|
||||
}, [active, target, hover, peekLines, lang, wrap, onPeek])
|
||||
}, [active, target, pick, peekLines, lang, wrap, onPeek])
|
||||
useEffect(() => () => onPeek(null), [onPeek])
|
||||
|
||||
return (
|
||||
@@ -702,7 +732,7 @@ export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor,
|
||||
<NoOriginal />
|
||||
) : editable ? (
|
||||
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} marks={marks}
|
||||
onHover={onHoverLine} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
|
||||
onPick={onPickLine} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
|
||||
) : (
|
||||
built && <PaneView cacheKey={tab.path + ':' + paneMode} path={tab.path} lines={built.lines}
|
||||
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
|
||||
@@ -714,16 +744,16 @@ export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor,
|
||||
)
|
||||
}
|
||||
|
||||
/** Everything the hover overlay needs: the original of the open file, the lines
|
||||
* the hovered one replaced, and where that hovered line sits on screen. */
|
||||
/** Everything the overlay needs: the original of the open file, the lines the
|
||||
* picked one replaced, and where that picked line sits on screen. */
|
||||
export interface PeekInfo {
|
||||
path: string
|
||||
lines: ViewLine[]
|
||||
lang: string | null
|
||||
wrap: boolean
|
||||
/** Original line to put level with the hovered one. */
|
||||
/** Original line to put level with the picked one. */
|
||||
anchor: number
|
||||
/** The hovered row's top, measured inside the editor viewport. */
|
||||
/** The picked row's top, measured inside the editor viewport. */
|
||||
top: number
|
||||
empty: boolean
|
||||
}
|
||||
@@ -731,8 +761,8 @@ export interface PeekInfo {
|
||||
const noop = (): void => undefined
|
||||
|
||||
/* The original file, over the agent column and level with the editor. It takes
|
||||
* no pointer of its own, so the panel can never steal the hover from the row
|
||||
* that opened it. */
|
||||
* no pointer of its own, so a click on it counts as a click outside the code and
|
||||
* closes the panel. */
|
||||
export function OriginalPeek({ path, lines, lang, wrap, anchor, top, empty }: PeekInfo): React.ReactElement {
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
// Measured, not counted: a folded line is taller than one row, so only the
|
||||
@@ -749,7 +779,7 @@ export function OriginalPeek({ path, lines, lang, wrap, anchor, top, empty }: Pe
|
||||
<div className="peek-head">
|
||||
<span className="ph-label">Original</span>
|
||||
<span className="ph-note">before this change · same scroll</span>
|
||||
<span className="ph-hint">hold hover</span>
|
||||
<span className="ph-hint">click the line again to close</span>
|
||||
</div>
|
||||
<div className="peek-body" ref={bodyRef}>
|
||||
{empty ? <NoOriginal /> : (
|
||||
|
||||
@@ -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; }
|
||||
@@ -492,8 +494,8 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
||||
.ce-line.chg::before { color:var(--add); }
|
||||
|
||||
/* The hover reveal: the whole original file over the agent column, level with
|
||||
the editor. It takes no pointer, so it can never break the hover that opened
|
||||
it. 496px is the design width of that column plus its 2px rule. */
|
||||
the editor. It takes no pointer, so a click on it counts as a click outside
|
||||
the code and closes the panel. 496px is the design width of that column plus its 2px rule. */
|
||||
.peek {
|
||||
position:absolute; top:0; right:0; bottom:0; width:496px; z-index:40;
|
||||
display:flex; flex-direction:column; overflow:hidden; pointer-events:none;
|
||||
@@ -826,9 +828,12 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
||||
/* xterm.js host (real terminals) */
|
||||
/* The pane carries the colour, taken from the terminal palette in config.json,
|
||||
so the padding around the canvas can never sit on a different ground. */
|
||||
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:10px 6px 10px 12px; background:var(--bg-0); }
|
||||
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:12px; background:var(--bg-0); }
|
||||
.term-xterm .xterm { height:100%; }
|
||||
.term-xterm .xterm-viewport { background:transparent !important; }
|
||||
/* The scrollbar takes layout width inside the viewport, so at right:0 it would
|
||||
stand on the last column of text. Pushed into the right padding it overlays
|
||||
empty ground, and the grid keeps all four gaps equal (see fitTerm). */
|
||||
.term-xterm .xterm-viewport { background:transparent !important; right:-9px; }
|
||||
.term-xterm .xterm-viewport::-webkit-scrollbar { width:9px; }
|
||||
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.13); border-radius:5px; border:2px solid transparent; background-clip:content-box; }
|
||||
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); background-clip:content-box; }
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* references can be stacked before the user hits Enter. */
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { ContextMenu } from './overlays'
|
||||
import type { Menu } from './overlays'
|
||||
@@ -45,10 +44,41 @@ function xtermOptions(cfg: HelderConfig['terminal']): {
|
||||
}
|
||||
}
|
||||
|
||||
/* Gap between the grid and the pane edge, in px. */
|
||||
const PAD = 12
|
||||
type Cell = { width: number; height: number }
|
||||
|
||||
function cellSize(term: XTerm): Cell | null {
|
||||
const core = (term as unknown as {
|
||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: Cell } } } }
|
||||
})._core
|
||||
const cell = core?._renderService?.dimensions?.css?.cell
|
||||
return cell && cell.width > 0 && cell.height > 0 ? cell : null
|
||||
}
|
||||
|
||||
/* Sizes the grid to the host and centres it. FitAddon is not used here: it
|
||||
* reserves 14px on the right for an overview ruler we never draw, and it leaves
|
||||
* the sub-cell remainder on one side, so the right and bottom gaps came out far
|
||||
* wider than the left and top. Both measurements come from clientWidth, which
|
||||
* counts the padding, so writing the padding back cannot change the result and
|
||||
* the ResizeObserver settles after one pass. */
|
||||
function fitTerm(term: XTerm, host: HTMLElement): void {
|
||||
const cell = cellSize(term)
|
||||
if (!cell) return
|
||||
const innerW = host.clientWidth - PAD * 2
|
||||
const innerH = host.clientHeight - PAD * 2
|
||||
const cols = Math.max(2, Math.floor(innerW / cell.width))
|
||||
const rows = Math.max(1, Math.floor(innerH / cell.height))
|
||||
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows)
|
||||
const x = PAD + Math.max(0, innerW - cols * cell.width) / 2
|
||||
const y = PAD + Math.max(0, innerH - rows * cell.height) / 2
|
||||
host.style.padding = `${y}px ${x}px`
|
||||
}
|
||||
|
||||
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<XTerm | null>(null)
|
||||
const fitRef = useRef<FitAddon | null>(null)
|
||||
const fitRef = useRef<(() => void) | null>(null)
|
||||
// The terminal is built once and lives as long as its PTY, so the config is
|
||||
// read through a ref on creation and re-applied by the effect below — editing
|
||||
// config.json restyles the running session instead of restarting it.
|
||||
@@ -69,10 +99,9 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
|
||||
const term = new XTerm({ ...xtermOptions(cfgRef.current), allowProposedApi: true })
|
||||
termRef.current = term
|
||||
const fit = new FitAddon()
|
||||
fitRef.current = fit
|
||||
term.loadAddon(fit)
|
||||
term.open(host)
|
||||
const refit = (): void => fitTerm(term, host)
|
||||
fitRef.current = refit
|
||||
|
||||
// Both panes (D1 agent + D2 shell): selecting text auto-copies it to the clipboard.
|
||||
if (bridge) {
|
||||
@@ -90,7 +119,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
term.selectAll()
|
||||
return false
|
||||
})
|
||||
try { fit.fit() } catch { /* host not measured yet */ }
|
||||
try { refit() } catch { /* host not measured yet */ }
|
||||
|
||||
let disposed = false
|
||||
let id = -1
|
||||
@@ -150,7 +179,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
if (frame) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
try { fit.fit() } catch { /* host not measured yet */ }
|
||||
try { refit() } catch { /* host not measured yet */ }
|
||||
})
|
||||
})
|
||||
ro.observe(host)
|
||||
@@ -176,7 +205,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
const term = termRef.current
|
||||
if (!term) return
|
||||
Object.assign(term.options, xtermOptions(cfg))
|
||||
fitRef.current?.fit()
|
||||
fitRef.current?.()
|
||||
}, [cfg])
|
||||
|
||||
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('a change that only removes a line', () => {
|
||||
expect(c.querySelector('.ce-gutter .chg')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands the removed line to the hover panel', async () => {
|
||||
it('hands the removed line to the picked panel', async () => {
|
||||
const c = await openRow()
|
||||
const scroll = await waitFor(() => {
|
||||
const el = c.querySelector<HTMLElement>('.ce-scroll')
|
||||
@@ -60,7 +60,7 @@ describe('a change that only removes a line', () => {
|
||||
return el
|
||||
})
|
||||
// 'tail' took the place of the removed line, and sits on line 2.
|
||||
fireEvent.mouseMove(scroll, { clientY: 10 + 20 + 5 })
|
||||
fireEvent.click(scroll, { clientY: 10 + 20 + 5 })
|
||||
const peek = await waitFor(() => {
|
||||
const p = c.querySelector<HTMLElement>('.peek')
|
||||
if (!p) throw new Error('peek not ready')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('Editor view modes', () => {
|
||||
expect(find(c, '.seg button.on', 'Original')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hovering a band in Actual reveals the original, leaving the editor hides it', async () => {
|
||||
it('clicking a band in Actual reveals the original, clicking it again hides it', async () => {
|
||||
const c = await openChanged()
|
||||
const scroll = await waitFor(() => {
|
||||
const el = c.querySelector<HTMLElement>('.ce-scroll')
|
||||
@@ -83,7 +83,7 @@ describe('Editor view modes', () => {
|
||||
})
|
||||
// Line 30 is the first rewritten line. Unwrapped, its band is arithmetic:
|
||||
// a 10px top pad plus 20px per line, and the hit test reads clientY.
|
||||
fireEvent.mouseMove(scroll, { clientY: 10 + 29 * 20 + 5 })
|
||||
fireEvent.click(scroll, { clientY: 10 + 29 * 20 + 5 })
|
||||
const peek = await waitFor(() => {
|
||||
const p = c.querySelector<HTMLElement>('.peek')
|
||||
if (!p) throw new Error('peek not ready')
|
||||
@@ -93,7 +93,8 @@ describe('Editor view modes', () => {
|
||||
expect(removed).toHaveLength(1)
|
||||
expect(removed[0].textContent).toContain("'plan' => $user->plan,")
|
||||
|
||||
fireEvent.mouseLeave(scroll)
|
||||
// A second click on the same band drops it.
|
||||
fireEvent.click(scroll, { clientY: 10 + 29 * 20 + 5 })
|
||||
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
|
||||
})
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('a file that is staged and then edited again', () => {
|
||||
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING'])
|
||||
})
|
||||
|
||||
it('the hover panel behind Actual always reaches back to HEAD', async () => {
|
||||
it('the picked panel behind Actual always reaches back to HEAD', async () => {
|
||||
const c = await boot()
|
||||
fireEvent.click(group(c, 'Changes')[0])
|
||||
const scroll = await waitFor(() => {
|
||||
@@ -112,7 +112,7 @@ describe('a file that is staged and then edited again', () => {
|
||||
return el
|
||||
})
|
||||
// Line 2 is 'STAGED'. Unwrapped, its band runs from 10 + (2-1)*20.
|
||||
fireEvent.mouseMove(scroll, { clientY: 10 + 20 + 5 })
|
||||
fireEvent.click(scroll, { clientY: 10 + 20 + 5 })
|
||||
const peek = await waitFor(() => {
|
||||
const p = c.querySelector<HTMLElement>('.peek')
|
||||
if (!p) throw new Error('peek not ready')
|
||||
@@ -121,7 +121,8 @@ describe('a file that is staged and then edited again', () => {
|
||||
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
|
||||
expect(removed.map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['b'])
|
||||
|
||||
fireEvent.mouseLeave(scroll)
|
||||
// A click anywhere off the code drops it.
|
||||
fireEvent.mouseDown(document.body)
|
||||
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
|
||||
})
|
||||
|
||||
|
||||
@@ -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