diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 49ed5bc..8035965 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,3 +22,48 @@ 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 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.
+ 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/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.
-## 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/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/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/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-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/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']]
})
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/AppToolbar.vue b/src/renderer/src/components/AppToolbar.vue
index d4a30ee..0f86e72 100644
--- a/src/renderer/src/components/AppToolbar.vue
+++ b/src/renderer/src/components/AppToolbar.vue
@@ -4,6 +4,7 @@
// MenuBar.vue) — this is the pointer-friendly half.
import { useDiffStore } from '../stores/diffStore'
import { MOD } from '../keys'
+import AppIcon from './AppIcon.vue'
const store = useDiffStore()
@@ -11,8 +12,11 @@ const store = useDiffStore()
- +{{ store.stats.additions }}
- −{{ store.stats.deletions }}
+ No differences
+
+ +{{ store.stats.additions }}
+ −{{ store.stats.deletions }}
+
- You dropped {{ dropped }}. Loading it will discard the comparison you have open ({{
- current
- }}{{ current }}). Save it first if you want to keep it.
-
+
-
-
+
+
diff --git a/src/renderer/src/components/SaveDiffDialog.vue b/src/renderer/src/components/SaveDiffDialog.vue
index 999eb51..559443c 100644
--- a/src/renderer/src/components/SaveDiffDialog.vue
+++ b/src/renderer/src/components/SaveDiffDialog.vue
@@ -37,11 +37,15 @@ async function save() {
if (!id) {
diff.saveThenShare = false
diff.replaceAfterSave = null
+ diff.pickAfterSave = null
diff.showNotice(
'Couldn’t save: the saved-diff key couldn’t be unlocked (the OS keychain may be locked). Try again once it’s available.'
)
return
}
+ // The on-screen comparison now matches a vault entry, so overwriting it no
+ // longer needs the "you'll lose it" prompt.
+ diff.markSaved()
if (diff.saveThenShare) {
// "Share" flow: continue straight into the recipient picker.
diff.saveThenShare = false
@@ -50,6 +54,10 @@ async function save() {
// "Save first" from the replace prompt: saved, now load the dropped file(s).
diff.showNotice('Saved (encrypted). Loading the dropped file…')
await diff.finishReplaceAfterSave()
+ } else if (diff.pickAfterSave) {
+ // "Save first" from the file-load prompt: saved, now open the picked file.
+ diff.showNotice('Saved (encrypted). Loading the file…')
+ diff.finishPickAfterSave()
} else {
diff.showNotice(`Saved (encrypted) — expires in ${ttl.value} h.`)
}
@@ -58,8 +66,9 @@ async function save() {
function cancel() {
diff.showSaveDialog = false
diff.saveThenShare = false
- // Cancelling the save abandons a pending "save-then-replace" too.
+ // Cancelling the save abandons a pending "save-then-replace/pick" too.
diff.replaceAfterSave = null
+ diff.pickAfterSave = null
}
diff --git a/src/renderer/src/components/SnippetEditorDialog.vue b/src/renderer/src/components/SnippetEditorDialog.vue
index 846420f..9e217f4 100644
--- a/src/renderer/src/components/SnippetEditorDialog.vue
+++ b/src/renderer/src/components/SnippetEditorDialog.vue
@@ -30,7 +30,10 @@ const {
isMermaid,
canFormat,
save,
- close,
+ confirmingDiscard,
+ requestClose,
+ keepEditing,
+ discardDraft,
formatContent,
copyContent,
expandDiagram
@@ -58,7 +61,7 @@ function saveSnippet() {
width="560px"
:title="isNew ? 'New Snippet' : 'Edit Snippet'"
:escape-closes="false"
- @close="close"
+ @close="requestClose"
>