Improve the git diff preview from the file viewer
CI / check (push) Waiting to run

This commit is contained in:
2026-09-21 06:47:46 +02:00
parent a2d1c5df83
commit dbb7e40c6c
10 changed files with 98 additions and 221 deletions
+2 -2
View File
@@ -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. - **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. - **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). - **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 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.
- **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. - **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 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 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. **A click on a marked line opens the full-screen Diff on that line**: the pair is always the file-level one (HEAD vs disk, never the git row, because that is what the mark means), and both panes scroll so the clicked line sits in the middle of the window. `Esc` returns to Actual. 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 view. **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 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. - **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. - **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. - **`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.
+1 -1
View File
@@ -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. 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. 4. **Preview**: markdown files only. It shows the rendered document instead of the source.
**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. **In Actual, a click on a marked line opens the Diff.** The full-screen side-by-side view comes up on the pair of the whole file, HEAD against disk, and both panes scroll so that the clicked line sits in the middle of the window. A line near the start or the end of the file goes as near to the middle as the file permits. `Esc` returns to Actual. 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). 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).
+4 -1
View File
@@ -26,10 +26,13 @@ const rgPathPromise: Promise<string | null> = (async () => {
export interface ContentHit { no: number; ln: string; ix: number } export interface ContentHit { no: number; ln: string; ix: number }
export interface ContentGroup { path: string; hits: ContentHit[] } export interface ContentGroup { path: string; hits: ContentHit[] }
/** Always-excluded heavy/noise dirs, on top of gitignore + user excludes. */ /** Always-excluded heavy/noise dirs and files, on top of gitignore + user
* excludes. `.DS_Store` is here because `files.followGitignore` defaults to
* false, so rg runs with --no-ignore and would otherwise list it in the tree. */
const BASE_IGNORE = [ const BASE_IGNORE = [
'node_modules', '.git', 'out', 'dist', 'build', '.cache', 'node_modules', '.git', 'out', 'dist', 'build', '.cache',
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode', 'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode',
'.DS_Store',
] ]
const MAX_FILES = 400 const MAX_FILES = 400
+21 -9
View File
@@ -2,8 +2,8 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { FileTree, GitPanel, Icon, Tip, groupByDir } from './components' import { FileTree, GitPanel, Icon, Tip, groupByDir } from './components'
import type { ContextTarget } from './components' import type { ContextTarget } from './components'
import { Editor, OriginalPeek, SplitView } from './editor' import { Editor, SplitView } from './editor'
import type { Cursor, Mode, PeekInfo, Selection } from './editor' import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals' import { Terminal, lid } from './terminals'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays' import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays'
import { ProjectLauncher } from './launcher' import { ProjectLauncher } from './launcher'
@@ -114,7 +114,9 @@ export function App(): React.ReactElement {
const [histInitSel, setHistInitSel] = useState(0) const [histInitSel, setHistInitSel] = useState(0)
const [menu, setMenu] = useState<Menu | null>(null) const [menu, setMenu] = useState<Menu | null>(null)
const [toasts, setToasts] = useState<Toast[]>([]) const [toasts, setToasts] = useState<Toast[]>([])
const [peek, setPeek] = useState<PeekInfo | null>(null) /* The line a click in Actual asked the Diff view to centre on. Kept per path
* so a Diff opened any other way starts at the top. */
const [splitFocus, setSplitFocus] = useState<{ path: string; line: number } | null>(null)
const [commitMsg, setCommitMsg] = useState('') const [commitMsg, setCommitMsg] = useState('')
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string; code?: string } | null>(null) const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string; code?: string } | null>(null)
const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null) const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
@@ -565,8 +567,15 @@ export function App(): React.ReactElement {
useEffect(() => { if (mode !== 'diff') preDiff.current = mode }, [mode]) useEffect(() => { if (mode !== 'diff') preDiff.current = mode }, [mode])
const splitOpen = !!active && mode === 'diff' && !!proj.diffs[active] && !HL.isImage(active) const splitOpen = !!active && mode === 'diff' && !!proj.diffs[active] && !HL.isImage(active)
function closeSplit(): void { function closeSplit(): void {
setSplitFocus(null)
if (active) setTabMode((m) => ({ ...m, [active]: preDiff.current })) if (active) setTabMode((m) => ({ ...m, [active]: preDiff.current }))
} }
/* A click on a marked line in Actual: the full-screen Diff opens on it. */
function openDiffAt(line: number): void {
if (!active) return
setSplitFocus({ path: active, line })
setTabMode((m) => ({ ...m, [active]: 'diff' }))
}
function stageGuarded(p: string): void { function stageGuarded(p: string): void {
if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return
actions.stage(p) actions.stage(p)
@@ -1132,8 +1141,8 @@ export function App(): React.ReactElement {
<div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}> <div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
<Editor active={active} mode={mode} side={activeSide} <Editor active={active} mode={mode} side={activeSide}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); reloadFromDisk(active) } }} setMode={(m) => { if (active) { setSplitFocus(null); setTabMode((mm) => ({ ...mm, [active]: m })); reloadFromDisk(active) } }}
onContext={openMenu} onPeek={setPeek} onContext={openMenu} onOpenDiff={openDiffAt}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection} cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
flash={lineFlash && lineFlash.path === active ? lineFlash : null} flash={lineFlash && lineFlash.path === active ? lineFlash : null}
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} /> bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
@@ -1149,9 +1158,6 @@ export function App(): React.ReactElement {
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} active={activePanel === 'terminal'} {proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} active={activePanel === 'terminal'}
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />} onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
{/* The hover reveal of Diff mode. It belongs to the body row, so it
covers the agent column and stops at the status bar. */}
{peek && <OriginalPeek {...peek} />}
</div> </div>
{/* status bar — display only: nothing here is clickable */} {/* status bar — display only: nothing here is clickable */}
@@ -1173,7 +1179,13 @@ export function App(): React.ReactElement {
</div> </div>
{/* overlays */} {/* overlays */}
{splitOpen && <SplitView path={active as string} side={activeSide} onClose={closeSplit} onContext={openMenu} />} {splitOpen && (() => {
/* A click in Actual marks against HEAD, so the Diff it opens must
* compare the same pair the file, not the git row. */
const line = splitFocus && splitFocus.path === active ? splitFocus.line : null
return <SplitView path={active as string} side={line == null ? activeSide : null}
focusLine={line} onClose={closeSplit} onContext={openMenu} />
})()}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code} {passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code}
onConfirm={(payload) => { onConfirm={(payload) => {
window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload })) window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload }))
+45 -141
View File
@@ -1,5 +1,5 @@
/* Editor: four view modes (Actual / Original / Diff / Preview) + line selection */ /* Editor: four view modes (Actual / Original / Diff / Preview) + line selection */
import React, { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import React, { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { Diff, DiffSide, ViewLine } from './types' import type { Diff, DiffSide, ViewLine } from './types'
import { rowId } from './types' import { rowId } from './types'
import { useProject } from './project' import { useProject } from './project'
@@ -92,8 +92,8 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
flash: FlashLine | null flash: FlashLine | null
/** Changed lines of this file, from the HEAD-vs-disk diff. */ /** Changed lines of this file, from the HEAD-vs-disk diff. */
marks: ChangeMark[] marks: ChangeMark[]
/** The marked line under the pointer, with its top in the pane's viewport. */ /** A click landed on a marked line: open the full-screen Diff there. */
onPick: (h: { line: number; top: number } | null) => void onPick: (line: number) => void
onChange: (text: string) => void onChange: (text: string) => void
onContext: OnContext onContext: OnContext
onSymbol: OnSymbol onSymbol: OnSymbol
@@ -159,32 +159,21 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
})) }))
}, [marks, wrap, text, paneW]) }, [marks, wrap, text, paneW])
/* The reveal of the old lines. A click pins it, a second click on the same /* A click on a marked line opens the full-screen Diff at that line. The click
* band or a click off every band drops it. The click lands on the textarea * lands on the textarea rather than on a row, so the line comes from the
* rather than on a row, so the line comes from the bands the same geometry * bands the same geometry that drew the tint. */
* 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)
pickRef.current = b ? b.line : null
onPick(b && scrollRef.current ? { line: b.line, top: b.top - scrollRef.current.scrollTop } : null)
}
function pickAt(e: React.MouseEvent): void { function pickAt(e: React.MouseEvent): void {
if (!bands.length && pickRef.current == null) return if (!bands.length) return
const box = innerRef.current?.getBoundingClientRect() const box = innerRef.current?.getBoundingClientRect()
if (!box) return if (!box) return
const y = e.clientY - box.top const y = e.clientY - box.top
const hit = bands.find((b) => y >= b.top && y < b.top + b.height) const hit = bands.find((b) => y >= b.top && y < b.top + b.height)
report(bands, hit && hit.line !== pickRef.current ? hit.line : null) if (hit) onPick(hit.line)
} }
// An edit moves the bands under the pinned line; the panel follows them.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => { if (pickRef.current != null) report(bands, pickRef.current) }, [bands])
function onScroll(): void { function onScroll(): void {
const s = scrollRef.current const s = scrollRef.current
if (s && gutterRef.current) gutterRef.current.style.transform = `translateY(${-s.scrollTop}px)` if (s && gutterRef.current) gutterRef.current.style.transform = `translateY(${-s.scrollTop}px)`
if (pickRef.current != null) report(bands, pickRef.current)
} }
// The textarea is overflow-hidden under the scroller, so keep the caret line // 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). // in view by scrolling the container ourselves (6px top pad, 20px line-height).
@@ -492,62 +481,42 @@ 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 picked row. */
interface PeekTarget {
anchor: number
from: number | null
to: number | null
}
/* Actual: the current file with the changed lines marked in place. Removals own /* 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 * 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 * its place unless that line is itself an added one, which already says the
* same thing in teal. The map is what the overlay reads. */ * same thing in teal. */
export function buildDiffView(diff: Diff): { lines: ViewLine[]; peek: Map<number, PeekTarget> } { export function buildDiffView(diff: Diff): ViewLine[] {
const peek = new Map<number, PeekTarget>()
const above = new Set<number>() const above = new Set<number>()
let tail: PeekTarget | null = null let tail = false
let dels: number[] = [] let dels = 0
let adds: number[] = [] let adds = 0
let prevSame = 0
const flush = (nextNew: number | null): void => { const flush = (nextNew: number | null): void => {
if (dels.length && adds.length) { if (dels && !adds) {
adds.forEach((no, k) => { if (nextNew == null) tail = true
const old = dels[Math.min(k, dels.length - 1)] else above.add(nextNew)
peek.set(no, { anchor: old, from: old, to: old })
})
} else if (dels.length) {
const target: PeekTarget = { anchor: dels[0], from: dels[0], to: dels[dels.length - 1] }
if (nextNew == null) tail = target
else { peek.set(nextNew, target); above.add(nextNew) }
} else {
adds.forEach((no) => peek.set(no, { anchor: Math.max(1, prevSame), from: null, to: null }))
} }
dels = []; adds = [] dels = 0; adds = 0
} }
for (const r of diff.rows) { for (const r of diff.rows) {
if (r.sign === '-') dels.push(r.oldNo as number) if (r.sign === '-') dels++
else if (r.sign === '+') adds.push(r.newNo as number) else if (r.sign === '+') adds++
else { flush(r.newNo); prevSame = r.oldNo as number } else flush(r.newNo)
} }
flush(null) flush(null)
// A run removed at the end of the file has no line after it, so its rule goes // A run removed at the end of the file has no line after it, so its rule goes
// under the last one instead. // under the last one instead.
const last = diff.right.length ? diff.right[diff.right.length - 1].no : null const last = diff.right.length ? diff.right[diff.right.length - 1].no : null
if (tail && last != null) peek.set(last, tail) return diff.right.map((l) => ({
const lines: ViewLine[] = diff.right.map((l) => ({
no: l.no, no: l.no,
text: l.text, text: l.text,
row: l.mark === 'add' ? 'add' : null, row: l.mark === 'add' ? 'add' : null,
gap: above.has(l.no) ? 'above' : tail && l.no === last ? 'below' : null, gap: above.has(l.no) ? 'above' : tail && l.no === last ? 'below' : null,
})) }))
return { lines, peek }
} }
/* Build the line descriptors for the read-only whole-file modes. Actual has its /* Build the line descriptors for the read-only whole-file modes. Actual has its
* own builder above, because it also has to answer what each line used to be. */ * own builder above, because it also marks the lines the change touched. */
function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } { function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } {
if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false } if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false }
if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false } if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false }
@@ -567,15 +536,15 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
return segs return segs
} }
export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: { export function Editor({ active, mode, side, setMode, onContext, onOpenDiff, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
active: string | null active: string | null
mode: Mode mode: Mode
/** Which git row opened this tab. Only Diff follows it. */ /** Which git row opened this tab. Only Diff follows it. */
side: DiffSide | null side: DiffSide | null
setMode: (m: Mode) => void setMode: (m: Mode) => void
onContext: OnContext onContext: OnContext
/** The original of the line under the pointer, for the overlay App renders. */ /** A click on a marked line in Actual: open Diff centred on that line. */
onPeek: (p: PeekInfo | null) => void onOpenDiff: (line: number) => void
cursor: Cursor | null cursor: Cursor | null
selection: Selection | null selection: Selection | null
setCursor: (c: Cursor) => void setCursor: (c: Cursor) => void
@@ -620,12 +589,11 @@ export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor,
const paneMode: Mode = effMode === 'diff' ? 'updated' : effMode const paneMode: Mode = effMode === 'diff' ? 'updated' : effMode
// The pair the bar counts: the clicked git row for Diff, the whole file otherwise. // The pair the bar counts: the clicked git row for Diff, the whole file otherwise.
const shown = effMode === 'diff' ? rowDiff : diff const shown = effMode === 'diff' ? rowDiff : diff
/* Actual marks the changed lines and answers what each one replaced. Both come /* Actual marks the changed lines from the file-level pair, never from the git
* from the file-level pair, never from the git row: the buffer on screen is the * row: the buffer on screen is the file on disk. */
* file on disk. */
const diffView = useMemo(() => (paneMode === 'updated' && diff ? buildDiffView(diff) : null), [paneMode, diff]) const diffView = useMemo(() => (paneMode === 'updated' && diff ? buildDiffView(diff) : null), [paneMode, diff])
const marks = useMemo<ChangeMark[]>(() => (diffView const marks = useMemo<ChangeMark[]>(() => (diffView
? diffView.lines ? diffView
.filter((l) => l.row === 'add' || l.gap) .filter((l) => l.row === 'add' || l.gap)
.map((l) => ({ line: l.no as number, tint: l.row === 'add', gap: l.gap ?? null })) .map((l) => ({ line: l.no as number, tint: l.row === 'add', gap: l.gap ?? null }))
: []), [diffView]) : []), [diffView])
@@ -647,38 +615,6 @@ export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor,
// Editable in the live-buffer modes; Original/Preview stay read-only views. // Editable in the live-buffer modes; Original/Preview stay read-only views.
const editable = paneMode === 'code' || paneMode === 'updated' const editable = paneMode === 'code' || paneMode === 'updated'
/* 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 [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 = pick && diffView ? diffView.peek.get(pick.line) ?? null : null
const peekLines = useMemo<ViewLine[]>(() => (target && diff
? diff.left.map((l) => ({
no: l.no,
text: l.text,
row: target.from != null && l.no >= target.from && l.no <= (target.to as number) ? 'del' : null,
}))
: []), [diff, target])
useEffect(() => {
onPeek(active && target && pick
? { path: active, lines: peekLines, lang, wrap, anchor: target.anchor, top: pick.top, empty: peekLines.length === 0 }
: null)
}, [active, target, pick, peekLines, lang, wrap, onPeek])
useEffect(() => () => onPeek(null), [onPeek])
return ( return (
<Fragment> <Fragment>
@@ -732,7 +668,7 @@ export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor,
<NoOriginal /> <NoOriginal />
) : editable ? ( ) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} marks={marks} <CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} marks={marks}
onPick={onPickLine} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} /> onPick={onOpenDiff} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
) : ( ) : (
built && <PaneView cacheKey={tab.path + ':' + paneMode} path={tab.path} lines={built.lines} built && <PaneView cacheKey={tab.path + ':' + paneMode} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection} lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
@@ -744,59 +680,14 @@ export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor,
) )
} }
/** 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 picked one. */
anchor: number
/** The picked row's top, measured inside the editor viewport. */
top: number
empty: boolean
}
const noop = (): void => undefined
/* The original file, over the agent column and level with the editor. It takes
* 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
// real geometry keeps the two versions on one line of the screen.
useLayoutEffect(() => {
const scroller = bodyRef.current?.querySelector('.editor') as HTMLElement | null
const row = scroller?.querySelector(`.ln-row[data-line="${anchor}"]`) as HTMLElement | null
if (!scroller || !row) return
scroller.scrollTop += row.getBoundingClientRect().top - scroller.getBoundingClientRect().top - top
}, [anchor, top, lines])
return (
<div className="peek">
<div className="peek-head">
<span className="ph-label">Original</span>
<span className="ph-note">before this change · same scroll</span>
<span className="ph-hint">click the line again to close</span>
</div>
<div className="peek-body" ref={bodyRef}>
{empty ? <NoOriginal /> : (
<PaneView cacheKey={path + ':peek'} path={path} lines={lines} lang={lang} showSign={false} wrap={wrap}
cursor={null} selection={null} setCursor={noop} setSelection={noop} onContext={noop} onSymbol={noop} />
)}
</div>
</div>
)
}
/* The Diff view: the two versions side by side, over the whole window. Esc puts /* The Diff view: the two versions side by side, over the whole window. Esc puts
* the pane back where it was. */ * the pane back where it was. */
export function SplitView({ path, side, onClose, onContext }: { export function SplitView({ path, side, focusLine, onClose, onContext }: {
path: string path: string
/** Which git row opened this file. Diff compares that row's pair. */ /** Which git row opened this file. Diff compares that row's pair. */
side: DiffSide | null side: DiffSide | null
/** Current-side line to put in the middle of the view, from the click in Actual. */
focusLine: number | null
onClose: () => void onClose: () => void
onContext: OnContext onContext: OnContext
}): React.ReactElement { }): React.ReactElement {
@@ -812,6 +703,19 @@ export function SplitView({ path, side, onClose, onContext }: {
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ''), [path, side]) const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ''), [path, side])
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ''), [path, side]) const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ''), [path, side])
/* The click in Actual names a line, and the view opens on it. Measured, not
* counted: only the real geometry survives a row that the other side pads. */
useLayoutEffect(() => {
const right = rightRef.current, left = leftRef.current
if (focusLine == null || !right) return
const row = right.querySelector(`.ln-row[data-line="${focusLine}"]`) as HTMLElement | null
if (!row) return
const box = row.getBoundingClientRect(), pane = right.getBoundingClientRect()
const top = right.scrollTop + box.top - pane.top - (pane.height - box.height) / 2
right.scrollTop = Math.max(0, top)
if (left) left.scrollTop = right.scrollTop
}, [path, side, focusLine, diff])
function sync(from: HTMLDivElement | null, to: HTMLDivElement | null): void { function sync(from: HTMLDivElement | null, to: HTMLDivElement | null): void {
if (lock.current || !from || !to) return if (lock.current || !from || !to) return
lock.current = true lock.current = true
-22
View File
@@ -493,28 +493,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.ce-gutter .chg { color:var(--add); } .ce-gutter .chg { color:var(--add); }
.ce-line.chg::before { color:var(--add); } .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 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;
background:var(--bg-0); border-left:2px solid var(--del); box-shadow:var(--shadow-menu);
}
/* The same height as the view strip, so line one of both panes shares a y. */
.peek-head {
height:var(--bar-h); flex:0 0 var(--bar-h); display:flex; align-items:center; gap:12px;
padding:0 14px; background:var(--bg-2); border-bottom:1px solid var(--border);
}
.peek-head .ph-label { font-family:var(--mono); font-weight:700; font-size:10px; line-height:1;
letter-spacing:.14em; text-transform:uppercase; color:var(--del); }
.peek-head .ph-note, .peek-head .ph-hint { font-family:var(--mono); font-size:11px; line-height:1; color:var(--fg-3); }
.peek-head .ph-hint { margin-left:auto; }
.peek-body { flex:1; min-height:0; display:flex; flex-direction:column; }
/* Amber is the previous state, so the line the hovered one replaced reads one
step below the current text. */
.peek-body .ln-row.del .ln-code { color:var(--fg-1); }
/* syntax token colors applied to both the read-only line views (.ln-code) /* syntax token colors applied to both the read-only line views (.ln-code)
* and the editable buffer's highlight layer (.ce-pre) */ * and the editable buffer's highlight layer (.ce-pre) */
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important,.ce-pre .token.keyword,.ce-pre .token.rule,.ce-pre .token.atrule,.ce-pre .token.important{color:var(--t-key);} .ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important,.ce-pre .token.keyword,.ce-pre .token.rule,.ce-pre .token.atrule,.ce-pre .token.important{color:var(--t-key);}
+4 -7
View File
@@ -52,7 +52,7 @@ describe('a change that only removes a line', () => {
expect(c.querySelector('.ce-gutter .chg')).toBeNull() expect(c.querySelector('.ce-gutter .chg')).toBeNull()
}) })
it('hands the removed line to the picked panel', async () => { it('opens the full-screen Diff from the ruled line', async () => {
const c = await openRow() const c = await openRow()
const scroll = await waitFor(() => { const scroll = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.ce-scroll') const el = c.querySelector<HTMLElement>('.ce-scroll')
@@ -61,11 +61,8 @@ describe('a change that only removes a line', () => {
}) })
// 'tail' took the place of the removed line, and sits on line 2. // 'tail' took the place of the removed line, and sits on line 2.
fireEvent.click(scroll, { clientY: 10 + 20 + 5 }) fireEvent.click(scroll, { clientY: 10 + 20 + 5 })
const peek = await waitFor(() => { await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
const p = c.querySelector<HTMLElement>('.peek') const left = Array.from(c.querySelectorAll('.split-pane.left .ln-row.del'))
if (!p) throw new Error('peek not ready') expect(left.map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['gone'])
return p
})
expect(Array.from(peek.querySelectorAll('.ln-row.del')).map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['gone'])
}) })
}) })
+9 -14
View File
@@ -2,44 +2,39 @@ import { describe, it, expect } from 'vitest'
import { buildDiffView } from '../src/renderer/src/editor' import { buildDiffView } from '../src/renderer/src/editor'
import { makeDiff } from '../src/renderer/src/diff' import { makeDiff } from '../src/renderer/src/diff'
/** The current-side view of one text pair, plus the map the overlay reads. */ /** The current-side view of one text pair: the lines Actual marks. */
function view(original: string, updated: string): ReturnType<typeof buildDiffView> { function view(original: string, updated: string): ReturnType<typeof buildDiffView> {
return buildDiffView(makeDiff('M', original, updated)) return buildDiffView(makeDiff('M', original, updated))
} }
describe('buildDiffView', () => { describe('buildDiffView', () => {
it('maps an added line to the line it replaced', () => { it('marks a rewritten line in place', () => {
const { lines, peek } = view('a\nold\nb', 'a\nnew\nb') const lines = view('a\nold\nb', 'a\nnew\nb')
expect(lines.map((l) => l.text)).toEqual(['a', 'new', 'b']) expect(lines.map((l) => l.text)).toEqual(['a', 'new', 'b'])
expect(lines[1].row).toBe('add') expect(lines[1].row).toBe('add')
expect(peek.get(2)).toEqual({ anchor: 2, from: 2, to: 2 })
expect(lines.every((l) => !l.gap)).toBe(true) expect(lines.every((l) => !l.gap)).toBe(true)
}) })
it('puts a pure deletion on the line that follows it', () => { it('puts a pure deletion on the line that follows it', () => {
const { lines, peek } = view('a\ngone\nb', 'a\nb') const lines = view('a\ngone\nb', 'a\nb')
expect(lines[1].text).toBe('b') expect(lines[1].text).toBe('b')
expect(lines[1].gap).toBe('above') expect(lines[1].gap).toBe('above')
expect(lines[1].row).toBeNull() expect(lines[1].row).toBeNull()
expect(peek.get(2)).toEqual({ anchor: 2, from: 2, to: 2 })
}) })
it('puts a deletion at end of file under the last line', () => { it('puts a deletion at end of file under the last line', () => {
const { lines, peek } = view('a\nb\ntail', 'a\nb') const lines = view('a\nb\ntail', 'a\nb')
expect(lines[1].gap).toBe('below') expect(lines[1].gap).toBe('below')
expect(peek.get(2)).toEqual({ anchor: 3, from: 3, to: 3 })
}) })
it('anchors an insertion but marks nothing as removed', () => { it('marks an insertion and leaves the neighbours alone', () => {
const { lines, peek } = view('a\nb', 'a\nNEW\nb') const lines = view('a\nb', 'a\nNEW\nb')
expect(lines[1].row).toBe('add') expect(lines[1].row).toBe('add')
expect(peek.get(2)).toEqual({ anchor: 1, from: null, to: null })
expect(lines.every((l) => !l.gap)).toBe(true) expect(lines.every((l) => !l.gap)).toBe(true)
}) })
it('leaves an unchanged file without marks or peek targets', () => { it('leaves an unchanged file without marks', () => {
const { lines, peek } = view('a\nb\n', 'a\nb\n') const lines = view('a\nb\n', 'a\nb\n')
expect(peek.size).toBe(0)
expect(lines.every((l) => !l.row && !l.gap)).toBe(true) expect(lines.every((l) => !l.row && !l.gap)).toBe(true)
}) })
}) })
+8 -12
View File
@@ -74,7 +74,7 @@ describe('Editor view modes', () => {
expect(find(c, '.seg button.on', 'Original')).toBeTruthy() expect(find(c, '.seg button.on', 'Original')).toBeTruthy()
}) })
it('clicking a band in Actual reveals the original, clicking it again hides it', async () => { it('clicking a band in Actual opens the full-screen Diff on that line', async () => {
const c = await openChanged() const c = await openChanged()
const scroll = await waitFor(() => { const scroll = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.ce-scroll') const el = c.querySelector<HTMLElement>('.ce-scroll')
@@ -84,18 +84,14 @@ describe('Editor view modes', () => {
// Line 30 is the first rewritten line. Unwrapped, its band is arithmetic: // 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. // a 10px top pad plus 20px per line, and the hit test reads clientY.
fireEvent.click(scroll, { clientY: 10 + 29 * 20 + 5 }) fireEvent.click(scroll, { clientY: 10 + 29 * 20 + 5 })
const peek = await waitFor(() => { await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
const p = c.querySelector<HTMLElement>('.peek') const right = Array.from(c.querySelectorAll('.split-pane.right .ln-row'))
if (!p) throw new Error('peek not ready') expect(right.some((el) => el.textContent?.includes("'plan'"))).toBe(true)
return p
})
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
expect(removed).toHaveLength(1)
expect(removed[0].textContent).toContain("'plan' => $user->plan,")
// A second click on the same band drops it. // Esc puts the pane back in Actual.
fireEvent.click(scroll, { clientY: 10 + 29 * 20 + 5 }) act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
await waitFor(() => expect(c.querySelector('.peek')).toBeNull()) await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
expect(c.querySelector('.seg button.on')?.textContent).toBe('Actual')
}) })
it('⌘M cycles Actual → Original → Diff, and nothing else', async () => { it('⌘M cycles Actual → Original → Diff, and nothing else', async () => {
+4 -12
View File
@@ -103,7 +103,7 @@ describe('a file that is staged and then edited again', () => {
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING']) expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING'])
}) })
it('the picked panel behind Actual always reaches back to HEAD', async () => { it('the Diff opened from Actual always reaches back to HEAD', async () => {
const c = await boot() const c = await boot()
fireEvent.click(group(c, 'Changes')[0]) fireEvent.click(group(c, 'Changes')[0])
const scroll = await waitFor(() => { const scroll = await waitFor(() => {
@@ -113,17 +113,9 @@ describe('a file that is staged and then edited again', () => {
}) })
// Line 2 is 'STAGED'. Unwrapped, its band runs from 10 + (2-1)*20. // Line 2 is 'STAGED'. Unwrapped, its band runs from 10 + (2-1)*20.
fireEvent.click(scroll, { clientY: 10 + 20 + 5 }) fireEvent.click(scroll, { clientY: 10 + 20 + 5 })
const peek = await waitFor(() => { await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
const p = c.querySelector<HTMLElement>('.peek') expect(splitText(c, 'left')).toEqual(['a', 'b', 'c', ''])
if (!p) throw new Error('peek not ready') expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING'])
return p
})
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
expect(removed.map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['b'])
// A click anywhere off the code drops it.
fireEvent.mouseDown(document.body)
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
}) })
it('labels which pair the diff is comparing', async () => { it('labels which pair the diff is comparing', async () => {