From ece0495166ee1ffc0d441eb0b93fd8603c7d3fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mindaugas=20Kasparavic=CC=8Cius?= Date: Thu, 23 Jul 2026 10:00:54 +0300 Subject: [PATCH 1/3] Add diff copy/patch, unsaved-work guards, and content-based highlighting Diff workflow: - Copy diff as a git-style unified patch (toolbar / Edit menu / Ctrl+Shift+C), via a pure, size-guarded LCS util and the main-process clipboard. - Explicit "No differences" state instead of a bare +0/-0. - Content-based syntax detection when a file has no known extension, so paste-mode diffs and files like Dockerfile are highlighted, not plaintext. - Focus re-diff now follows partial-paste files too and coalesces multiple on-disk changes into one notice. Unsaved-work guards: - Replacing an active comparison (drop new files, or open one into a loaded slot) confirms first; a saved comparison replaces without the prompt. - The snippet editor confirms before Cancel/x discards a dirty draft. Tests: +unit coverage (unifiedDiff, diffStore guards, useSnippetDraft) and 4 new e2e specs (copy-diff, diff-overwrite, snippet-discard); all 20 e2e pass. Docs: compact user-facing README; DEVELOPMENT_PLAN updated. --- DEVELOPMENT_PLAN.md | 17 +- README.md | 151 ++++++--------- e2e/copy-diff.spec.mjs | 108 +++++++++++ e2e/diff-overwrite.spec.mjs | 61 ++++++ e2e/snippet-discard.spec.mjs | 39 ++++ src/main/menu.js | 5 + src/renderer/src/adapters/textAdapter.js | 19 +- src/renderer/src/components/AppDialogs.vue | 2 +- src/renderer/src/components/AppToolbar.vue | 16 +- src/renderer/src/components/PasteInput.vue | 8 +- .../src/components/ReplaceDiffDialog.vue | 26 ++- .../src/components/SaveDiffDialog.vue | 11 +- .../src/components/SnippetEditorDialog.vue | 19 +- .../src/components/styles/AppToolbar.css | 3 + .../components/styles/SnippetEditorDialog.css | 19 ++ src/renderer/src/composables/useFileDrop.js | 4 +- .../src/composables/useSnippetDraft.js | 38 +++- src/renderer/src/menus.js | 5 + src/renderer/src/stores/diffStore.js | 140 ++++++++++++-- src/renderer/src/utils/detectLanguage.js | 6 +- src/renderer/src/utils/shortcuts.js | 1 + src/renderer/src/utils/unifiedDiff.js | 113 +++++++++++ tests/renderer/adapters/textAdapter.test.js | 25 +++ .../composables/useSnippetDraft.test.js | 64 ++++++ tests/renderer/stores/diffStore.test.js | 182 +++++++++++++++++- tests/renderer/utils/detectLanguage.test.js | 4 +- tests/renderer/utils/unifiedDiff.test.js | 68 +++++++ 27 files changed, 1012 insertions(+), 142 deletions(-) create mode 100644 e2e/copy-diff.spec.mjs create mode 100644 e2e/diff-overwrite.spec.mjs create mode 100644 e2e/snippet-discard.spec.mjs create mode 100644 src/renderer/src/utils/unifiedDiff.js create mode 100644 tests/renderer/composables/useSnippetDraft.test.js create mode 100644 tests/renderer/utils/unifiedDiff.test.js diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 560dc3b..1b72e49 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -15,7 +15,9 @@ electron-builder. - [x] Vue 3 + Pinia wired up - [x] Monaco diff editor with worker configuration for Vite - [x] Secure IPC: contextIsolation on, nodeIntegration off, file access in main -- [x] Adapter registry with text adapter (language detection by extension) +- [x] Adapter registry with text adapter (language detection by extension, + falling back to content sniffing for extensionless files and pasted text + so paste-mode diffs and files like `Dockerfile` still get syntax coloring) - [x] electron-builder config (NSIS for Windows, DMG for macOS) First run: `npm install && npm run dev` @@ -46,7 +48,14 @@ First run: `npm install && npm run dev` - [x] App menu: Open Left (Ctrl+1) / Open Right (Ctrl+2) / Swap (Ctrl+Shift+S) / Clear (Ctrl+K) / Paste mode (Ctrl+T) / Toggle split (Ctrl+\) - [x] Diff stats in toolbar (n additions, m deletions) via - `editor.getLineChanges()` on `onDidUpdateDiff` + `editor.getLineChanges()` on `onDidUpdateDiff`; two loaded sides with no + changes surface an explicit "No differences" state instead of a bare +0/−0 +- [x] Copy diff as a git-style unified patch (toolbar / Edit menu / + Ctrl+Shift+C): a pure line-level LCS in `utils/unifiedDiff.js` (guarded for + size), copied via the main-process clipboard (`window.api.copyText`) +- [x] Live re-diff on focus follows the two comparison sides *and* a partial-paste + loaded file (any slot with a real path), coalescing multiple changes into a + single "reloaded" notice ## Phase 2 – Polish (~2–3 days) @@ -169,6 +178,10 @@ First run: `npm install && npm run dev` - [x] Diff search gains match-case, whole-word, and safety-limited regex (`utils/searchRegex.js` refuses over-long / catastrophic patterns) - [x] Partial paste mode: diff pasted text against a dropped/chosen file +- [x] Unsaved-work guards: replacing an active comparison (drop new files, or + open one into a loaded slot) confirms first, unless it's already saved + (`diffStore.diffSaved`); the snippet editor confirms before Cancel/× + discards a dirty draft (`useSnippetDraft` — unit + e2e tested) - [x] Tools menu grouped per format (Base64 / JSON / XML / SQL / Text Encryption); Help → Keyboard Shortcuts lists bindings for the host OS - [x] Help → Report an Issue confirms before leaving the offline sandbox, then diff --git a/README.md b/README.md index 86820a2..9a0739f 100644 --- a/README.md +++ b/README.md @@ -4,93 +4,42 @@ # Diff Bro -An **offline-only** desktop diff viewer / sharing app for Windows and macOS — GitHub-style -rendering, serious about privacy. Electron + Vue 3 + Pinia + Monaco. - -It never makes a network request: files stay on your machine, enforced by a -session-level kill-switch, a strict CSP, and a sandboxed renderer. See -[docs/security.md](docs/security.md). +**An offline-only desktop diff viewer for Windows and macOS.** GitHub-style +side-by-side comparison, syntax highlighting, and encrypted local history — with +a hard promise: it never touches the network.

Diff Bro comparing two JSON files side by side, with word-level highlights and add/remove counts

-## Download - -Grab the latest installer for your OS from the -[**latest release**](https://github.com/mindaugaskasp/diff-bro/releases/latest) -— no account, no telemetry, no auto-update: - -| OS | Download | -| --- | --- | -| **Windows** (10/11) | `diff-bro-Setup-v.exe` | -| **macOS** (Apple silicon, 12+) | `diff-bro-v.dmg` | - -Installer filenames carry the version, so a download stays identifiable once -it's off the release page. - -**macOS via Homebrew** (updates with `brew upgrade`): - -```bash -brew tap mindaugaskasp/tap -brew install --cask diff-bro -xattr -dr com.apple.quarantine "/Applications/Diff Bro.app" -``` - -The third line clears the Gatekeeper quarantine described below (the build -isn't notarized yet); Homebrew 6 removed the `--no-quarantine` flag that used -to skip it. The cask is refreshed automatically on every release. - -Builds are **unsigned** for now (no Apple/Microsoft cert yet): - -- **Windows** — SmartScreen warns; click **More info → Run anyway**. -- **macOS** — Gatekeeper says *"Diff Bro is damaged and can't be opened."* It - isn't damaged — that's just how recent macOS reports an unsigned, - un-notarized app that was quarantined on download. Drag it to **Applications**, - then run once in Terminal: - - ```bash - xattr -dr com.apple.quarantine "/Applications/Diff Bro.app" - ``` +## Why Diff Bro - and open it normally. More detail in [docs/packaging.md](docs/packaging.md). +- **Truly offline.** No account, no telemetry, no auto-update, no CDN. The app + makes *zero* network requests — enforced by a session-level kill switch, a + strict CSP, and a sandboxed renderer, not just a promise. Your files never + leave your machine. +- **Private by default.** Anything you keep — saved diffs, snippets, keys — is + encrypted on-device (AES-256-GCM), with the key held by your OS keychain. + Saved diffs auto-expire. +- **Fast and familiar.** The Monaco editor that powers VS Code, with GitHub-style + rendering you already know. ## Features -- **Diff** two files or pasted text — split/inline, word-level highlights, - syntax highlighting, live re-diff when a file changes on disk, and an in-view - search with match-case, whole-word, and safety-limited regex, match count and - jump-to-match. -- **Paste mode** compares two pasted snippets, or mixes the two — paste one - side and drop/choose a real file on the other (partial paste). -- **Drag & drop** files onto the window (two at once builds the diff; a third - starts over). Fixed, single window; five themes (Light, Dark, Solar, Neon, - Contrast) picked in Settings → Appearance; clamped zoom. -- **Saved diffs** — AES-256-GCM encrypted at rest, auto-expiring (≤ 24 h), - organized into drag-reorderable categories, favoritable. Categories are a - local organizing tool and never travel with a shared diff. -- **Rearrangeable sidebar** — reorder the Saved / External / Snippets sections - to taste; the order (and other preferences) persist in a plaintext - `settings.json`. -- **Share** a saved diff as a sealed, signed `.diffbro` file for one recipient; - manage named trusted keys under the **Security** menu. -- **Snippets** — an encrypted, tagged, non-expiring text library with - per-snippet syntax (JSON / SQL / Markdown / YAML / Python / Bash / PHP / …), - filter + copy, and passphrase-protected export/import. **Mermaid** snippets - render to a diagram — a live preview while editing plus a resizable - zoom/pan viewer, themed to match the app, all offline. -- **Tools** — grouped by format (Base64, and JSON / XML / SQL format+validate, - Monaco-highlighted, with "Add to Snippets"), plus a passphrase text - Encrypt/Decrypt. **Help → Keyboard Shortcuts** lists every binding for your OS. -- **Settings** — split into Appearance / Storage / Limits panes; backed by a - plaintext `settings.json` (theme, data folder, shortcut-bar visibility, and - user-raisable comparison-file / snippet size limits with safe defaults). -- **Config backup/restore** — one passphrase-encrypted file for your keys, - trusted hosts, snippets and settings (not diffs). - -JSON/XML content shows an inline "pretty-print it?" banner before you diff. - -## Screenshots +- **Diff** two files or pasted text — split or inline, word-level highlights, + syntax highlighting, in-view search, and a live re-diff when a file changes on + disk. Copy the result as a git-style unified patch. +- **Paste mode** for quick throwaway comparisons, including pasted text against a + real file. +- **Drag & drop** files onto the window; it warns before discarding unsaved work. +- **Saved diffs** — encrypted, auto-expiring, organized into categories. +- **Share** a diff as a sealed, signed file only its intended recipient can open. +- **Snippets** — an encrypted, tagged text library with per-language + highlighting and live **Mermaid** diagram rendering. +- **Tools** — Base64, JSON / XML / SQL format + validate, and passphrase text + encryption. +- **Yours to arrange** — five themes, a rearrangeable sidebar, and adjustable + limits, all remembered between sessions. @@ -105,27 +54,49 @@ JSON/XML content shows an inline "pretty-print it?" banner before you diff.
-## Quick start +## Download + +Grab the latest installer from the +[**latest release**](https://github.com/mindaugaskasp/diff-bro/releases/latest): + +| OS | Download | +| --- | --- | +| **Windows** (10/11) | `diff-bro-Setup-v.exe` | +| **macOS** (Apple silicon, 12+) | `diff-bro-v.dmg` | + +**macOS via Homebrew:** ```bash -npm install -npm run dev -npm run check # ESLint + Vitest — run before every change lands +brew tap mindaugaskasp/tap +brew install --cask diff-bro +xattr -dr com.apple.quarantine "/Applications/Diff Bro.app" ``` -No local Node? The same flow runs in Docker: `make dev` (app via noVNC at -) and `make check`. See -[docker/README.md](docker/README.md) and `make help`. +Builds are currently **unsigned**, so Windows SmartScreen and macOS Gatekeeper +will warn on first launch (the `xattr` line above clears it on macOS). Full +details in [docs/packaging.md](docs/packaging.md). + +## Tech + +Electron · Vue 3 · Pinia · Monaco. Text first, with an adapter registry for +richer formats to come. Security and offline guarantees are non-negotiable — see +the [security model](docs/security.md). + +## Build from source + +```bash +npm install +npm run dev # run locally +npm run check # lint + tests +``` -End-to-end tests (`make e2e`) drive the built app through Playwright's Electron -integration in the Docker display env — launch smoke, Settings panes, theme -persistence, snippet copy. No bundled browser and no network, in keeping with -the offline guarantee. +No local Node? The same flow runs in Docker: `make dev`, `make check`, +`make e2e`. See [docker/README.md](docker/README.md) and `make help`. ## Docs - [Architecture](docs/architecture.md) — processes, trust boundary, directory map. - [Security model](docs/security.md) — offline guarantee, sharing, keys, backup. - [Packaging & releasing](docs/packaging.md) — installers, signing notes, CI. -- Coding standards and hard rules live in [CLAUDE.md](CLAUDE.md); roadmap in +- Coding standards live in [CLAUDE.md](CLAUDE.md); roadmap in [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md). diff --git a/e2e/copy-diff.spec.mjs b/e2e/copy-diff.spec.mjs new file mode 100644 index 0000000..5a23f5d --- /dev/null +++ b/e2e/copy-diff.spec.mjs @@ -0,0 +1,108 @@ +import { test, expect } from './fixtures.mjs' + +// Drive the diff through the one path that needs no native file dialog: +// paste-compare. Leaves a real Monaco diff mounted so the toolbar actions, +// stats and layout can be asserted against a live editor — the half jsdom +// can't see. +async function pasteCompare(page, left, right) { + await page.getByRole('button', { name: 'Paste text' }).click() + await page.getByPlaceholder('Paste original text here').fill(left) + await page.getByPlaceholder('Paste changed text here').fill(right) + await page.getByRole('button', { name: 'Compare', exact: true }).click() + await expect(page.getByText('Choose or drop two files to compare.')).toBeHidden() +} + +// Copy diff must land a valid git-style unified patch on the OS clipboard. Like +// the snippet-copy test, this is the seam the deny-all permission handler +// breaks: the write goes through window.api.copyText -> clipboard:write (main), +// never navigator.clipboard. Reading the clipboard back from the main process +// proves the whole compute -> copyText -> clipboard round-trip, not just that a +// notice fired. +test('Copy diff writes a unified patch to the OS clipboard', async ({ app, page }) => { + await pasteCompare(page, 'alpha\ngamma\n', 'alpha\nBETA\ngamma\n') + + const copy = page.getByRole('button', { name: 'Copy diff' }) + await expect(copy).toBeEnabled() + await copy.click() + await expect(page.getByText('Unified diff copied to clipboard.')).toBeVisible() + + const clip = await app.evaluate(({ clipboard }) => clipboard.readText()) + expect(clip).toMatch(/^--- /m) // ---/+++ file header + expect(clip).toMatch(/^\+\+\+ /m) + expect(clip).toMatch(/^@@ .* @@$/m) // at least one hunk + expect(clip).toContain('+BETA') // the inserted line + expect(clip).toContain(' alpha') // an unchanged context line +}) + +// Two identical sides surface an affirmative "No differences" instead of a bare +// +0/−0 that reads as "did it even run?". +test('identical sides show a No differences state and copy nothing', async ({ page }) => { + await pasteCompare(page, 'same\nlines\n', 'same\nlines\n') + + await expect(page.locator('.stats .identical')).toHaveText('No differences') + await expect(page.locator('.stats .add')).toBeHidden() + await expect(page.locator('.stats .del')).toBeHidden() + + // Copy diff on an identical comparison explains itself rather than copying an + // empty patch. + await page.getByRole('button', { name: 'Copy diff' }).click() + await expect(page.getByText('The two sides are identical — nothing to copy.')).toBeVisible() +}) + +// Pasted content carries no file extension, so highlighting depends on the +// adapter's content sniffing. Monaco tokenizes JSON into several token classes; +// plaintext would collapse to one. Seeing >= 2 distinct .mtk* classes proves a +// grammar is active — i.e. the paste is coloured, not dumped as plaintext. +test('pasted content is syntax-highlighted via content detection', async ({ page }) => { + await pasteCompare(page, '{"name":"a","count":1}', '{"name":"b","count":2}') + + await expect + .poll(() => + page.evaluate(() => { + const set = new Set() + document + .querySelectorAll('.diff-container .view-lines span[class*="mtk"]') + .forEach((s) => s.className.split(/\s+/).forEach((c) => c.startsWith('mtk') && set.add(c))) + return set.size + }) + ) + .toBeGreaterThanOrEqual(2) +}) + +// Layout guard for the band system (CLAUDE.md): the new Copy diff button must +// sit in the toolbar band, line up with its neighbours, and never push the bar +// into a horizontal scroll. jsdom has no layout, so this can only be checked in +// a real launch. +test('the Copy diff button keeps the toolbar band aligned and un-overflowed', async ({ page }) => { + await pasteCompare(page, 'a\n', 'b\n') // ready -> stats + every action render + + const toolbar = page.locator('.toolbar') + const copy = page.getByRole('button', { name: 'Copy diff' }) + const clear = page.getByRole('button', { name: 'Clear', exact: true }) + await expect(copy).toBeVisible() + + const [tb, cb, clr] = await Promise.all([ + toolbar.boundingBox(), + copy.boundingBox(), + clear.boundingBox() + ]) + + // Inside the band, not clipped above/below or wrapped onto a second row. + expect(cb.y).toBeGreaterThanOrEqual(tb.y - 1) + expect(cb.y + cb.height).toBeLessThanOrEqual(tb.y + tb.height + 1) + + // Band rule: controls sharing a row are equal-height, flex-centred boxes, so + // Copy diff and Clear must share a top edge and a height (within 1px). + expect(Math.abs(cb.y - clr.y)).toBeLessThanOrEqual(1) + expect(Math.abs(cb.height - clr.height)).toBeLessThanOrEqual(1) + + // No sideways scroll: the toolbar stays within the viewport and the document + // never gains horizontal overflow. + const fits = await page.evaluate(() => { + const bar = document.querySelector('.toolbar').getBoundingClientRect() + const noPageScroll = + document.documentElement.scrollWidth <= document.documentElement.clientWidth + return bar.right <= window.innerWidth + 1 && noPageScroll + }) + expect(fits).toBe(true) +}) diff --git a/e2e/diff-overwrite.spec.mjs b/e2e/diff-overwrite.spec.mjs new file mode 100644 index 0000000..e5119e4 --- /dev/null +++ b/e2e/diff-overwrite.spec.mjs @@ -0,0 +1,61 @@ +import { test, expect } from './fixtures.mjs' + +// Build a two-sided comparison without a native dialog (paste-compare), then +// return to files mode where the file slots are live. +async function pasteCompare(page, left, right) { + await page.getByRole('button', { name: 'Paste text' }).click() + await page.getByPlaceholder('Paste original text here').fill(left) + await page.getByPlaceholder('Paste changed text here').fill(right) + await page.getByRole('button', { name: 'Compare', exact: true }).click() + await expect(page.getByText('Choose or drop two files to compare.')).toBeHidden() +} + +// Make "Open file" deterministic: replace the native file dialog handler with +// one that returns a fixed file, so clicking a slot exercises the real +// renderer -> IPC -> store.pick path without an un-driveable OS dialog. +async function stubOpenFile(app, name) { + await app.evaluate(({ ipcMain }, fileName) => { + ipcMain.removeHandler('file:open') + ipcMain.handle('file:open', (_e, side) => ({ + path: `/virtual/${side}-${fileName}`, + name: fileName, + content: `content of ${fileName}` + })) + }, name) +} + +// Opening a file into a loaded, UNSAVED comparison must warn before discarding +// it — the same protection drag-drop already has, now on the Open/slot path. +test('opening a file over an unsaved comparison warns first, and can replace', async ({ + app, + page +}) => { + await pasteCompare(page, 'left body', 'right body') + await stubOpenFile(app, 'picked.txt') + + // Click the left file slot -> store.pick('left') -> the guard fires. + await page.locator('.slot[data-side="left"]').click() + + const dialog = page.getByRole('dialog', { name: 'Replace current comparison?' }) + await expect(dialog).toBeVisible() + // Nothing replaced while the prompt is up. + await expect(page.locator('.slot[data-side="left"] .name')).toHaveText('Left (pasted)') + + await dialog.getByRole('button', { name: 'Replace anyway' }).click() + await expect(dialog).toBeHidden() + await expect(page.locator('.slot[data-side="left"] .name')).toHaveText('picked.txt') +}) + +// Cancelling the prompt leaves the current comparison exactly as it was. +test('cancelling the overwrite prompt keeps the current comparison', async ({ app, page }) => { + await pasteCompare(page, 'left body', 'right body') + await stubOpenFile(app, 'other.txt') + + await page.locator('.slot[data-side="right"]').click() + const dialog = page.getByRole('dialog', { name: 'Replace current comparison?' }) + await expect(dialog).toBeVisible() + + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toBeHidden() + await expect(page.locator('.slot[data-side="right"] .name')).toHaveText('Right (pasted)') +}) diff --git a/e2e/snippet-discard.spec.mjs b/e2e/snippet-discard.spec.mjs new file mode 100644 index 0000000..77673b8 --- /dev/null +++ b/e2e/snippet-discard.spec.mjs @@ -0,0 +1,39 @@ +import { test, expect } from './fixtures.mjs' + +// Cancelling (or ×-ing) a new snippet that has typed/pasted content must not +// silently throw it away — a real bug only a launch reproduces, since the guard +// lives on the dialog's close paths and Monaco feeds the "dirty" state. +test('cancelling a new snippet with typed content asks before discarding', async ({ page }) => { + await page.getByRole('button', { name: '+ New snippet' }).click() + const dialog = page.getByRole('dialog', { name: 'New Snippet' }) + await expect(dialog).toBeVisible() + + await dialog.locator('.editor').click() + await page.keyboard.type('do not lose me') + + // Cancel does not close — it raises the discard confirmation. + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toBeVisible() + await expect(dialog.getByText('Discard this snippet?')).toBeVisible() + + // Keep editing returns to the editor with the content intact. + await dialog.getByRole('button', { name: 'Keep editing' }).click() + await expect(dialog.getByText('Discard this snippet?')).toBeHidden() + await expect(dialog).toBeVisible() + await expect(page.getByText('do not lose me').first()).toBeVisible() + + // Cancel again, then Discard, actually closes and drops the draft. + await dialog.getByRole('button', { name: 'Cancel' }).click() + await dialog.getByRole('button', { name: 'Discard', exact: true }).click() + await expect(dialog).toBeHidden() +}) + +// An untouched new snippet has nothing to protect, so Cancel closes at once. +test('cancelling an empty new snippet closes without a prompt', async ({ page }) => { + await page.getByRole('button', { name: '+ New snippet' }).click() + const dialog = page.getByRole('dialog', { name: 'New Snippet' }) + await expect(dialog).toBeVisible() + + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toBeHidden() +}) diff --git a/src/main/menu.js b/src/main/menu.js index 732a925..583cf64 100644 --- a/src/main/menu.js +++ b/src/main/menu.js @@ -128,6 +128,11 @@ export function installMenu() { click: () => sendToFocused('swap') }, { label: 'Clear', accelerator: 'CmdOrCtrl+K', click: () => sendToFocused('clear') }, + { + label: 'Copy Diff as Patch', + accelerator: 'CmdOrCtrl+Shift+C', + click: () => sendToFocused('copy-diff') + }, { type: 'separator' }, { label: 'Paste Text Mode', diff --git a/src/renderer/src/adapters/textAdapter.js b/src/renderer/src/adapters/textAdapter.js index 96dee10..dd46123 100644 --- a/src/renderer/src/adapters/textAdapter.js +++ b/src/renderer/src/adapters/textAdapter.js @@ -1,3 +1,10 @@ +import { detectSnippetLanguage } from '../utils/detectLanguage' + +// Content detection is capped to a prefix: the signals it looks for sit near the +// top of a file, and running the JSON validator across a multi-MB paste would +// cost far more than the syntax coloring is worth. +const DETECT_LIMIT = 50_000 + const EXT_TO_LANGUAGE = { js: 'javascript', mjs: 'javascript', @@ -35,10 +42,12 @@ export const textAdapter = { matches: () => true, toComparable(file) { const ext = file.name.split('.').pop()?.toLowerCase() ?? '' - return { - kind: 'text', - text: file.content, - language: EXT_TO_LANGUAGE[ext] ?? 'plaintext' - } + // A known extension is authoritative. Otherwise — an extensionless file + // (Dockerfile, Makefile, a shell script named `deploy`) or pasted text, + // whose synthetic name carries no real extension — sniff the content so the + // diff is still highlighted rather than dumped as plaintext. + const language = + EXT_TO_LANGUAGE[ext] ?? detectSnippetLanguage(file.content?.slice(0, DETECT_LIMIT) ?? '') + return { kind: 'text', text: file.content, language } } } diff --git a/src/renderer/src/components/AppDialogs.vue b/src/renderer/src/components/AppDialogs.vue index 3fa634d..ada51e4 100644 --- a/src/renderer/src/components/AppDialogs.vue +++ b/src/renderer/src/components/AppDialogs.vue @@ -30,7 +30,7 @@ const vault = useVaultStore() diff --git a/src/renderer/src/components/styles/AppToolbar.css b/src/renderer/src/components/styles/AppToolbar.css index 47b6bd8..156121c 100644 --- a/src/renderer/src/components/styles/AppToolbar.css +++ b/src/renderer/src/components/styles/AppToolbar.css @@ -19,6 +19,9 @@ .stats .del { color: var(--danger-bg); } +.stats .identical { + color: var(--text-dim); +} .options { display: flex; align-items: center; diff --git a/src/renderer/src/components/styles/SnippetEditorDialog.css b/src/renderer/src/components/styles/SnippetEditorDialog.css index 88385e9..30b3345 100644 --- a/src/renderer/src/components/styles/SnippetEditorDialog.css +++ b/src/renderer/src/components/styles/SnippetEditorDialog.css @@ -78,3 +78,22 @@ color: var(--danger-bg); font-size: var(--font-sm); } +/* Unsaved-changes guard bar — a danger-accented confirm shown before a dirty + draft is discarded via Cancel or ×. */ +.discard-confirm { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 4px; + padding: 8px 10px; + border: 1px solid var(--danger-border); + border-radius: var(--radius); + background: var(--bg-panel); + font-size: var(--font-md); +} +.discard-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} diff --git a/src/renderer/src/composables/useFileDrop.js b/src/renderer/src/composables/useFileDrop.js index b1ab2de..660c092 100644 --- a/src/renderer/src/composables/useFileDrop.js +++ b/src/renderer/src/composables/useFileDrop.js @@ -11,7 +11,9 @@ export function useFileTextDrop(apply) { const path = window.api.getPathForFile(file) if (!path) return const res = await window.api.readFile(path) - if (res && !res.error && res.content != null) apply(res.content, res.name) + // `path` is passed through as a third arg for callers that keep it (e.g. + // partial-paste live reload); tool inputs simply ignore it. + if (res && !res.error && res.content != null) apply(res.content, res.name, res.path) } return { onDropFile } } diff --git a/src/renderer/src/composables/useSnippetDraft.js b/src/renderer/src/composables/useSnippetDraft.js index ac59969..6eab9b8 100644 --- a/src/renderer/src/composables/useSnippetDraft.js +++ b/src/renderer/src/composables/useSnippetDraft.js @@ -48,8 +48,18 @@ export function useSnippetDraft() { const saving = ref(false) const initialTags = initial.tags + // Baseline for the unsaved-changes guard: the fields as first shown. For an + // existing snippet the real content arrives only after decryption, so the + // baseline updates when it lands — an untouched snippet is never "dirty". + const baselineName = ref(initial.name) + const baselineContent = ref(initial.content) + // An existing snippet's content has to be decrypted before it can be shown. - if (!isNew) store.load(editing.id).then((text) => (content.value = text ?? '')) + if (!isNew) + store.load(editing.id).then((text) => { + content.value = text ?? '' + baselineContent.value = text ?? '' + }) // 'auto' defers to the content-based detector; any other value is the user's // explicit syntax choice, remembered with the snippet. @@ -63,6 +73,27 @@ export function useSnippetDraft() { store.editingSnippet = null } + // Unsaved-changes guard. The editor's only exits are Cancel and the × (Escape + // is disabled, the backdrop is inert), and both would silently drop typed or + // pasted code. When the draft differs from what was first shown, a close + // request asks to confirm instead of closing; an untouched draft closes at + // once. + const isDirty = computed( + () => name.value !== baselineName.value || content.value !== baselineContent.value + ) + const confirmingDiscard = ref(false) + function requestClose() { + if (isDirty.value) confirmingDiscard.value = true + else close() + } + function keepEditing() { + confirmingDiscard.value = false + } + function discardDraft() { + confirmingDiscard.value = false + close() + } + // `tags` and `tagColors` come from the tag field, which owns them. async function save({ tags, tagColors }) { // A snippet needs both a name and content; the button is disabled without @@ -132,6 +163,11 @@ export function useSnippetDraft() { canFormat, save, close, + isDirty, + confirmingDiscard, + requestClose, + keepEditing, + discardDraft, formatContent, copyContent, expandDiagram diff --git a/src/renderer/src/menus.js b/src/renderer/src/menus.js index 4aa0df7..81ad049 100644 --- a/src/renderer/src/menus.js +++ b/src/renderer/src/menus.js @@ -30,6 +30,11 @@ export function buildMenus(store) { items: [ { label: 'Swap Sides', keys: `${MOD}+Shift+S`, run: () => store.swap() }, { label: 'Clear', keys: `${MOD}+K`, run: () => store.clear() }, + { + label: 'Copy Diff as Patch', + keys: `${MOD}+Shift+C`, + run: () => store.handleMenuAction('copy-diff') + }, { sep: true }, { label: 'Paste Text Mode', keys: `${MOD}+T`, run: () => store.togglePasteMode() } ] diff --git a/src/renderer/src/stores/diffStore.js b/src/renderer/src/stores/diffStore.js index f2fda81..b717840 100644 --- a/src/renderer/src/stores/diffStore.js +++ b/src/renderer/src/stores/diffStore.js @@ -4,6 +4,7 @@ import { resolveAdapter } from '../adapters' import { useVaultStore } from './vaultStore' import { useSnippetStore } from './snippetStore' import { detectTextFormat, formatJson, formatXml } from '../utils/textFormats' +import { toUnifiedDiff } from '../utils/unifiedDiff' import { loadPersisted, savePersisted } from '../persist' import { isDarkTheme, normalizeTheme } from '../utils/themes' @@ -57,6 +58,7 @@ const MENU_ACTIONS = { 'share-current': (s) => s.shareCurrent(), swap: (s) => s.swap(), clear: (s) => s.clear(), + 'copy-diff': (s) => s.copyDiff(), 'toggle-paste': (s) => s.togglePasteMode(), 'toggle-split': (s) => (s.renderSideBySide = !s.renderSideBySide), 'toggle-theme': (s) => s.toggleTheme(), @@ -105,6 +107,17 @@ export const useDiffStore = defineStore('diff', { // dialog when the user chooses "Save first". pendingReplace: null, replaceAfterSave: null, + // Picked file (via Open / clicking a slot) waiting on the "replace current + // diff?" confirmation, when it would overwrite a side of a complete, unsaved + // comparison — { side, file }. `pickAfterSave` carries it through the save + // dialog when the user chooses "Save first". null when no prompt is open. + pendingPick: null, + pickAfterSave: null, + // True once the current comparison has been saved to the vault (or opened + // from a saved diff) and not changed since — so overwriting it loses + // nothing and the replace prompt is skipped. Any edit to either side clears + // it (see receive/comparePasted/swap/formatSide). + diffSaved: false, // Persisted through the durable data-dir store (persist.js), same as vault // and snippets, so the choice survives a reinstall that wipes userData; the // old localStorage 'diffbro.theme' key is migrated forward automatically. @@ -139,6 +152,10 @@ export const useDiffStore = defineStore('diff', { }), getters: { ready: (s) => !!s.left && !!s.right, + // Two loaded sides with a computed diff of no changes — surfaced as an + // affirmative "identical" state so an empty +0/−0 doesn't read as "did it + // run?". With Ignore-whitespace on, this also covers whitespace-only diffs. + identical: (s) => !!s.left && !!s.right && s.stats?.additions === 0 && s.stats?.deletions === 0, // A diff is saveable once there is anything to keep: two loaded files, // or text/files put into the paste panes (even before Compare). canSave: (s) => @@ -153,6 +170,14 @@ export const useDiffStore = defineStore('diff', { actions: { async pick(side) { const file = await window.api.openFile(side) + if (!file) return // dialog cancelled + // Replacing a side of a complete, unsaved comparison would discard it — + // ask first. A saved comparison is safe to overwrite, so it skips the + // prompt; a binary file falls through to receive(), which rejects it. + if (this.ready && !this.diffSaved && !file.error) { + this.pendingPick = { side, file } + return + } this.receive(side, file) }, async drop(side, path) { @@ -173,10 +198,14 @@ export const useDiffStore = defineStore('diff', { await this.drop(targetSide, paths[0]) return } - // Would this discard a complete comparison? Ask first, rather than - // silently replacing it. + // Would this discard a complete comparison? Ask first — unless it's + // already saved, in which case replacing it loses nothing. if (this.left && this.right) { - this.pendingReplace = paths.slice(0, 2) + if (!this.diffSaved) { + this.pendingReplace = paths.slice(0, 2) + return + } + await this._loadReplacement(paths.slice(0, 2)) return } if (paths.length >= 2) { @@ -214,6 +243,31 @@ export const useDiffStore = defineStore('diff', { cancelReplace() { this.pendingReplace = null }, + // --- file-load (Open / slot click) overwrite of an active comparison --- + confirmPick() { + const p = this.pendingPick + this.pendingPick = null + if (p) this.receive(p.side, p.file) + }, + saveThenPick() { + this.pickAfterSave = this.pendingPick + this.pendingPick = null + this.showSaveDialog = true + }, + finishPickAfterSave() { + const p = this.pickAfterSave + this.pickAfterSave = null + if (p) this.receive(p.side, p.file) + }, + cancelPick() { + this.pendingPick = null + }, + // The current comparison now matches a vault entry (just saved, or opened + // from a saved diff): overwriting it is safe, so the replace prompt is + // skipped until the next edit. + markSaved() { + this.diffSaved = true + }, receive(side, file) { if (!file) return // dialog cancelled or large-file load declined if (file.error === 'binary') { @@ -227,6 +281,8 @@ export const useDiffStore = defineStore('diff', { if (file.error) return this[side] = file this.mode = 'files' + // A newly loaded side is unsaved work again. + this.diffSaved = false }, comparePasted() { // Each side is whichever the user provided: a loaded file, else the @@ -236,6 +292,7 @@ export const useDiffStore = defineStore('diff', { this.left = { path: null, name: l.name, content: l.content } this.right = { path: null, name: r.name, content: r.content } this.mode = 'files' + this.diffSaved = false }, togglePasteMode() { this.mode = this.mode === 'paste' ? 'files' : 'paste' @@ -251,9 +308,12 @@ export const useDiffStore = defineStore('diff', { return } if (file.error) return + // Keep the path so a partial-paste file follows external edits on focus + // (refreshFromDisk), exactly like a loaded comparison side does. this[side === 'left' ? 'pasteLeftFile' : 'pasteRightFile'] = { name: file.name, - content: file.content + content: file.content, + path: file.path ?? null } }, async pastePickFile(side) { @@ -284,25 +344,64 @@ export const useDiffStore = defineStore('diff', { toggleTheme() { this.setTheme(isDarkTheme(this.theme) ? 'light' : 'dark') }, - // Re-read both sides from disk (quietly — no large-file prompt) so the - // diff follows external edits. Called when the window regains focus. + // Re-read one slot quietly (no large-file prompt); returns the file name if + // its on-disk content actually changed, else null. Paste-file slots keep + // their trimmed { name, content, path } shape; comparison sides take the + // full loaded-file object. + async _reloadSlot(slot) { + const current = this[slot] + if (!current?.path) return null + try { + const file = await window.api.readFile(current.path, { quiet: true }) + if (!file || file.error || file.content === current.content) return null + this[slot] = slot.startsWith('paste') + ? { name: file.name, content: file.content, path: file.path } + : file + return file.name + } catch { + // File gone or unreadable now; keep showing the last loaded state. + return null + } + }, + // Follow external edits when the window regains focus: both comparison + // sides and either partial-paste source. One coalesced notice covers all of + // them, so two files changing at once can't race the single toast timer. async refreshFromDisk() { - for (const side of ['left', 'right']) { - const path = this[side]?.path - if (!path) continue - try { - const file = await window.api.readFile(path, { quiet: true }) - if (file && !file.error && file.content !== this[side].content) { - this[side] = file - this.showNotice(`"${file.name}" changed on disk — diff reloaded.`) - } - } catch { - // File gone or unreadable now; keep showing the last loaded state. - } + const changed = [] + for (const slot of ['left', 'right', 'pasteLeftFile', 'pasteRightFile']) { + const name = await this._reloadSlot(slot) + if (name) changed.push(name) + } + if (changed.length === 1) { + this.showNotice(`"${changed[0]}" changed on disk — diff reloaded.`) + } else if (changed.length > 1) { + this.showNotice(`${changed.length} files changed on disk — diff reloaded.`) + } + }, + // Copy the current comparison as a git-style unified diff (File → Copy diff, + // toolbar, Ctrl+Shift+C). The on-screen diff is Monaco's; this recomputes a + // patch that applies cleanly (see utils/unifiedDiff.js). Clipboard write goes + // through the main process — navigator.clipboard is denied here (CLAUDE.md). + async copyDiff() { + if (!this.ready) { + this.showNotice('Load two files (or compare pasted text) before copying a diff.') + return } + // ready guarantees both sides are loaded file objects with names. + const res = toUnifiedDiff(this.leftComparable.text, this.rightComparable.text, { + leftLabel: this.left.name, + rightLabel: this.right.name + }) + if (res.error === 'too-large') + return this.showNotice('This diff is too large to copy as a patch.') + if (!res.patch) return this.showNotice('The two sides are identical — nothing to copy.') + const out = await window.api.copyText(res.patch) + this.showNotice(out?.ok ? 'Unified diff copied to clipboard.' : 'Could not copy the diff.') }, swap() { ;[this.left, this.right] = [this.right, this.left] + // A swapped comparison no longer matches the saved snapshot's side order. + this.diffSaved = false }, // Pretty-print `side` in place using whichever format its hint detected. formatSide(side) { @@ -311,6 +410,7 @@ export const useDiffStore = defineStore('diff', { if (!file || !hint?.valid) return const pretty = hint.kind === 'json' ? formatJson(file.content) : formatXml(file.content) this[side] = { ...file, content: pretty } + this.diffSaved = false }, dismissFormatHint(side) { this.dismissedFormatHint[side] = this[side]?.content ?? null @@ -340,11 +440,15 @@ export const useDiffStore = defineStore('diff', { this.renderSideBySide = payload.renderSideBySide ?? true this.ignoreTrimWhitespace = payload.ignoreTrimWhitespace ?? false this.mode = payload.mode ?? 'files' + // Opened from a saved diff: it already exists in the vault, so replacing + // it later needs no "you'll lose it" prompt. + this.diffSaved = true }, clear() { this.left = null this.right = null this.stats = null + this.diffSaved = false // Also wipe paste-mode text and files so a cleared session never leaves // the previous content lingering behind. this.pasteLeft = '' diff --git a/src/renderer/src/utils/detectLanguage.js b/src/renderer/src/utils/detectLanguage.js index 74d2c99..ecb0cf7 100644 --- a/src/renderer/src/utils/detectLanguage.js +++ b/src/renderer/src/utils/detectLanguage.js @@ -74,7 +74,8 @@ function looksLikeDockerfile(t) { } // Code keywords that mean "this brace block is a program, not a CSS rule". -const NOT_CSS = /\b(function|const|let|var|interface|class|enum|import|export|return|def|func|fn|public|private|protected)\b|=>/ +const NOT_CSS = + /\b(function|const|let|var|interface|class|enum|import|export|return|def|func|fn|public|private|protected)\b|=>/ const CSS_AT_RULE = /@(media|import|font-face|keyframes|supports|charset|namespace)\b/i const CSS_RULE = /(^|[};])\s*[*.#:@]?[\w-][^\n{}]*\{[^{}]*[\w-]+\s*:\s*[^{}]+;?\s*\}/m function looksLikeCss(t) { @@ -104,7 +105,8 @@ function looksLikeRust(t) { ) } -const JAVA_DECL = /\b(public|private|protected)\s+(static\s+|final\s+|abstract\s+)*(class|interface|enum)\s+\w+/ +const JAVA_DECL = + /\b(public|private|protected)\s+(static\s+|final\s+|abstract\s+)*(class|interface|enum)\s+\w+/ function looksLikeJava(t) { return ( JAVA_DECL.test(t) || diff --git a/src/renderer/src/utils/shortcuts.js b/src/renderer/src/utils/shortcuts.js index c436a9e..3f4dce9 100644 --- a/src/renderer/src/utils/shortcuts.js +++ b/src/renderer/src/utils/shortcuts.js @@ -22,6 +22,7 @@ export const SHORTCUT_GROUPS = [ items: [ { keys: `${MOD}+Shift+S`, label: 'Swap sides' }, { keys: `${MOD}+K`, label: 'Clear' }, + { keys: `${MOD}+Shift+C`, label: 'Copy diff as patch' }, { keys: `${MOD}+T`, label: 'Paste text mode' } ] }, diff --git a/src/renderer/src/utils/unifiedDiff.js b/src/renderer/src/utils/unifiedDiff.js new file mode 100644 index 0000000..f34b048 --- /dev/null +++ b/src/renderer/src/utils/unifiedDiff.js @@ -0,0 +1,113 @@ +// Line-level LCS → git-style unified diff. Pure and unit-tested. +// +// The on-screen diff is Monaco's; a *copied* patch has a higher bar — it must +// apply cleanly with `patch`/`git apply` — so we compute our own line diff with +// well-defined semantics here rather than scraping the editor's change list. + +const CONTEXT = 3 + +// Guard the O(n·m) LCS table: past this the transient memory/time isn't worth it +// for a clipboard convenience, and the caller shows a notice instead. LCS depths +// stay below Uint16's ceiling at this cap, so the table can be a Uint16Array. +export const MAX_DIFF_LINES = 4000 + +// Drop a single trailing newline so a file ending in "\n" doesn't yield a +// phantom empty final line; every real line is preserved otherwise. +function splitLines(text) { + const t = text.endsWith('\n') ? text.slice(0, -1) : text + return t.length === 0 ? [] : t.split('\n') +} + +// Classic LCS edit script: 'eq' | 'del' | 'add', in output order. +function lcsOps(a, b) { + const n = a.length + const m = b.length + const w = m + 1 + const dp = new Uint16Array((n + 1) * w) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i * w + j] = + a[i] === b[j] + ? dp[(i + 1) * w + (j + 1)] + 1 + : Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]) + } + } + const ops = [] + let i = 0 + let j = 0 + while (i < n && j < m) { + if (a[i] === b[j]) { + ops.push({ sign: ' ', text: a[i] }) + i++ + j++ + } else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)]) { + ops.push({ sign: '-', text: a[i++] }) + } else { + ops.push({ sign: '+', text: b[j++] }) + } + } + while (i < n) ops.push({ sign: '-', text: a[i++] }) + while (j < m) ops.push({ sign: '+', text: b[j++] }) + return ops +} + +// Tag each emitted line with the 1-based old/new line numbers it occupies +// (null on the side where it doesn't exist). +function annotate(ops) { + let oldNo = 1 + let newNo = 1 + return ops.map((op) => { + const line = { sign: op.sign, text: op.text, oldNo: null, newNo: null } + if (op.sign !== '+') line.oldNo = oldNo++ + if (op.sign !== '-') line.newNo = newNo++ + return line + }) +} + +// A hunk header counts context+deletions on the old side, context+additions on +// the new side; start numbers come from the first real line on each side (0 when +// a side contributes nothing, e.g. a file created from empty). +function renderHunk(slice) { + const olds = slice.filter((l) => l.oldNo != null) + const news = slice.filter((l) => l.newNo != null) + const oldStart = olds.length ? olds[0].oldNo : 0 + const newStart = news.length ? news[0].newNo : 0 + const head = `@@ -${oldStart},${olds.length} +${newStart},${news.length} @@\n` + return head + slice.map((l) => l.sign + l.text).join('\n') + '\n' +} + +// Group changed lines into hunks, padding each with up to CONTEXT unchanged +// lines; changes closer than 2·CONTEXT lines share a hunk (their context would +// otherwise overlap). +function buildHunks(lines) { + const changed = [] + lines.forEach((l, i) => { + if (l.sign !== ' ') changed.push(i) + }) + if (!changed.length) return [] + const clusters = [[changed[0]]] + for (let k = 1; k < changed.length; k++) { + const last = clusters[clusters.length - 1] + if (changed[k] - last[last.length - 1] <= 2 * CONTEXT + 1) last.push(changed[k]) + else clusters.push([changed[k]]) + } + return clusters.map((cluster) => { + const start = Math.max(0, cluster[0] - CONTEXT) + const end = Math.min(lines.length - 1, cluster[cluster.length - 1] + CONTEXT) + return renderHunk(lines.slice(start, end + 1)) + }) +} + +// Returns { patch } — '' when the two sides are identical — or { error }. +export function toUnifiedDiff( + leftText, + rightText, + { leftLabel = 'original', rightLabel = 'changed' } = {} +) { + const a = splitLines(leftText) + const b = splitLines(rightText) + if (a.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES) return { error: 'too-large' } + const hunks = buildHunks(annotate(lcsOps(a, b))) + if (!hunks.length) return { patch: '' } + return { patch: `--- ${leftLabel}\n+++ ${rightLabel}\n${hunks.join('')}` } +} diff --git a/tests/renderer/adapters/textAdapter.test.js b/tests/renderer/adapters/textAdapter.test.js index b0bfd7d..4bfd8b6 100644 --- a/tests/renderer/adapters/textAdapter.test.js +++ b/tests/renderer/adapters/textAdapter.test.js @@ -22,4 +22,29 @@ describe('textAdapter', () => { const out = textAdapter.toComparable({ name: 'a.js', content: 'const x = 1' }) expect(out).toEqual({ kind: 'text', text: 'const x = 1', language: 'javascript' }) }) + + it('sniffs content when the extension is unknown or absent', () => { + // Extensionless files that developers diff all the time. + expect( + textAdapter.toComparable({ name: 'Dockerfile', content: 'FROM node:20\nRUN npm ci' }).language + ).toBe('dockerfile') + // Pasted text arrives with a synthetic name that has no real extension. + expect(textAdapter.toComparable({ name: 'Left (pasted)', content: '{"a": 1}' }).language).toBe( + 'json' + ) + expect( + textAdapter.toComparable({ name: 'script', content: '#!/bin/bash\necho hi' }).language + ).toBe('shell') + }) + + it('lets a known extension win over content sniffing', () => { + // A .txt holding JSON stays plaintext — the extension is the author's intent. + expect(textAdapter.toComparable({ name: 'notes.md', content: '{"a": 1}' }).language).toBe( + 'markdown' + ) + }) + + it('tolerates missing content', () => { + expect(textAdapter.toComparable({ name: 'noext' }).language).toBe('plaintext') + }) }) diff --git a/tests/renderer/composables/useSnippetDraft.test.js b/tests/renderer/composables/useSnippetDraft.test.js new file mode 100644 index 0000000..6718c44 --- /dev/null +++ b/tests/renderer/composables/useSnippetDraft.test.js @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useSnippetDraft } from '../../../src/renderer/src/composables/useSnippetDraft' +import { useSnippetStore } from '../../../src/renderer/src/stores/snippetStore' + +// The snippet editor's only exits (Cancel, ×) run through requestClose. This +// covers the unsaved-changes guard that keeps a stray click from discarding +// typed/pasted code — the exact bug the layout can't catch. +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +// Put the store into "creating a new snippet" mode. +function newDraft() { + const snippets = useSnippetStore() + snippets.editingSnippet = { id: null } + return { snippets, draft: useSnippetDraft() } +} + +describe('useSnippetDraft — discard guard', () => { + it('a fresh empty draft closes immediately, no confirmation', () => { + const { snippets, draft } = newDraft() + expect(draft.isDirty.value).toBe(false) + draft.requestClose() + expect(draft.confirmingDiscard.value).toBe(false) + expect(snippets.editingSnippet).toBeNull() // closed + }) + + it('a draft with typed content asks to confirm instead of closing', () => { + const { snippets, draft } = newDraft() + draft.content.value = 'critical pasted code' + expect(draft.isDirty.value).toBe(true) + + draft.requestClose() + expect(draft.confirmingDiscard.value).toBe(true) + expect(snippets.editingSnippet).not.toBeNull() // still open — nothing lost + }) + + it('a name alone (no content yet) still counts as unsaved', () => { + const { draft } = newDraft() + draft.name.value = 'My snippet' + expect(draft.isDirty.value).toBe(true) + }) + + it('Keep editing dismisses the prompt and leaves the draft open', () => { + const { snippets, draft } = newDraft() + draft.content.value = 'x' + draft.requestClose() + draft.keepEditing() + expect(draft.confirmingDiscard.value).toBe(false) + expect(snippets.editingSnippet).not.toBeNull() + }) + + it('Discard closes the editor and drops the draft', () => { + const { snippets, draft } = newDraft() + draft.content.value = 'x' + draft.requestClose() + draft.discardDraft() + expect(draft.confirmingDiscard.value).toBe(false) + expect(snippets.editingSnippet).toBeNull() // discarded + }) +}) diff --git a/tests/renderer/stores/diffStore.test.js b/tests/renderer/stores/diffStore.test.js index 5cc5964..62b1831 100644 --- a/tests/renderer/stores/diffStore.test.js +++ b/tests/renderer/stores/diffStore.test.js @@ -107,7 +107,7 @@ describe('diffStore', () => { const store = useDiffStore() window.api.openFile = async (side) => ({ name: `${side}.txt`, content: 'picked' }) await store.pastePickFile('right') - expect(store.pasteRightFile).toEqual({ name: 'right.txt', content: 'picked' }) + expect(store.pasteRightFile).toEqual({ name: 'right.txt', content: 'picked', path: null }) }) it('dropFiles loads two dropped files into left and right', async () => { @@ -612,4 +612,184 @@ describe('diffStore', () => { expect(store.showSaveDialog).toBe(false) expect(store.notice).toContain('Nothing to share yet') }) + + it('identical is true only for two loaded sides whose diff has no changes', () => { + const store = useDiffStore() + expect(store.identical).toBe(false) // nothing loaded + store.left = FILE('a.txt') + store.right = FILE('b.txt') + expect(store.identical).toBe(false) // stats not computed yet + store.stats = { additions: 2, deletions: 1 } + expect(store.identical).toBe(false) + store.stats = { additions: 0, deletions: 0 } + expect(store.identical).toBe(true) + }) + + it('copyDiff writes a unified patch to the clipboard', async () => { + const store = useDiffStore() + store.left = { path: '/tmp/a.txt', name: 'a.txt', content: 'one\ntwo\nthree\n' } + store.right = { path: '/tmp/b.txt', name: 'b.txt', content: 'one\nTWO\nthree\n' } + let copied = null + window.api.copyText = async (t) => { + copied = t + return { ok: true } + } + await store.copyDiff() + expect(copied).toContain('--- a.txt') + expect(copied).toContain('+++ b.txt') + expect(copied).toContain('-two') + expect(copied).toContain('+TWO') + expect(store.notice).toContain('copied') + }) + + it('copyDiff says the sides are identical instead of copying nothing', async () => { + const store = useDiffStore() + store.left = { path: '/tmp/a.txt', name: 'a.txt', content: 'same\n' } + store.right = { path: '/tmp/b.txt', name: 'b.txt', content: 'same\n' } + let called = false + window.api.copyText = async () => ((called = true), { ok: true }) + await store.copyDiff() + expect(called).toBe(false) + expect(store.notice).toContain('identical') + }) + + it('copyDiff refuses when both sides are not loaded', async () => { + const store = useDiffStore() + store.left = FILE('a.txt') + await store.copyDiff() + expect(store.notice).toContain('before copying') + }) + + it('refreshFromDisk coalesces multiple changed files into one notice', async () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') + window.api.readFile = async (path) => ({ + path, + name: path.split('/').pop(), + content: `NEW ${path}` + }) + await store.refreshFromDisk() + expect(store.left.content).toBe('NEW /tmp/a.txt') + expect(store.right.content).toBe('NEW /tmp/b.txt') + expect(store.notice).toBe('2 files changed on disk — diff reloaded.') + }) + + it('refreshFromDisk also follows a partial-paste file and keeps its shape', async () => { + const store = useDiffStore() + store.receivePasteFile('left', { name: 'src.txt', content: 'old body', path: '/tmp/src.txt' }) + window.api.readFile = async (path) => ({ path, name: 'src.txt', content: 'fresh body' }) + await store.refreshFromDisk() + expect(store.pasteLeftFile).toEqual({ + name: 'src.txt', + content: 'fresh body', + path: '/tmp/src.txt' + }) + expect(store.notice).toContain('src.txt') + }) + + it('a partial-paste file without a path is never re-read from disk', async () => { + const store = useDiffStore() + store.receivePasteFile('left', { name: 'typed.txt', content: 'x' }) // no path + let read = false + window.api.readFile = async () => ((read = true), { error: 'not-permitted' }) + await store.refreshFromDisk() + expect(read).toBe(false) + expect(store.pasteLeftFile.content).toBe('x') + }) + + // --- overwrite guard for an active, unsaved comparison --- + + it('opening a file into an unsaved complete comparison asks before replacing', async () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') // ready, and never saved + window.api.openFile = async (side) => ({ + path: `/tmp/new-${side}.txt`, + name: `new-${side}.txt`, + content: 'new' + }) + await store.pick('left') + expect(store.pendingPick).toMatchObject({ side: 'left' }) + expect(store.left.name).toBe('a.txt') // nothing replaced until confirmed + store.confirmPick() + expect(store.left.name).toBe('new-left.txt') + expect(store.pendingPick).toBeNull() + }) + + it('opening a file into a SAVED comparison replaces it without a prompt', async () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') + store.markSaved() + window.api.openFile = async () => ({ path: '/tmp/x.txt', name: 'x.txt', content: 'new' }) + await store.pick('left') + expect(store.pendingPick).toBeNull() + expect(store.left.name).toBe('x.txt') // replaced directly — nothing to lose + expect(store.diffSaved).toBe(false) // and it's unsaved work again + }) + + it('completing an incomplete comparison never prompts', async () => { + const store = useDiffStore() + store.left = FILE('a.txt') // only one side loaded → not ready + window.api.openFile = async () => ({ path: '/tmp/b.txt', name: 'b.txt', content: 'x' }) + await store.pick('right') + expect(store.pendingPick).toBeNull() + expect(store.right.name).toBe('b.txt') + }) + + it('cancelPick keeps the current comparison', async () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') + window.api.openFile = async () => ({ path: '/tmp/x', name: 'x', content: 'y' }) + await store.pick('left') + store.cancelPick() + expect(store.pendingPick).toBeNull() + expect(store.left.name).toBe('a.txt') + }) + + it('saveThenPick opens the save dialog, then finishPickAfterSave applies the pick', async () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') + window.api.openFile = async () => ({ path: '/tmp/x', name: 'x.txt', content: 'y' }) + await store.pick('right') + store.saveThenPick() + expect(store.pendingPick).toBeNull() + expect(store.pickAfterSave).toMatchObject({ side: 'right' }) + expect(store.showSaveDialog).toBe(true) + store.finishPickAfterSave() + expect(store.right.name).toBe('x.txt') + expect(store.pickAfterSave).toBeNull() + }) + + it('drag-drop over a SAVED comparison replaces without prompting', async () => { + const store = useDiffStore() + window.api.readFile = async (path) => ({ path, name: path.split('/').pop(), content: 'x' }) + store.left = FILE('a.txt') + store.right = FILE('b.txt') + store.markSaved() + await store.dropFiles(['/tmp/c.txt', '/tmp/d.txt']) + expect(store.pendingReplace).toBeNull() // no prompt + expect(store.left.name).toBe('c.txt') + expect(store.right.name).toBe('d.txt') + }) + + it('restore marks the diff saved; editing a side makes it unsaved again', () => { + const store = useDiffStore() + store.restore({ left: FILE('a.txt'), right: FILE('b.txt') }) + expect(store.diffSaved).toBe(true) + store.receive('left', FILE('c.txt')) + expect(store.diffSaved).toBe(false) + }) + + it('swap marks the comparison unsaved', () => { + const store = useDiffStore() + store.left = FILE('a.txt') + store.right = FILE('b.txt') + store.markSaved() + store.swap() + expect(store.diffSaved).toBe(false) + }) }) diff --git a/tests/renderer/utils/detectLanguage.test.js b/tests/renderer/utils/detectLanguage.test.js index 7b8581e..99ef5b0 100644 --- a/tests/renderer/utils/detectLanguage.test.js +++ b/tests/renderer/utils/detectLanguage.test.js @@ -117,7 +117,9 @@ describe('detectSnippetLanguage — programming languages', () => { }) it('detects Go', () => { - expect(detect('package main\n\nimport "fmt"\n\nfunc main() {\n fmt.Println("hi")\n}')).toBe('go') + expect(detect('package main\n\nimport "fmt"\n\nfunc main() {\n fmt.Println("hi")\n}')).toBe( + 'go' + ) }) it('detects Rust', () => { diff --git a/tests/renderer/utils/unifiedDiff.test.js b/tests/renderer/utils/unifiedDiff.test.js new file mode 100644 index 0000000..dee0b53 --- /dev/null +++ b/tests/renderer/utils/unifiedDiff.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { toUnifiedDiff, MAX_DIFF_LINES } from '../../../src/renderer/src/utils/unifiedDiff' + +describe('toUnifiedDiff', () => { + it('returns an empty patch for identical sides', () => { + expect(toUnifiedDiff('same\ntext\n', 'same\ntext\n')).toEqual({ patch: '' }) + }) + + it('emits a git-style hunk for a one-line modification', () => { + const res = toUnifiedDiff('one\ntwo\nthree\n', 'one\nTWO\nthree\n', { + leftLabel: 'a.txt', + rightLabel: 'b.txt' + }) + expect(res.patch).toBe('--- a.txt\n+++ b.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n') + }) + + it('represents a file created from empty as -0,0', () => { + const res = toUnifiedDiff('', 'x\ny\n') + expect(res.patch).toBe('--- original\n+++ changed\n@@ -0,0 +1,2 @@\n+x\n+y\n') + }) + + it('represents a pure deletion', () => { + const res = toUnifiedDiff('keep\ndrop\nkeep2\n', 'keep\nkeep2\n') + expect(res.patch).toContain('@@ -1,3 +1,2 @@') + expect(res.patch).toContain('-drop') + }) + + it('appends a trailing addition without a phantom empty line', () => { + const res = toUnifiedDiff('a\nb\n', 'a\nb\nc\n') + // Only one added line (c) in the hunk body; no spurious blank line from the + // trailing "\n". (Slice past the '+++' header so it isn't miscounted.) + const body = res.patch.slice(res.patch.indexOf('@@')) + expect((body.match(/^\+/gm) || []).length).toBe(1) + expect(res.patch).toContain('+c') + }) + + it('splits changes that are far apart into separate hunks', () => { + const left = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join('\n') + const right = left.replace('line1\n', 'X1\n').replace('\nline20', '\nX20') + const res = toUnifiedDiff(left, right) + expect((res.patch.match(/^@@ /gm) || []).length).toBe(2) + }) + + it('keeps nearby changes in a single hunk', () => { + const left = 'a\nb\nc\nd\ne\n' + const right = 'a\nB\nc\nD\ne\n' // two changes two lines apart -> one hunk + const res = toUnifiedDiff(left, right) + expect((res.patch.match(/^@@ /gm) || []).length).toBe(1) + }) + + it('guards against oversized inputs', () => { + const big = Array.from({ length: MAX_DIFF_LINES + 1 }, (_, i) => `l${i}`).join('\n') + expect(toUnifiedDiff(big, 'x')).toEqual({ error: 'too-large' }) + expect(toUnifiedDiff('x', big)).toEqual({ error: 'too-large' }) + }) + + it('line counts in the header match the hunk body', () => { + const res = toUnifiedDiff('a\nb\nc\nd\n', 'a\nX\nc\nd\n') + const header = res.patch.match(/@@ -(\d+),(\d+) \+(\d+),(\d+) @@/) + const [, , oldCount, , newCount] = header.map(Number) + const body = res.patch.slice(res.patch.indexOf('@@')) + const oldLines = (body.match(/^[ -]/gm) || []).length + const newLines = (body.match(/^[ +]/gm) || []).length + // The @@ header line itself starts with '@', not counted by [ -]/[ +]. + expect(oldLines).toBe(oldCount) + expect(newLines).toBe(newCount) + }) +}) From 6969251bb51e7287e8f483c907e9fd66eaade061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mindaugas=20Kasparavic=CC=8Cius?= Date: Thu, 23 Jul 2026 13:58:55 +0300 Subject: [PATCH 2/3] Add e2e coverage for search, tools, snippets, sharing; run e2e in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E (Playwright + the app's own Electron, all offline): 24 new tests across 8 specs — in-view diff search, the format-hint banner, Base64/JSON/Encrypt tools, full snippet CRUD, view toggles (split/ignore-ws/swap), menu-bar actions, config backup+restore round-trip, and — heavily guarded — the two-identity sharing flow: key export, add-trusted, recipient picker, sealed share, import, plus trusted-key rename/remove and own-key/garbage rejection. Shared menu-navigation + dialog-stub helpers live in e2e/fixtures.mjs. Full suite: 44 passing. CI: new `e2e` job runs the suite under Xvfb on every push/PR to main and uploads the HTML report + per-failure traces (with screenshots) as artifacts; each app-fixture test is traced, kept only on failure. --- .github/workflows/ci.yml | 39 +++++++++ e2e/config-backup.spec.mjs | 55 ++++++++++++ e2e/diff-search.spec.mjs | 60 +++++++++++++ e2e/fixtures.mjs | 41 +++++++++ e2e/format-hint.spec.mjs | 38 +++++++++ e2e/menu-actions.spec.mjs | 39 +++++++++ e2e/sharing.spec.mjs | 148 +++++++++++++++++++++++++++++++++ e2e/snippet-lifecycle.spec.mjs | 43 ++++++++++ e2e/tools.spec.mjs | 92 ++++++++++++++++++++ e2e/view-toggles.spec.mjs | 39 +++++++++ playwright.config.mjs | 8 +- 11 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 e2e/config-backup.spec.mjs create mode 100644 e2e/diff-search.spec.mjs create mode 100644 e2e/format-hint.spec.mjs create mode 100644 e2e/menu-actions.spec.mjs create mode 100644 e2e/sharing.spec.mjs create mode 100644 e2e/snippet-lifecycle.spec.mjs create mode 100644 e2e/tools.spec.mjs create mode 100644 e2e/view-toggles.spec.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49ed5bc..cb480d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,42 @@ jobs: - run: npm ci - run: npm run check # ESLint + Vitest - run: npm run build # ensure it bundles + + # End-to-end tests drive the app's OWN Electron (Playwright `_electron`) under a + # virtual display — no bundled browser, no network, same as the Docker test env. + # 22.04 keeps the pre-t64 apt package names the app's Electron links against. + e2e: + runs-on: ubuntu-22.04 + env: + # Never let Playwright pull Chromium/Firefox/WebKit — the suite uses none. + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + # Container/CI switch: --no-sandbox + software rendering (see security.js). + DIFFBRO_DOCKER: '1' + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + - run: npm install -g npm@11 + - run: npm ci + # Electron's runtime libraries + Xvfb (the same set the Docker test env installs). + - name: Install Electron runtime libraries + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 libatspi2.0-0 \ + libdrm2 libgbm1 libasound2 libx11-xcb1 libxcb-dri3-0 libsecret-1-0 xvfb + - name: Run end-to-end tests under Xvfb + run: xvfb-run -a npm run test:e2e # electron-vite build && playwright test + # Report + per-failure traces (with screenshots) for inspection after the run. + - name: Upload Playwright report and traces + if: always() + uses: actions/upload-artifact@v6 + with: + name: e2e-report + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 14 diff --git a/e2e/config-backup.spec.mjs b/e2e/config-backup.spec.mjs new file mode 100644 index 0000000..3d109de --- /dev/null +++ b/e2e/config-backup.spec.mjs @@ -0,0 +1,55 @@ +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { test, expect, openSettings, openMenu, stubSaveDialog, stubOpenDialog } from './fixtures.mjs' + +// Config backup seals identity keys, trusted hosts, snippets and settings into +// one passphrase-encrypted file; restore applies it back. The crypto is +// unit-tested — this proves the menu → dialog → file round-trip, using a known +// setting (theme) as the observable that survives the trip. +test('backing up then restoring recovers the saved settings', async ({ app, page }) => { + const backupPath = join(tmpdir(), `diffbro-e2e-cfg-${Date.now()}.diffbroconf`) + + // A distinctive theme to carry through the backup. + await openSettings(page) + await page.getByTitle('Use the Neon theme').click() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'neon') + await page.keyboard.press('Escape') + + // Back up (save dialog stubbed to a temp file). + await stubSaveDialog(app, backupPath) + await openMenu(page, 'Security', 'Configuration', 'Back Up') + const backup = page.getByRole('dialog', { name: 'Back up configuration' }) + await backup.getByLabel('Passphrase').fill('backup-pass-123') + await backup.getByRole('button', { name: 'Back up' }).click() + await expect(page.getByText(/backed up/i)).toBeVisible() + + // Change the theme away from the backed-up value. + await openMenu(page, 'View', 'Toggle Light/Dark Theme') // neon is dark-ground → light + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + + // Restore brings it back. + await stubOpenDialog(app, backupPath) + await openMenu(page, 'Security', 'Configuration', 'Restore') + const restore = page.getByRole('dialog', { name: 'Restore configuration' }) + await restore.getByLabel('Passphrase').fill('backup-pass-123') + await restore.getByRole('button', { name: 'Restore' }).click() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'neon') +}) + +test('restore rejects a wrong passphrase', async ({ app, page }) => { + const backupPath = join(tmpdir(), `diffbro-e2e-cfg-wrong-${Date.now()}.diffbroconf`) + + await stubSaveDialog(app, backupPath) + await openMenu(page, 'Security', 'Configuration', 'Back Up') + const backup = page.getByRole('dialog', { name: 'Back up configuration' }) + await backup.getByLabel('Passphrase').fill('the-right-one') + await backup.getByRole('button', { name: 'Back up' }).click() + await expect(page.getByText(/backed up/i)).toBeVisible() + + await stubOpenDialog(app, backupPath) + await openMenu(page, 'Security', 'Configuration', 'Restore') + const restore = page.getByRole('dialog', { name: 'Restore configuration' }) + await restore.getByLabel('Passphrase').fill('the-wrong-one') + await restore.getByRole('button', { name: 'Restore' }).click() + await expect(page.getByText(/Wrong passphrase/i)).toBeVisible() +}) diff --git a/e2e/diff-search.spec.mjs b/e2e/diff-search.spec.mjs new file mode 100644 index 0000000..693f3db --- /dev/null +++ b/e2e/diff-search.spec.mjs @@ -0,0 +1,60 @@ +import { test, expect } from './fixtures.mjs' + +// The in-view search drives Monaco's findMatches + decorations per side. The +// pure regex guard is unit-tested; only a launch exercises the editor wiring — +// match counting, next/prev navigation, and the bad-regex flag. +async function pasteCompare(page, left, right) { + await page.getByRole('button', { name: 'Paste text' }).click() + await page.getByPlaceholder('Paste original text here').fill(left) + await page.getByPlaceholder('Paste changed text here').fill(right) + await page.getByRole('button', { name: 'Compare', exact: true }).click() + await expect(page.getByText('Choose or drop two files to compare.')).toBeHidden() +} + +test('left-side search counts matches and steps through them', async ({ page }) => { + await pasteCompare(page, 'apple\napple pie\napple sauce', 'pear') + + const left = page.locator('.diff-viewer .side', { hasText: 'left' }) + const count = left.locator('.count') + + await left.getByPlaceholder('Search left side…').fill('apple') + await expect(count).toHaveText('1/3') + + await left.getByTitle('Next match').click() + await expect(count).toHaveText('2/3') + + await left.getByTitle('Previous match').click() + await expect(count).toHaveText('1/3') +}) + +test('whole-word narrows the matches', async ({ page }) => { + await pasteCompare(page, 'car cart cartoon car', 'x') + const left = page.locator('.diff-viewer .side', { hasText: 'left' }) + const count = left.locator('.count') + + await left.getByPlaceholder('Search left side…').fill('car') + await expect(count).toHaveText('1/4') // car, cart, cartoon, car + + await left.locator('label.opt', { hasText: 'W' }).locator('input').check() + await expect(count).toHaveText('1/2') // only the two standalone "car" +}) + +test('an unsafe regex is refused and flagged, not run', async ({ page }) => { + await pasteCompare(page, 'aaaaaa', 'x') + const left = page.locator('.diff-viewer .side', { hasText: 'left' }) + + await left.locator('label.opt', { hasText: '.*' }).locator('input').check() + await left.getByPlaceholder('Search left side…').fill('(') // invalid regex + await expect(left.locator('.count')).toHaveText('bad regex') +}) + +test('the right side searches independently of the left', async ({ page }) => { + await pasteCompare(page, 'alpha', 'beta beta beta') + const right = page.locator('.diff-viewer .side', { hasText: 'right' }) + await right.getByPlaceholder('Search right side…').fill('beta') + await expect(right.locator('.count')).toHaveText('1/3') + + // The left side, unqueried, shows no count. + const left = page.locator('.diff-viewer .side', { hasText: 'left' }) + await expect(left.locator('.count')).toHaveText('') +}) diff --git a/e2e/fixtures.mjs b/e2e/fixtures.mjs index 9850688..57cc6ce 100644 --- a/e2e/fixtures.mjs +++ b/e2e/fixtures.mjs @@ -32,7 +32,17 @@ export const test = base.extend({ app: async ({}, use) => { const userDataDir = freshUserDataDir() const app = await launchApp(userDataDir) + // Trace the whole run (with screenshots); keep it only when the test fails, + // attached so it shows up in the HTML report and as a CI artifact. Electron + // has its own BrowserContext, so tracing is wired here rather than via the + // config's `use.trace`, which only covers Playwright-launched browsers. + await app.context().tracing.start({ screenshots: true, snapshots: true, sources: true }) await use(app) + const info = test.info() + const failed = info.status !== info.expectedStatus + const tracePath = info.outputPath('trace.zip') + await app.context().tracing.stop(failed ? { path: tracePath } : {}) + if (failed) await info.attach('trace', { path: tracePath, contentType: 'application/zip' }) await app.close() rmSync(userDataDir, { recursive: true, force: true }) }, @@ -48,4 +58,35 @@ export async function openSettings(page) { await expect(page.getByRole('dialog', { name: 'Settings' })).toBeVisible() } +// Navigate the in-app menu bar (Windows/Linux — the Docker env is Linux). Pass a +// leaf item under a top menu, or a submenu + leaf for the nested Tools/Security +// groups. Targets by structural class + text so kbd hints in the label don't +// interfere. Only the open dropdown/flyout is in the DOM, so text is unambiguous. +export async function openMenu(page, top, sub, leaf) { + await page.getByRole('button', { name: top, exact: true }).click() + if (leaf) { + // The submenu opens its flyout on hover; clicking the toggle would fire + // mouseenter (opens) then the click handler (toggles shut), so hover only. + await page.locator('.submenu', { hasText: sub }).hover() + await page.locator('.flyout .item', { hasText: leaf }).click() + } else { + await page.locator('.dropdown .item', { hasText: sub }).click() + } +} + +// Replace the native save/open dialogs in the MAIN process so file flows are +// deterministic (no un-driveable OS dialog). The handlers call dialog.show*Dialog +// at invoke time, so reassigning the property takes effect immediately. +export async function stubSaveDialog(app, filePath) { + await app.evaluate(({ dialog }, fp) => { + dialog.showSaveDialog = async () => ({ canceled: false, filePath: fp }) + }, filePath) +} +export async function stubOpenDialog(app, filePaths) { + const list = Array.isArray(filePaths) ? filePaths : [filePaths] + await app.evaluate(({ dialog }, fps) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: fps }) + }, list) +} + export { expect } diff --git a/e2e/format-hint.spec.mjs b/e2e/format-hint.spec.mjs new file mode 100644 index 0000000..fa77b4e --- /dev/null +++ b/e2e/format-hint.spec.mjs @@ -0,0 +1,38 @@ +import { test, expect } from './fixtures.mjs' + +// The "looks like JSON/XML — pretty-print it?" banner is driven by a store +// getter over live content, and Format rewrites the side in place. A launch +// proves the banner renders above the diff, formats, and then clears itself. +async function pasteCompare(page, left, right) { + await page.getByRole('button', { name: 'Paste text' }).click() + await page.getByPlaceholder('Paste original text here').fill(left) + await page.getByPlaceholder('Paste changed text here').fill(right) + await page.getByRole('button', { name: 'Compare', exact: true }).click() + await expect(page.getByText('Choose or drop two files to compare.')).toBeHidden() +} + +// The format banner is a `div.hint`; the ShortcutBar's chips are `span.hint`, +// so scope to the div to avoid a collision. +test('minified JSON offers to format, and formatting clears the banner', async ({ page }) => { + await pasteCompare(page, '{"b":2,"a":1}', 'plain text') + + const banner = page.locator('div.hint') + await expect(banner).toContainText('Left looks like JSON') + + await banner.getByRole('button', { name: 'Format' }).click() + // Formatted content is multi-line, so the diff now shows a "b" line on its own. + await expect(page.getByText('"b": 2').first()).toBeVisible() + // Already pretty → the banner clears itself. + await expect(page.locator('div.hint')).toBeHidden() +}) + +test('Dismiss hides the banner without changing the content', async ({ page }) => { + await pasteCompare(page, '{"x":1,"y":2}', 'plain text') + const banner = page.locator('div.hint') + await expect(banner).toContainText('Left looks like JSON') + + await banner.getByRole('button', { name: 'Dismiss' }).click() + await expect(page.locator('div.hint')).toBeHidden() + // Content is untouched — the minified form is still what's shown. + await expect(page.getByText('{"x":1,"y":2}').first()).toBeVisible() +}) diff --git a/e2e/menu-actions.spec.mjs b/e2e/menu-actions.spec.mjs new file mode 100644 index 0000000..53c03e3 --- /dev/null +++ b/e2e/menu-actions.spec.mjs @@ -0,0 +1,39 @@ +import { test, expect, openMenu } from './fixtures.mjs' + +// The in-app menu bar (Windows/Linux) mirrors the hidden native menu that binds +// the real accelerators. Native accelerators can't be synthesized through the +// renderer, so this drives the menu bar itself — which shares the menus.js +// action table — proving each menu action reaches the store. +async function pasteCompare(page, left, right) { + await page.getByRole('button', { name: 'Paste text' }).click() + await page.getByPlaceholder('Paste original text here').fill(left) + await page.getByPlaceholder('Paste changed text here').fill(right) + await page.getByRole('button', { name: 'Compare', exact: true }).click() + await expect(page.getByText('Choose or drop two files to compare.')).toBeHidden() +} + +test('Edit → Clear empties a loaded comparison', async ({ page }) => { + await pasteCompare(page, 'a', 'b') + await openMenu(page, 'Edit', 'Clear') + await expect(page.getByText('Choose or drop two files to compare.')).toBeVisible() +}) + +test('Edit → Paste Text Mode opens the paste panes', async ({ page }) => { + await openMenu(page, 'Edit', 'Paste Text Mode') + await expect(page.getByPlaceholder('Paste original text here')).toBeVisible() +}) + +test('View → Toggle Light/Dark Theme flips the ground', async ({ page }) => { + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + await openMenu(page, 'View', 'Toggle Light/Dark Theme') + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') +}) + +test('Edit → Copy Diff as Patch reaches the clipboard', async ({ app, page }) => { + await pasteCompare(page, 'one\ntwo', 'one\nTWO') + await openMenu(page, 'Edit', 'Copy Diff as Patch') + await expect(page.getByText('Unified diff copied to clipboard.')).toBeVisible() + const clip = await app.evaluate(({ clipboard }) => clipboard.readText()) + expect(clip).toContain('@@') + expect(clip).toContain('+TWO') +}) diff --git a/e2e/sharing.spec.mjs b/e2e/sharing.spec.mjs new file mode 100644 index 0000000..fb56ff0 --- /dev/null +++ b/e2e/sharing.spec.mjs @@ -0,0 +1,148 @@ +import { mkdtempSync, rmSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + test, + expect, + launchApp, + freshUserDataDir, + firstReadyPage, + openMenu, + stubSaveDialog, + stubOpenDialog +} from './fixtures.mjs' + +// Sharing is the highest-stakes flow: exchange public keys, seal a diff for one +// recipient, and import it on the other side. The crypto core (seal/open, +// tamper, recipient-binding, TTL) is unit-tested; these guard the whole UX +// wiring across TWO real identities — export, add-trusted, the recipient picker, +// the sealed-file write, and import — which only two live apps can exercise. + +// Export this app's public key to `path` via the (stubbed) save dialog. Saving +// closes the dialog itself, so no explicit Close is needed. +async function exportKey(app, page, name, path) { + await stubSaveDialog(app, path) + await openMenu(page, 'Security', 'Share My Public Key') + const dlg = page.getByRole('dialog', { name: 'Share my public key' }) + await dlg.getByLabel('Name others will see').fill(name) + await dlg.getByRole('button', { name: /Save to file/ }).click() + await expect(page.getByText(/public key was saved/i)).toBeVisible() + await expect(dlg).toBeHidden() +} + +// Import a peer's key file and commit it under its prefilled name. +async function addTrusted(app, page, keyPath) { + await stubOpenDialog(app, keyPath) + await openMenu(page, 'Security', 'Add Trusted Key') + const add = page.getByRole('dialog', { name: 'Add trusted key' }) + await expect(add).toBeVisible() + await add.getByRole('button', { name: 'Add', exact: true }).click() + // The manager opens once stored; close it (footer button, not the × — both + // are named "Close"). + const mgr = page.getByRole('dialog', { name: 'Trusted keys' }) + await expect(mgr).toBeVisible() + await mgr.locator('.dialog-actions button', { hasText: 'Close' }).click() +} + +test('two peers exchange keys, share a sealed diff, and import it', async () => { + test.setTimeout(120_000) // two Electron instances + a full key-exchange flow + const exchange = mkdtempSync(join(tmpdir(), 'diffbro-exchange-')) + const dirA = freshUserDataDir() + const dirB = freshUserDataDir() + const appA = await launchApp(dirA) + const pageA = await firstReadyPage(appA) + const appB = await launchApp(dirB) + const pageB = await firstReadyPage(appB) + const keyA = join(exchange, 'alice.diffbrokey') + const keyB = join(exchange, 'bob.diffbrokey') + try { + // 1. Both export their public keys. + await exportKey(appA, pageA, 'Alice', keyA) + await exportKey(appB, pageB, 'Bob', keyB) + + // 2. Each trusts the other's key (the exchange must go both ways). + await addTrusted(appA, pageA, keyB) + await addTrusted(appB, pageB, keyA) + + // 3. Alice makes a diff, saves it, and shares it — Bob is the only recipient. + await pageA.getByRole('button', { name: 'Paste text' }).click() + await pageA.getByPlaceholder('Paste original text here').fill('release v1') + await pageA.getByPlaceholder('Paste changed text here').fill('release v2') + await pageA.getByRole('button', { name: 'Compare', exact: true }).click() + + await pageA.getByRole('button', { name: 'Save', exact: true }).click() + const saveDlg = pageA.getByRole('dialog', { name: 'Save diff' }) + await saveDlg.getByLabel('Name').fill('Shared work') + await saveDlg.getByRole('button', { name: 'Save', exact: true }).click() + + const row = pageA.locator('.diff', { hasText: 'Shared work' }) + await expect(row).toBeVisible() + await stubSaveDialog(appA, join(exchange, 'sealed-placeholder')) + await row.getByTitle('Share as sealed file').click() + const shareDlg = pageA.getByRole('dialog', { name: 'Share diff' }) + await expect(shareDlg).toBeVisible() + await shareDlg.getByRole('button', { name: 'Create file' }).click() + await expect(pageA.getByText(/Sealed shared diff/i)).toBeVisible() + + // The sealed file has a forced, hashed name — find it in the exchange dir. + const sealed = readdirSync(exchange).find((f) => f.endsWith('.diffbro')) + expect(sealed).toBeTruthy() + + // 4. Bob imports it — it lands in External diffs, credited to Alice. + await stubOpenDialog(appB, join(exchange, sealed)) + await pageB.getByRole('button', { name: 'Import', exact: true }).click() + await expect(pageB.getByText(/Imported "Shared work" from Alice/)).toBeVisible() + const external = pageB.locator('.sidebar-section', { hasText: 'External diffs' }) + await expect(external.getByText('Shared work')).toBeVisible() + + // 5. Manage trusted keys on Alice: rename Bob, then remove him. + await openMenu(pageA, 'Security', 'Manage Trusted Keys') + const mgr = pageA.getByRole('dialog', { name: 'Trusted keys' }) + await expect(mgr.getByText('Bob', { exact: true })).toBeVisible() + await mgr.getByTitle('Rename').click() + await mgr.locator('input.rename').fill('Bob — laptop') + await mgr.locator('input.rename').press('Enter') + await expect(mgr.getByText('Bob — laptop')).toBeVisible() + await mgr.getByTitle('Remove').click() + await expect(mgr.getByText(/No trusted keys yet/i)).toBeVisible() + } finally { + await appA.close() + await appB.close() + rmSync(dirA, { recursive: true, force: true }) + rmSync(dirB, { recursive: true, force: true }) + rmSync(exchange, { recursive: true, force: true }) + } +}) + +test('adding your own key is refused', async ({ app, page }) => { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-ownkey-')) + const keyPath = join(dir, 'me.diffbrokey') + try { + await stubSaveDialog(app, keyPath) + await openMenu(page, 'Security', 'Share My Public Key') + const dlg = page.getByRole('dialog', { name: 'Share my public key' }) + await dlg.getByRole('button', { name: /Save to file/ }).click() + await expect(page.getByText(/public key was saved/i)).toBeVisible() + + await stubOpenDialog(app, keyPath) + await openMenu(page, 'Security', 'Add Trusted Key') + await expect(page.getByText(/your own public key/i)).toBeVisible() + // No naming dialog opened. + await expect(page.getByRole('dialog', { name: 'Add trusted key' })).toBeHidden() + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a file that is not a public key is rejected', async ({ app, page }) => { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-badkey-')) + const bad = join(dir, 'bad.diffbrokey') + writeFileSync(bad, 'this is not a key') + try { + await stubOpenDialog(app, bad) + await openMenu(page, 'Security', 'Add Trusted Key') + await expect(page.getByText(/not a valid public key/i)).toBeVisible() + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/e2e/snippet-lifecycle.spec.mjs b/e2e/snippet-lifecycle.spec.mjs new file mode 100644 index 0000000..45cb27c --- /dev/null +++ b/e2e/snippet-lifecycle.spec.mjs @@ -0,0 +1,43 @@ +import { test, expect } from './fixtures.mjs' + +// Full snippet CRUD through the real vault: create (encrypt + store), see it in +// the list, edit (decrypt → update), and delete (with its confirm dialog). The +// unit tests cover the store; this proves the UI round-trip end to end. +test('a snippet can be created, edited, and deleted', async ({ page }) => { + // --- create --- + await page.getByRole('button', { name: '+ New snippet' }).click() + const editor = page.getByRole('dialog', { name: 'New Snippet' }) + await expect(editor).toBeVisible() + + await editor.getByPlaceholder('Snippet name…').fill('My note') + await editor.locator('.editor').click() + await page.keyboard.type('remember to hydrate') + await editor.getByRole('button', { name: 'Save', exact: true }).click() + + await expect(editor).toBeHidden() + const row = page.locator('.snippets-section .row', { hasText: 'My note' }) + await expect(row).toBeVisible() + + // --- edit (rename) --- + await page.getByText('My note', { exact: true }).click() + const edit = page.getByRole('dialog', { name: 'Edit Snippet' }) + await expect(edit).toBeVisible() + // Wait for the decrypted content to load (Save enables only with content). + await expect(edit.getByRole('button', { name: 'Save', exact: true })).toBeEnabled() + await edit.getByPlaceholder('Snippet name…').fill('My renamed note') + await edit.getByRole('button', { name: 'Save', exact: true }).click() + + await expect(edit).toBeHidden() + await expect(page.getByText('My renamed note', { exact: true })).toBeVisible() + await expect(page.getByText('My note', { exact: true })).toBeHidden() + + // --- delete (with confirm) --- + const renamed = page.locator('.snippets-section .row', { hasText: 'My renamed note' }) + await renamed.hover() + await renamed.getByTitle('Delete').click() + const del = page.getByRole('dialog', { name: 'Delete snippet?' }) + await expect(del).toBeVisible() + await del.getByRole('button', { name: 'Delete', exact: true }).click() + + await expect(page.getByText('My renamed note', { exact: true })).toBeHidden() +}) diff --git a/e2e/tools.spec.mjs b/e2e/tools.spec.mjs new file mode 100644 index 0000000..234968d --- /dev/null +++ b/e2e/tools.spec.mjs @@ -0,0 +1,92 @@ +import { test, expect, openMenu } from './fixtures.mjs' + +// The Tools dialogs are pure-util workbenches opened from the Tools menu. Their +// utils are unit-tested; a launch proves the menu → dialog → util → output +// wiring and the OS-clipboard write. + +test('Base64 encodes, then decodes back to the original', async ({ app, page }) => { + await openMenu(page, 'Tools', 'Base64', 'Encode / Decode') + const dialog = page.getByRole('dialog', { name: 'Base64 Encode / Decode' }) + await expect(dialog).toBeVisible() + + await dialog.getByPlaceholder('Text or Base64…').fill('Diff Bro') + await dialog.getByRole('button', { name: 'Encode →' }).click() + const output = dialog.locator('textarea[readonly]') + await expect(output).toHaveValue('RGlmZiBCcm8=') + + await dialog.getByRole('button', { name: 'Use as Input' }).click() + await dialog.getByRole('button', { name: 'Decode →' }).click() + await expect(output).toHaveValue('Diff Bro') + + // Copy writes to the real OS clipboard (via the main process). + await dialog.getByRole('button', { name: 'Copy', exact: true }).click() + const clip = await app.evaluate(({ clipboard }) => clipboard.readText()) + expect(clip).toBe('Diff Bro') +}) + +test('invalid Base64 is reported, not silently wrong', async ({ page }) => { + await openMenu(page, 'Tools', 'Base64', 'Encode / Decode') + const dialog = page.getByRole('dialog', { name: 'Base64 Encode / Decode' }) + await dialog.getByPlaceholder('Text or Base64…').fill('not valid base64 !!!') + await dialog.getByRole('button', { name: 'Decode →' }).click() + await expect(dialog.locator('.error')).toContainText('Not valid Base64') +}) + +test('JSON tool validates input and only formats when valid', async ({ page }) => { + await openMenu(page, 'Tools', 'JSON', 'Format / Validate') + const dialog = page.getByRole('dialog', { name: /JSON/ }) + await expect(dialog).toBeVisible() + const editor = dialog.locator('.editor') + + // Monaco auto-closes brackets, so we type only the opener + contents. A + // trailing comma makes it invalid → status invalid, Format disabled. + await editor.click() + await page.keyboard.type('[1,2,') + await expect(dialog.locator('.status.invalid')).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Format' })).toBeDisabled() + + // Replace with valid content → status flips and Format enables + pretty-prints. + await editor.click() + await page.keyboard.press('Control+A') + await page.keyboard.press('Delete') + await page.keyboard.type('[1,2') + await expect(dialog.locator('.status.valid')).toBeVisible() + const format = dialog.getByRole('button', { name: 'Format' }) + await expect(format).toBeEnabled() + await format.click() + // A formatted array spans multiple lines. + await expect(dialog.locator('.view-line')).toHaveCount(4) +}) + +test('Encrypt then Decrypt round-trips text under a passphrase', async ({ page }) => { + await openMenu(page, 'Tools', 'Text Encryption', 'Encrypt / Decrypt') + const dialog = page.getByRole('dialog', { name: 'Encrypt / Decrypt Text' }) + await expect(dialog).toBeVisible() + + await dialog.getByPlaceholder('Plain text to encrypt').fill('top secret') + await dialog.getByLabel('Passphrase').fill('correct horse battery') + await dialog.getByRole('button', { name: 'Encrypt →' }).click() + + const output = dialog.locator('textarea[readonly]') + await expect(output).not.toHaveValue('') + await expect(output).not.toHaveValue('top secret') + + // Round-trip: feed the ciphertext back and decrypt with the same passphrase. + await dialog.getByRole('button', { name: 'Use output as input' }).click() + await dialog.getByRole('button', { name: 'Decrypt →' }).click() + await expect(output).toHaveValue('top secret') +}) + +test('decrypting with the wrong passphrase fails loudly', async ({ page }) => { + await openMenu(page, 'Tools', 'Text Encryption', 'Encrypt / Decrypt') + const dialog = page.getByRole('dialog', { name: 'Encrypt / Decrypt Text' }) + + await dialog.getByPlaceholder('Plain text to encrypt').fill('secret') + await dialog.getByLabel('Passphrase').fill('right passphrase') + await dialog.getByRole('button', { name: 'Encrypt →' }).click() + await dialog.getByRole('button', { name: 'Use output as input' }).click() + + await dialog.getByLabel('Passphrase').fill('wrong passphrase') + await dialog.getByRole('button', { name: 'Decrypt →' }).click() + await expect(dialog.locator('.error')).toBeVisible() +}) diff --git a/e2e/view-toggles.spec.mjs b/e2e/view-toggles.spec.mjs new file mode 100644 index 0000000..0f749af --- /dev/null +++ b/e2e/view-toggles.spec.mjs @@ -0,0 +1,39 @@ +import { test, expect } from './fixtures.mjs' + +// The toolbar toggles feed Monaco options and store state; a launch proves each +// actually changes the rendered diff, which jsdom can't show. +async function pasteCompare(page, left, right) { + await page.getByRole('button', { name: 'Paste text' }).click() + await page.getByPlaceholder('Paste original text here').fill(left) + await page.getByPlaceholder('Paste changed text here').fill(right) + await page.getByRole('button', { name: 'Compare', exact: true }).click() + await expect(page.getByText('Choose or drop two files to compare.')).toBeHidden() +} + +test('ignore-whitespace turns a whitespace-only diff into "No differences"', async ({ page }) => { + await pasteCompare(page, 'alpha\nbeta', 'alpha \nbeta') // trailing space, line 1 + await expect(page.locator('.stats .identical')).toBeHidden() // a change, for now + + await page.getByLabel('Ignore whitespace').check() + await expect(page.locator('.stats .identical')).toHaveText('No differences') +}) + +test('Swap flips additions and deletions', async ({ page }) => { + await pasteCompare(page, 'a', 'a\nb') // right has an extra line → +1 / −0 + await expect(page.locator('.stats .add')).toHaveText('+1') + await expect(page.locator('.stats .del')).toContainText('0') + + await page.getByTitle(/Swap sides/).click() + await expect(page.locator('.stats .add')).toHaveText('+0') + await expect(page.locator('.stats .del')).toContainText('1') +}) + +test('Split view toggles between side-by-side and inline', async ({ page }) => { + await pasteCompare(page, 'a\nb', 'a\nc') + // Monaco tags the diff root .side-by-side only when rendering two columns. + const sideBySide = page.locator('.monaco-diff-editor.side-by-side') + await expect(sideBySide).toHaveCount(1) + + await page.getByLabel('Split view').uncheck() + await expect(sideBySide).toHaveCount(0) // now inline +}) diff --git a/playwright.config.mjs b/playwright.config.mjs index 7ca3eb1..f549c7a 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -19,5 +19,11 @@ export default defineConfig({ forbidOnly: !!process.env.CI, timeout: 30_000, expect: { timeout: 8_000 }, - reporter: process.env.CI ? [['list'], ['github']] : [['list']] + // On CI also emit a self-contained HTML report (uploaded as an artifact) so a + // failure can be inspected without re-running; locally the list reporter is + // enough. Traces/screenshots are captured per-test in e2e/fixtures.mjs (the + // `use.trace` option doesn't apply to Electron's own context). + reporter: process.env.CI + ? [['list'], ['github'], ['html', { open: 'never' }]] + : [['list']] }) From 14f38f15a7ebf4e34abefd87b49c2450af16eb36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mindaugas=20Kasparavic=CC=8Cius?= Date: Thu, 23 Jul 2026 14:02:07 +0300 Subject: [PATCH 3/3] CI: run e2e only as the pull-request merge gate for main E2E is the behavioural source of truth, so it runs on pull requests to main (the merge gate) rather than on post-merge pushes. Mark the `e2e` job as a required status check in main's branch-protection rule to actually block a merge when it is red. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb480d5..8035965 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,13 @@ jobs: # End-to-end tests drive the app's OWN Electron (Playwright `_electron`) under a # virtual display — no bundled browser, no network, same as the Docker test env. # 22.04 keeps the pre-t64 apt package names the app's Electron links against. + # + # E2E is the behavioural source of truth: it runs as the merge gate on pull + # requests to main only (not on post-merge pushes — main is reached through a + # PR). Make it a required status check in the branch-protection rule for main + # so a red run blocks the merge. e2e: + if: github.event_name == 'pull_request' runs-on: ubuntu-22.04 env: # Never let Playwright pull Chromium/Firefox/WebKit — the suite uses none.