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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ node_modules/
build/
dist/
coverage/
test-results/
playwright-report/
.claude/settings.local.json
.DS_Store
*.log
Expand Down
50 changes: 47 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<AppIcon name="…" />`, 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"`
Expand Down Expand Up @@ -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).
Expand All @@ -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

Expand All @@ -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.
56 changes: 52 additions & 4 deletions DEVELOPMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down
14 changes: 8 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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) | \
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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),)
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,32 @@ 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
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** — 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).

Expand Down Expand Up @@ -105,6 +117,11 @@ No local Node? The same flow runs in Docker: `make dev` (app via noVNC at
<http://localhost:6080/vnc.html>) 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.
Expand Down
5 changes: 5 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>DiffViewer · Saved diffs · Snippets · Tools dialogs"]
stores["Pinia stores<br/>diff · vault · snippets"]
stores["Pinia stores<br/>diff · vault · snippets · settings"]
adapters["Adapter registry<br/>text → { kind, … } comparable"]
ui --> stores --> adapters
end
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions e2e/category.spec.mjs
Original file line number Diff line number Diff line change
@@ -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 <label> is non-exact because the <select>'s option text is part of
// its accessible name.
await dialog.getByLabel('Category').selectOption('__new__')
await dialog.getByLabel('New category name').fill(CATEGORY)
await dialog.getByRole('button', { name: 'Save', exact: true }).click()

// The new shelf appears (auto-expanded) with the diff filed under it.
await expect(page.getByText('Saved (encrypted)')).toBeVisible()
await expect(page.locator('.cat-name', { hasText: CATEGORY })).toBeVisible()
await expect(page.getByText(DIFF)).toBeVisible()
})
Loading