Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,13 @@ needs the popup↔anchor pairing or toggle-off breaks.
`test/git.test.ts` builds throwaway repos; extend it for any git.ts
change.
- `node test/ui.test.js` — full Playwright E2E against the real
renderer + real git layer over an HTTP shim (Chromium at
`/opt/pw-browsers/chromium`, override with `DIFFIER_CHROMIUM`). Run
`yarn build` first (nothing chains it since the test is invoked
directly, not via `yarn test`); run it before claiming any
renderer change works. Edit `test/ui.test.ts`, not the compiled
`test/ui.test.js`.
renderer + real git layer over an HTTP shim. Uses the sandbox
Chromium at `/opt/pw-browsers/chromium` when that path exists and
Playwright's own downloaded browser otherwise; `DIFFIER_CHROMIUM`
overrides both. Run `yarn build` first (nothing chains it since the
test is invoked directly, not via `yarn test`); run it before
claiming any renderer change works. Edit `test/ui.test.ts`, not the
compiled `test/ui.test.js`.
- `yarn smoke` — boots real Electron headless-ish; needs the Electron
binary (often unavailable in sandboxes; the UI test is the substitute).
- `yarn typecheck` — fast `--noEmit` check across all four
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,9 @@ entry, tests) without writing any `.js`.
Git layer and drives the full flow (tree, F7 navigation, editing +
autosave, commit, rollback, hunk staging, blame, file history, log
details, filtering, stash, branch create/switch, conflict resolution)
with Playwright. Set `DIFFIER_CHROMIUM` to your Chromium binary if it
is not at `/opt/pw-browsers/chromium`.
with Playwright. It uses `/opt/pw-browsers/chromium` when that path
exists and Playwright's own downloaded browser otherwise; set
`DIFFIER_CHROMIUM` to point at a specific binary.
- `yarn smoke` — recompiles, then boots the real Electron app, loads
the repo from `DIFFIER_SMOKE_REPO`, and fails on any renderer error.

Expand Down
2 changes: 2 additions & 0 deletions main/keymap-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type ActionId =
| 'rollback'
| 'toggle-log'
| 'filter'
| 'find'
| 'annotate'
| 'save'
| 'open-repo'
Expand Down Expand Up @@ -75,6 +76,7 @@ export const ACTIONS: KeymapAction[] = [
{ id: 'rollback', label: 'Rollback Selected', default: 'Alt+Mod+Z' },
{ id: 'toggle-log', label: 'Log Tool Window', default: 'Mod+9' },
{ id: 'filter', label: 'Filter Changes', default: 'Mod+Shift+F' },
{ id: 'find', label: 'Find in Diff / Document', default: 'Mod+F' },
{ id: 'annotate', label: 'Toggle Blame Annotations', default: null },
{ id: 'save', label: 'Save File', default: 'Mod+S' },
{ id: 'open-repo', label: 'Open Repository', default: 'Mod+O' },
Expand Down
2 changes: 2 additions & 0 deletions main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,8 @@ function buildMenu(): void {
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
{ type: 'separator' },
mi('find', 'Find in Diff / Document'),
],
},
{
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"smoke": "yarn build && DIFFIER_SMOKE=1 electron --no-sandbox .",
"test": "yarn build && node test/git.test.js && node test/keymap.test.js && node test/themes.test.js && node test/languages.test.js",
"build:highlighter": "esbuild renderer/highlighter-entry.ts --bundle --format=iife --minify --log-level=warning --outfile=renderer/highlighter.js",
"build:mermaid": "esbuild renderer/mermaid-entry.ts --bundle --format=iife --minify --log-level=warning --outfile=renderer/mermaid.js",
"build:mermaid": "esbuild renderer/mermaid-entry.ts --bundle --format=iife --minify --log-level=warning --banner:js=\"(function(define){\" --footer:js=\"})();\" --outfile=renderer/mermaid.js",
"build:preload": "esbuild main/preload.ts --bundle --platform=node --format=cjs --external:electron --log-level=warning --outfile=main/preload.js",
"postinstall": "yarn build:highlighter || node -e \"console.warn('shiki highlighter bundle skipped; falling back to built-in grammars')\"; yarn build:mermaid || node -e \"console.warn('mermaid bundle skipped; mermaid fences will render as plain code blocks')\"",
"dist": "yarn build && yarn build:highlighter && yarn build:mermaid && BUILD_NUMBER=${BUILD_NUMBER:-dev} electron-builder --mac"
Expand Down
9 changes: 9 additions & 0 deletions renderer/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const ACTION_IMPL: Partial<Record<ActionId, () => void>> = {
$('tree-filter').focus();
$<HTMLInputElement>('tree-filter').select();
},
find: () => findInView(),
annotate: () => toggleBlame(),
'zoom-in': () => zoomIn(),
'zoom-out': () => zoomOut(),
Expand Down Expand Up @@ -146,6 +147,14 @@ window.addEventListener(
return;
}

// Escape closes the markdown find bar before the keymap turns it into
// "focus changes tree".
if (mdFindOpen() && e.key === 'Escape') {
e.preventDefault();
closeMdFind();
return;
}

// While the About dialog is open, only Escape (close) is handled.
if (aboutOpen) {
if (e.key === 'Escape') {
Expand Down
9 changes: 9 additions & 0 deletions renderer/app/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ $('btn-md-view').addEventListener('click', (e) => {
}
});

// Find bar inside the markdown pane (Monaco's own widget covers the editors).
$('md-find-input').addEventListener('input', () => runMdFind());
$('md-find-input').addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') stepMdFind((e as KeyboardEvent).shiftKey ? -1 : 1);
});
$('btn-md-find-next').addEventListener('click', () => stepMdFind(1));
$('btn-md-find-prev').addEventListener('click', () => stepMdFind(-1));
$('btn-md-find-close').addEventListener('click', closeMdFind);

// Diff / Old / New buttons inside the markdown pane itself.
$('md-mode-bar').addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-md-mode]');
Expand Down
2 changes: 2 additions & 0 deletions renderer/app/diff-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function showPane(which: DiffPaneView): void {
$('markdown-diff').classList.toggle('hidden', which !== 'markdown');
$('conflict-bar').classList.toggle('hidden', which !== 'conflict');
$('viewer-mode').classList.toggle('hidden', which === 'markdown');
if (which !== 'markdown') closeMdFind();
}

function setDiffHeader(file: DiffableFile, extra?: string): void {
Expand Down Expand Up @@ -268,6 +269,7 @@ function showMarkdownPane(mode: MdPaneMode): void {
showPane('markdown');
updateMdDiffRuler();
resetMdChangeNav();
refreshMdFind();
}

function disposeModels(): void {
Expand Down
1 change: 1 addition & 0 deletions renderer/app/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function updateShortcutHints(): void {
$<HTMLInputElement>('tree-filter').placeholder = 'Filter changes…' +
(actionShortcut('filter') === 'None' ? '' : ` (${actionShortcut('filter')})`);
$('status-branch').title = 'Branches' + hint('branches');
$<HTMLInputElement>('md-find-input').placeholder = 'Find in document…' + hint('find');
}

// Layout-stable key name from a keyboard event (e.key for shifted
Expand Down
93 changes: 93 additions & 0 deletions renderer/app/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,99 @@ function updateMdDiffRuler(): void {
}
}

// ------------------------------------------------------ markdown find

/* Find-in-document for the rendered markdown pane — the one view with no
editor, so Monaco's own find widget can't serve it. Matches are painted
with the CSS Custom Highlight API (Ranges, no DOM mutation), so the
rendered document and its diff tinting stay exactly as rendered and
clearing is one delete() per highlight. */

let mdFindMatches: Range[] = [];
let mdFindIndex = -1;

function mdFindOpen(): boolean {
return !$('md-find-bar').classList.contains('hidden');
}

function openMdFind(): void {
$('md-find-bar').classList.remove('hidden');
const input = $<HTMLInputElement>('md-find-input');
input.focus();
input.select();
runMdFind(true);
}

function closeMdFind(): void {
$('md-find-bar').classList.add('hidden');
mdFindMatches = [];
mdFindIndex = -1;
CSS.highlights.delete('md-find');
CSS.highlights.delete('md-find-current');
}

// ponytail: plain case-insensitive substring, matched inside one text node —
// a query straddling an inline element edge ("**bo**ld") won't hit. Search a
// concatenation of the pane with an offset map if that ever matters.
function runMdFind(keepIndex?: boolean): void {
const q = $<HTMLInputElement>('md-find-input').value.toLowerCase();
const prev = mdFindIndex;
mdFindMatches = [];
if (q) {
const walk = document.createTreeWalker($('md-diff-body'), NodeFilter.SHOW_TEXT);
for (let n = walk.nextNode(); n; n = walk.nextNode()) {
const text = (n.nodeValue || '').toLowerCase();
for (let i = text.indexOf(q); i !== -1; i = text.indexOf(q, i + q.length)) {
const r = document.createRange();
r.setStart(n, i);
r.setEnd(n, i + q.length);
mdFindMatches.push(r);
}
}
}
const wanted = keepIndex && prev > 0 ? prev : 0;
mdFindIndex = mdFindMatches.length ? Math.min(wanted, mdFindMatches.length - 1) : -1;
paintMdFind();
}

function paintMdFind(): void {
const current = mdFindIndex >= 0 ? mdFindMatches[mdFindIndex]! : null;
CSS.highlights.set('md-find', new Highlight(...mdFindMatches.filter((r) => r !== current)));
CSS.highlights.set('md-find-current', new Highlight(...(current ? [current] : [])));
$('md-find-count').textContent = mdFindMatches.length
? `${mdFindIndex + 1}/${mdFindMatches.length}`
: $<HTMLInputElement>('md-find-input').value
? 'No matches'
: '';
// Ranges have no scrollIntoView of their own; the containing element is
// close enough at markdown block sizes.
if (current) current.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'auto' });
}

function stepMdFind(delta: number): void {
if (!mdFindMatches.length) return;
mdFindIndex = (mdFindIndex + delta + mdFindMatches.length) % mdFindMatches.length;
paintMdFind();
}

// The pane re-renders on a mode switch, a file switch and every edit — the
// old Ranges then point at nodes that are gone, so re-find from scratch.
function refreshMdFind(): void {
if (mdFindOpen()) runMdFind(true);
}

// ⌘F: the markdown pane gets our bar, every editor view gets Monaco's own
// find widget (the global keydown handler swallows the key before Monaco
// would see it, so hand it over explicitly).
function findInView(): void {
if (!$('markdown-diff').classList.contains('hidden')) return openMdFind();
const ed: monaco.editor.ICodeEditor | null =
state.conflict && conflictEditor ? conflictEditor : diffEditor ? diffEditor.getModifiedEditor() : null;
if (!ed) return void toast('Nothing to search');
ed.focus();
void ed.getAction('actions.find')?.run();
}

function nextDifference(): void {
if (!$('markdown-diff').classList.contains('hidden')) {
nextMarkdownChange();
Expand Down
7 changes: 7 additions & 0 deletions renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@
</div>
</div>
<div id="markdown-diff" class="hidden">
<div id="md-find-bar" class="hidden">
<input id="md-find-input" type="text" spellcheck="false" placeholder="Find in document…" />
<span id="md-find-count" class="dim"></span>
<button class="icon-btn svg-icon" id="btn-md-find-prev" title="Previous Match (⇧⏎)" style="--icon: url('icons/chevron-up.svg')"></button>
<button class="icon-btn svg-icon" id="btn-md-find-next" title="Next Match (⏎)" style="--icon: url('icons/chevron-down.svg')"></button>
<button class="icon-btn" id="btn-md-find-close" title="Close (Esc)">✕</button>
</div>
<div id="md-mode-bar">
<button class="icon-btn active" data-md-mode="diff" title="Show as unified diff">Diff</button>
<button class="icon-btn" data-md-mode="old" title="Show old side only, rendered">Old</button>
Expand Down
8 changes: 8 additions & 0 deletions renderer/mermaid-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
* Mermaid diagram bundle entry — built by esbuild into renderer/mermaid.js
* (see package.json "build:mermaid", run on postinstall).
*
* The bundle is wrapped in `(function(define){ … })()` (the banner/footer in
* "build:mermaid"): several of mermaid's deps ship UMD wrappers whose first
* branch is `typeof define === 'function' && define.amd`, and Monaco's AMD
* loader has already put a global `define` on the page — they'd register as
* anonymous modules and the whole script dies with "Can only have one
* anonymous define call per script file". Shadowing `define` sends them down
* their CommonJS branch instead.
*
* Renders ```mermaid fenced code blocks in the markdown preview
* (renderer/app/markdown.ts). securityLevel: 'strict' sanitizes any
* HTML/click-handler content mermaid would otherwise inject into labels —
Expand Down
28 changes: 28 additions & 0 deletions renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,34 @@ kbd {
padding: 2px;
}

/* Find-in-document bar for the rendered markdown pane (⌘F). */
#md-find-bar {
position: absolute;
top: 6px;
left: 16px;
display: flex;
align-items: center;
gap: 4px;
z-index: 2;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px;
}
#md-find-input { width: 200px; }
#md-find-count { min-width: 64px; text-align: center; font-size: 11px; }

/* CSS Custom Highlight API — see runMdFind() in navigation.ts. Painting the
matches this way leaves the rendered document (and its diff tinting)
completely untouched. */
::highlight(md-find) {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
::highlight(md-find-current) {
background: var(--accent);
color: var(--bg);
}

/* Overview-ruler-style strip mirroring the Monaco diff editor's right-hand
change markers, so scanning for changes doesn't require scrolling. */
.md-diff-ruler {
Expand Down
55 changes: 51 additions & 4 deletions test/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,12 @@ async function main(): Promise<void> {
const port = typeof address === 'object' && address ? address.port : 0;
const url = `http://127.0.0.1:${port}/renderer/index.html`;

// Sandboxes ship a chromium at a fixed path; on a dev machine that path
// doesn't exist, so fall through to Playwright's own downloaded browser
// (executablePath undefined = let Playwright resolve it).
const chromiumPath = process.env.DIFFIER_CHROMIUM || '/opt/pw-browsers/chromium';
const browser = await chromium.launch({
executablePath: process.env.DIFFIER_CHROMIUM || '/opt/pw-browsers/chromium',
executablePath: fs.existsSync(chromiumPath) ? chromiumPath : undefined,
args: ['--no-sandbox'],
});
const page: Page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
Expand Down Expand Up @@ -287,8 +291,12 @@ async function main(): Promise<void> {
});
// The assertions assume non-mac key semantics (Mod = Ctrl); pin
// navigator.platform so the suite also passes on a macOS dev machine.
// Defined on Navigator.prototype (where the real accessor lives) — current
// Chromium makes the own property non-configurable, so redefining it on the
// instance throws "Cannot redefine property" and the pin silently never
// applies.
await page.addInitScript(
"Object.defineProperty(navigator, 'platform', { get: () => 'Linux x86_64' });"
"Object.defineProperty(Navigator.prototype, 'platform', { configurable: true, get: () => 'Linux x86_64' });"
);
await page.goto(url);

Expand Down Expand Up @@ -345,7 +353,13 @@ async function main(): Promise<void> {
await expect('alpha.js open', async () =>
(await page.locator('#diff-file-path').textContent()) === 'src/alpha.js');
await expect('2 differences reported', async () =>
(await page.locator('#diff-count').textContent()) === '2 differences');
/^2 differences\b/.test((await page.locator('#diff-count').textContent()) || ''));

// Mod+F in an editor view hands over to Monaco's own find widget.
await page.keyboard.press('Control+f');
await expect('Mod+F opens Monaco\'s find widget in the text diff', async () =>
(await page.locator('#diff-editor .find-widget.visible').count()) === 1);
await page.keyboard.press('Escape');

// --- F7 navigation: first change → second change → armed toast → next file
const line = async (): Promise<boolean | undefined> =>
Expand Down Expand Up @@ -631,7 +645,7 @@ async function main(): Promise<void> {
await page.locator('#btn-refresh').click();
await page.locator('.tree-row[data-key="file:src/alpha.js"]').click();
await expect('alpha.js reopened with 2 hunks', async () =>
(await page.locator('#diff-count').textContent()) === '2 differences');
/^2 differences\b/.test((await page.locator('#diff-count').textContent()) || ''));
await expect('hunk checkboxes rendered in the gutter', async () =>
(await page.locator('.hunk-check.checked').count()) === 2);
await page.locator('.hunk-check').nth(1).click();
Expand Down Expand Up @@ -926,6 +940,39 @@ async function main(): Promise<void> {
await page.keyboard.press('Shift+F7');
await expect('Shift+F7 past the first change shows a toast instead of looping', async () =>
/No more changes/.test((await page.locator('#toast').textContent()) || ''));
// --- markdown find bar (Mod+F): the rendered pane has no editor, so
// matches are painted with the CSS Custom Highlight API.
await page.keyboard.press('Control+f');
await expect('Mod+F opens the markdown find bar and focuses its input', async () =>
!((await page.locator('#md-find-bar').getAttribute('class')) || '').includes('hidden') &&
(await page.evaluate(() => document.activeElement?.id)) === 'md-find-input');
await page.locator('#md-find-input').fill('EDITED');
const highlighted = () =>
page.evaluate(() => [
CSS.highlights.get('md-find')?.size ?? -1,
CSS.highlights.get('md-find-current')?.size ?? -1,
]);
await expect('typing finds both edited blocks, first one current', async () =>
(await page.locator('#md-find-count').textContent()) === '1/2' &&
String(await highlighted()) === '1,1');
await page.keyboard.press('Enter');
await expect('Enter advances to the next match', async () =>
(await page.locator('#md-find-count').textContent()) === '2/2');
await page.keyboard.press('Enter');
await expect('Enter past the last match wraps around', async () =>
(await page.locator('#md-find-count').textContent()) === '1/2');
await page.keyboard.press('Shift+Enter');
await expect('Shift+Enter wraps backwards', async () =>
(await page.locator('#md-find-count').textContent()) === '2/2');
await page.locator('#md-find-input').fill('nothing here matches');
await expect('a query with no matches says so and highlights nothing', async () =>
(await page.locator('#md-find-count').textContent()) === 'No matches' &&
String(await highlighted()) === '0,0');
await page.keyboard.press('Escape');
await expect('Escape closes the find bar and drops the highlights', async () =>
((await page.locator('#md-find-bar').getAttribute('class')) || '').includes('hidden') &&
String(await highlighted()) === '-1,-1');

await page.locator('.tree-row[data-key="file:README.md"]').click();
await page.locator('#btn-md-view').click();

Expand Down
Loading