diff --git a/.gitignore b/.gitignore index aa06506..0c1cfb2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ build/ dist/ coverage/ +test-results/ +playwright-report/ .claude/settings.local.json .DS_Store *.log diff --git a/CLAUDE.md b/CLAUDE.md index 8167af4..58a4d7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,24 @@ Monaco. Roadmap lives in `DEVELOPMENT_PLAN.md` — keep its checkboxes current. hardcoded color, font-size or radius in `components/styles/` — add a token rather than a literal, or `/* token-exempt: reason */` when a literal is genuinely right. New UI must be checked in both themes. +- **Alignment.** Any full-width horizontal strip (toolbar, file-slots row, + section header, dialog header) is a *band*: it carries `.band` and vertically + centres its content with flexbox. Never fake vertical alignment with top + padding — it drifts the instant a font size or line-height changes (that is + what twice broke the sidebar/file-input alignment). Bands that sit at the + same vertical position across the sidebar/main divider share a height so + their content lines up by construction: the file-slots row and EVERY sidebar + section header add `.band-row` (height `--band-row`), so the headers all + match each other and the first lines up with the file inputs. Controls + sharing a band row must be + equal-height flex-centred boxes. +- **Icons are SVG, never Unicode glyphs.** A symbol like ◈ / ⧉ / 🔒 tofus into a + `[]` box on any font that lacks it, which kept happening. Every icon comes + from ``, whose geometry lives in `src/renderer/src/icons.js` + (Feather/Lucide-style 24×24, sizes to 1em, inherits `currentColor`). Add a new + icon by adding an entry to that map — never reach for a glyph character. Text + glyphs used as *prose* (⌘ in a shortcut label, ↔ in a diff name, the − on the + deletions count) stay text; only standalone/interactive icons are SVG. - Every modal is a `BaseDialog` (backdrop, header, `#actions` slot, Escape, focus trap). Its panel is BaseDialog's, so a dialog sizes itself with the `width` prop — scoped CSS cannot reach into a child. `:escape-closes="false"` @@ -98,9 +116,19 @@ Monaco. Roadmap lives in `DEVELOPMENT_PLAN.md` — keep its checkboxes current. in the Docker env. - Vitest, jsdom environment (`tests/setup.js` provides localStorage — Node's built-in one is broken in workers). The tree under `tests/` mirrors `src/`: - `tests/main/`, `tests/renderer/{stores,utils,adapters}/`. A new test goes - in the directory matching its subject's source path. Fixtures live in - `tests/data/`. + `tests/main/`, `tests/renderer/{stores,utils,adapters,composables}/`. A new + test goes in the directory matching its subject's source path. Fixtures live + in `tests/data/`. +- **Interaction bugs split two ways, and each has a home — this is how the + recurring UI regressions get caught.** *Event logic* (does a backdrop click + close only when the press began on the backdrop? does Space commit a tag? + does Escape leave the snippet editor open?) is pulled OUT of the `.vue` file + into a `composables/` unit and unit-tested there — never left inline where + nothing exercises it. The Mermaid-viewer resize-closes bug became + `useBackdropClose` + `useBackdropClose.test.js`; follow that pattern for any + new event guard. *Layout* (alignment, sizing, overlap) can't be asserted in + jsdom — verify it in the Docker env with screenshots, and encode the + invariant as a shared class/token (see the band system) so it can't drift. - Every behavior change in `src/main/sealing.js`, `vaultCrypt.js`, the Pinia stores, or the adapters needs a test in the same change. Crypto code additionally needs negative tests (tamper, wrong key, expiry). @@ -110,6 +138,17 @@ Monaco. Roadmap lives in `DEVELOPMENT_PLAN.md` — keep its checkboxes current. - UI-level changes are verified in the Docker test env (screenshots via `xdotool`/`scrot` inside the container; keyboard via `xdotool`, not the noVNC page — see `docker/README.md`). +- **End-to-end** tests live in `e2e/` (Playwright driving the app's OWN + Electron via `_electron` — no bundled browser, no network). `make e2e` + builds then runs them INSIDE the up container (they need Xvfb :99, so they + can't use the one-off `make check`/`test` container). Each test launches its + own Electron against a throwaway `--user-data-dir`, so runs never touch the + developer's real data and never fight the single-instance lock. Reach for E2E + for a flow only a real launch exercises (preload/IPC round-trips, persistence + across relaunch, OS-clipboard writes) — the kind of bug jsdom can't see. E2E + is trusted-click, so `navigator.clipboard` writes there hit the deny-all + permission handler and fail: clipboard writes go through `window.api.copyText` + (main process, `src/main/clipboard.js`), never `navigator.clipboard`. ## Workflow @@ -128,5 +167,10 @@ Monaco. Roadmap lives in `DEVELOPMENT_PLAN.md` — keep its checkboxes current. work around npm dropping them from the lock (they are transitive optionals of vitest's wasm toolchain) — do not remove them just because nothing imports them. +- Playwright drives the app's own Electron, never a bundled browser, so its + ~400 MB browser download is skipped via `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` + (set in `docker/Dockerfile` before `npm ci`, and pass it when adding/updating + deps). Never run `playwright install` — it would pull Chromium/Firefox/WebKit + the suite doesn't use. - Update `README.md` (including the mermaid diagram) and `DEVELOPMENT_PLAN.md` when architecture or feature status changes. diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 288d56b..560dc3b 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -105,9 +105,24 @@ First run: `npm install && npm run dev` adapters); ESLint flat config with security rules (renderer banned from Node/Electron imports, no v-html/eval) + Prettier; `npm run check` = lint + tests; CLAUDE.md encodes the guidelines. -- [x] Light/dark theme toggle (Monaco `vs` / `vs-dark` + CSS variables on - `:root[data-theme]`; toolbar button + View menu + Ctrl/Cmd+D; choice - persisted through the durable data-dir store, so it survives a reinstall) +- [x] End-to-end suite in `e2e/` (Playwright `_electron`, `make e2e`): drives + the built app in an isolated `--user-data-dir`, no bundled browser / no + network. Covers launch smoke, the Settings domain-pane rail, theme apply + + persistence across a relaunch, the snippet copy "Copied" flash + the real + OS-clipboard write, paste-compare diffing with Monaco stats, saving a diff + and reopening it after a relaunch (the full vault encrypt→store→decrypt + round-trip), the Share two-step flow into first-time key setup, and Mermaid + rendering in both the diagram viewer and the snippet editor's live preview. + Caught a real shipping bug — `navigator.clipboard.writeText` is denied by + the deny-all permission handler, so all clipboard writes now go through the + main process (`src/main/clipboard.js`, `window.api.copyText`). +- [x] Five selectable themes (Light default, Dark, Solar, Neon, Contrast) — + registry in `utils/themes.js`, palette per theme on `:root[data-theme]`, + Monaco/Mermaid ground keyed off `isDarkTheme`; picked in Settings → + Appearance (swatch previews), Ctrl/Cmd+D still flips light↔dark; choice + persisted through the durable data-dir store, so it survives a reinstall +- [x] Settings split into domain panes behind a left rail (Appearance / + Storage / Limits) so the window stays scannable - [x] Re-read files on window focus (quiet re-read: no large-file prompt, silent skip if the file vanished; toast when the diff was reloaded) - [x] Window state persistence (size/position/maximized in @@ -122,7 +137,8 @@ First run: `npm install && npm run dev` - [x] Encrypted, tagged snippet library ("quiet shelves" sidebar: ★ Favorites + All snippets, newest-first, collapsible tag filter that composes with - search, hover preview that decrypts on demand) + search, hover preview that decrypts on demand; copy-to-clipboard shows a + transient "Copied" flash at the row via unit-tested `useCopyFeedback`) - [x] Mermaid diagram rendering for `mermaid` snippets — lazy-loaded (dynamic `import`, its own build chunks; nothing added to the main bundle), runs **offline under the strict CSP with no `unsafe-eval`** (verified against @@ -132,6 +148,38 @@ First run: `npm install && npm run dev` - [x] Live preview in the snippet editor + a resizable, zoom/pan diagram viewer; diagram theme paired to the app theme (dark → `dark`, light → `default`), re-rendered on theme switch so text never blends into the canvas +- [x] Auto-detect for the snippet editor's syntax picker + (`utils/detectLanguage.js`): distinctive, low-ambiguity signals for every + offered language (JSON, Mermaid, SQL, Markdown, YAML/K8s, Python, shell, + PHP, JS, TS, XML, HTML, CSS, Dockerfile, Go, Rust, Java), ordered + most-distinctive-first with anti-false-positive guards (a fenced block is + Markdown not its inner code; TS before JS; HTML-only tags before generic + XML; code braces disqualify CSS/YAML). Best-effort — a miss lands on + plaintext rather than mis-coloring. Covered by a positive-plus-negative + test matrix in `tests/renderer/utils/detectLanguage.test.js`. + +## Phase 2.6 – UI/UX refinements ✅ + +- [x] Plaintext `settings.json` store (`stores/settingsStore.js`): reorderable + sidebar sections, drag-reorderable saved-diff categories, shortcut-bar + visibility, and user-raisable comparison-file / snippet size limits with + safe defaults and hard ceilings (main enforces the file limit from it) +- [x] Reorderable sidebar sections behind a shared `SectionHeader`; Saved / + External / Snippets each extracted into a self-contained component +- [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] 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 + hands the fixed repo issue URL to the OS browser (the only outward link; + the URL is fixed in main, the renderer can only trigger it) +- [x] Categories are local-only — never offered or sent in the share flow +- [x] Color palette split into `styles/themes.css` (structure stays in + `tokens.css`), plus app-wide `.section-actions` spacing so no section's + buttons drift out of alignment +- [x] Mermaid "Expand" opens a full-window viewer above all dialogs (the + snippet editor closes first) ## Phase 3 – Packaging & distribution (~1–2 days + cert wait times) diff --git a/Makefile b/Makefile index e426c5c..601078e 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ VNC_URL := http://localhost:6080/vnc.html RUN_NPM := docker compose run --rm --entrypoint npm $(SERVICE) .PHONY: help install test-env test-env-detached up stop down restart rebuild logs shell \ - clean dev check test lint build package-win package-linux package-mac audit-fix brew-cask + clean dev check test e2e lint build package-win package-linux package-mac audit-fix brew-cask help: ## List available targets @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ @@ -83,6 +83,12 @@ check: ## Lint + tests in the container (run before declaring a task done) test: ## Run the test suite in the container $(RUN_NPM) -- test +# Unlike `check`/`test`, E2E needs the virtual display, so it runs INSIDE the +# up container (which owns Xvfb :99) rather than a one-off `run` container. +# `up` is a dependency so the display is guaranteed to be there first. +e2e: up ## Build + drive the app end-to-end with Playwright in the running container + docker compose exec -T $(SERVICE) npm run test:e2e + lint: ## Run ESLint in the container $(RUN_NPM) -- run lint @@ -124,10 +130,6 @@ package-mac: ## Build the macOS DMG -> dist/ (must run natively on macOS) exit 1; } npm run build:mac -# Regenerate the Homebrew cask (packaging/homebrew/diff-bro.rb) with the current -# version and the sha256 of the released DMG. Runs on the host (plain node, no -# deps). By default it downloads the tagged release asset; pass a local DMG with -# DMG=dist/Diff-Bro-macOS.dmg to hash that instead. Override the version with -# VERSION=x.y.z. CI does this automatically on release (see release.yml). +# Regenerate the Homebrew cask (packaging/homebrew/diff-bro.rb) with the current tag version brew-cask: ## Regenerate the Homebrew cask for the current (or VERSION=x.y.z) release node scripts/gen-homebrew-cask.mjs $(VERSION) $(if $(DMG),--dmg $(DMG),) diff --git a/README.md b/README.md index 92630e1..86820a2 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,19 @@ Builds are **unsigned** for now (no Apple/Microsoft cert yet): - **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 (plain / regex, match count, jump-to-match). + 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; light/dark themes; clamped zoom. + 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 categories, favoritable. + 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 @@ -71,8 +79,12 @@ Builds are **unsigned** for now (no Apple/Microsoft cert yet): 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** — Base64, and JSON / XML / SQL format+validate (Monaco-highlighted, - with "Add to Snippets"), plus a passphrase text Encrypt/Decrypt. +- **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). @@ -105,6 +117,11 @@ 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`. +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. + ## Docs - [Architecture](docs/architecture.md) — processes, trust boundary, directory map. diff --git a/docker/Dockerfile b/docker/Dockerfile index ecea95c..c9a6663 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -34,6 +34,11 @@ WORKDIR /app # by one fails `npm ci` under the other. One resolver everywhere. RUN npm install -g npm@11 +# The E2E suite drives the app's own Electron (playwright's `_electron`), never +# a bundled browser — so skip Playwright's ~400 MB Chromium/Firefox/WebKit +# download at install time. Keeps the image lean and the build offline-light. +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + # Install dependencies at image-build time so the node_modules volume that # shadows the host's (Windows) node_modules starts out populated with the # Linux Electron binaries. diff --git a/docs/architecture.md b/docs/architecture.md index bc9a2a8..91d41ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,7 @@ crypto, keys) lives in **main**; the **preload** exposes only a small, validated flowchart TB subgraph R["Renderer — Vue 3 (sandboxed: no Node, no Electron)"] ui["UI
DiffViewer · Saved diffs · Snippets · Tools dialogs"] - stores["Pinia stores
diff · vault · snippets"] + stores["Pinia stores
diff · vault · snippets · settings"] adapters["Adapter registry
text → { kind, … } comparable"] ui --> stores --> adapters end @@ -54,7 +54,9 @@ flowchart TB - `src/main` — Electron main: window, menu, file dialogs + reads (binary/size detection, `chardet`/`iconv-lite` encoding), `appData.js` (the configurable data directory + file-backed store where diffs/snippets/keys live, so data - survives a reinstall), and the pure, unit-tested crypto cores: `sealing.js` + survives a reinstall; it also reads the renderer's plaintext `settings.json` + for the few limits main enforces, like the large-file threshold), and the + pure, unit-tested crypto cores: `sealing.js` (sealed diff sharing), `vaultCrypt.js` (saved-diff vault), `snippetSealing.js` (snippet export), `textCrypt.js` (Tools encrypt/decrypt), `configBackup.js` (config backup). `share.js` is the thin Electron glue. diff --git a/e2e/category.spec.mjs b/e2e/category.spec.mjs new file mode 100644 index 0000000..387b78d --- /dev/null +++ b/e2e/category.spec.mjs @@ -0,0 +1,32 @@ +import { test, expect } from './fixtures.mjs' + +// Creating a category on the fly from the Save dialog is a UI seam the store +// tests can't reach: the "+ New category…" option reveals a name field, and on +// save the diff must file into a freshly created, auto-expanded shelf in the +// sidebar. Uses paste-compare to make something saveable without a file dialog. +test('saving into a new category creates its shelf and files the diff there', async ({ page }) => { + const CATEGORY = 'E2E work items' + const DIFF = 'Categorised diff' + + await page.getByRole('button', { name: 'Paste text' }).click() + await page.getByPlaceholder('Paste original text here').fill('a') + await page.getByPlaceholder('Paste changed text here').fill('b') + await page.getByRole('button', { name: 'Compare', exact: true }).click() + + await page.getByRole('button', { name: 'Save', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Save diff' }) + await dialog.getByLabel('Name', { exact: true }).fill(DIFF) + + // Choosing "+ New category…" (value __new__) reveals the name field; fill it, + // then save. Selected by value — the option label carries an ellipsis, and the + // wrapping + + @@ -128,10 +144,10 @@ onBeforeUnmount(() => { title="Previous match" @click="s.ref.step(-1)" > - ‹ + diff --git a/src/renderer/src/components/EncryptDecryptDialog.vue b/src/renderer/src/components/EncryptDecryptDialog.vue index 6b875c0..39e3275 100644 --- a/src/renderer/src/components/EncryptDecryptDialog.vue +++ b/src/renderer/src/components/EncryptDecryptDialog.vue @@ -60,7 +60,7 @@ function useOutputAsInput() { } async function copyOutput() { - await navigator.clipboard.writeText(output.value) + await window.api.copyText(output.value) store.showNotice('Copied to clipboard.') } diff --git a/src/renderer/src/components/ExternalDiffsSection.vue b/src/renderer/src/components/ExternalDiffsSection.vue new file mode 100644 index 0000000..f0c1ea7 --- /dev/null +++ b/src/renderer/src/components/ExternalDiffsSection.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/src/renderer/src/components/KeyboardShortcutsDialog.vue b/src/renderer/src/components/KeyboardShortcutsDialog.vue new file mode 100644 index 0000000..2474364 --- /dev/null +++ b/src/renderer/src/components/KeyboardShortcutsDialog.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/renderer/src/components/MenuBar.vue b/src/renderer/src/components/MenuBar.vue index d8f4b36..f7bf92e 100644 --- a/src/renderer/src/components/MenuBar.vue +++ b/src/renderer/src/components/MenuBar.vue @@ -2,6 +2,7 @@ import { onBeforeUnmount, onMounted, ref } from 'vue' import { useDiffStore } from '../stores/diffStore' import { buildMenus } from '../menus' +import AppIcon from './AppIcon.vue' // Themed replacement for the native File/View menu bar on Windows/Linux // (macOS keeps the system menu bar). Items mirror the hidden application @@ -57,7 +58,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))