diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 1b72e49..ffbfc66 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -1,7 +1,7 @@ # DiffBro – Development Plan Goal: cross-platform (Windows + macOS) desktop diff viewer with GitHub-style -rendering. Text files first; Word, PDF, and image comparison later via the +rendering. Text files first; Word and Excel comparison later via the adapter pattern. Stack: Electron + electron-vite + Vue 3 + Pinia + Monaco diff editor + @@ -154,9 +154,12 @@ First run: `npm install && npm run dev` every diagram type in real Chromium before adopting the dep). SVG is inserted via `DOMParser` + `replaceChildren`, never `innerHTML`/`v-html`; `securityLevel: 'strict'` (DOMPurify) is never lowered. -- [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] Live preview in the snippet editor + a zoom/pan diagram viewer, drag-resizable + from any of its four corners (`useResizable` + pure `utils/resizeRect.js`) and + auto-maximised when the app window enters fullscreen (main pushes + `window:fullscreen`, `useFullScreen` relays it); 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, @@ -174,7 +177,11 @@ First run: `npm install && npm run dev` 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 + External / Snippets each extracted into a self-contained component. Reorder + by dragging a whole header (`useSectionReorder`) or via its up/down + steppers; a single toolbar padlock freezes the arrangement + (`settings.sectionsLocked`, persisted — locked headers drop the drag handle + and steppers) - [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 @@ -226,21 +233,51 @@ First run: `npm install && npm run dev` - [ ] Extend `file:read` IPC to return a Buffer for binary formats - [ ] Note limitation in UI: content diff, not formatting diff -## Phase 5 – PDF (~3–4 days) - -- [ ] `pdfAdapter`: extract text with `pdfjs-dist` (renderer-side is fine) -- [ ] Normalize extraction artifacts: hyphenation, line-order in multi-column - layouts, page markers -- [ ] Out of scope initially: scanned PDFs (would need tesseract.js OCR) and - visual/pixel diff of rendered pages — decide later if needed - -## Phase 6 – Images (~2 days) - -- [ ] `imageAdapter` returning `{ kind: 'image', dataUrl }` — first non-text - comparable kind -- [ ] New `ImageDiffViewer.vue`: side-by-side, overlay slider, and pixel-diff - mode via `pixelmatch` (render highlighted-difference canvas) -- [ ] Router in the content area: pick viewer component by comparable `kind` +## Phase 5 – Excel spreadsheets (~5–7 days) + +A **structured grid diff** (not text extraction): sheet tabs, aligned grids, +cell/row/column-level highlighting. `.xlsx` only (zip-of-XML); legacy `.xls` +(BIFF) is out of scope. + +- [x] **Parser spike (Phase 0):** custom, minimal, read-only OOXML reader in the + **main process** on `fflate` (zip) + `saxen` (streaming SAX) — chosen over + SheetJS (npm frozen at 0.18.5 with unpatched CVE-2023-30533 + + CVE-2024-22363; fixes CDN-only) and exceljs (21 MB, write surface). Lives + in `src/main/xlsx/`, fully unit-tested (`tests/main/xlsx/`, 16 cases incl. + bomb / DOCTYPE / cell-budget / proto-pollution). Security by *not parsing*: + only `workbook.xml`, its rels, `sharedStrings.xml`, and `worksheets/sheetN.xml` + are inflated; formulas (``) are never read or evaluated; external links, + VBA, drawings, media, styles are never touched. Caps: decompression-bomb + (input/entry/total/ratio), per-sheet cell budget; `DOCTYPE` rejected (XXE). +- [x] `file:read` detects `.xlsx` (extension + `PK` zip magic) before the binary + sniff and parses it in main, returning `{ kind:'spreadsheet', sheets }` or a + polite `{ error:'xlsx' }` (`src/main/files.js`). Shares the binary read path + with the Word/`docx` phase. +- [x] `xlsxAdapter` returning `{ kind: 'spreadsheet', sheets }`; registered ahead + of textAdapter (`adapters/xlsxAdapter.js`). +- [x] **Router in the content area:** `App.vue` picks the viewer by + `store.comparableKind` (`DiffViewer` for text, `SpreadsheetDiffViewer` for + spreadsheets). +- [x] `SpreadsheetDiffViewer.vue` (+ `SheetTabBar`, `SpreadsheetGrid`): sheet tabs + with per-sheet change counts, two aligned grids sharing one scroll region, + changed-cell / added-row / removed-row highlighting, and a status strip. +- [x] Row-alignment algorithm (`utils/alignRows.js`, unit-tested): LCS over row + signatures with key-column pairing so an inserted/deleted row doesn't + cascade; O(n·m) LCS under a 4M-product budget, else O(n) positional. +- [x] Hang protection: the grid caps rendered rows at `RENDER_ROW_CAP` (3000) with + a "first N rows shown" note — no virtualization yet. Text already has its + guards (10 MB file-size prompt on load + `MAX_DIFF_LINES` on the patch). + Stress benchmark: `tests/stress/diff-stress.test.js` (opt-in, `STRESS=1`). + Measured on this machine: parse+align stays smooth to 10k rows (~99 ms), + "ok" to 50k (~0.5 s), sluggish at 100k / 5.5 MB (~1 s); the DOM render is the + real viewing ceiling (Docker-measured). +- [ ] Note limitation in UI: value diff, not formatting; dates read as serials + (styles deliberately not parsed). +- [ ] Follow-up: true row virtualization for the grid (replace `RENDER_ROW_CAP`); + swap the synchronous unzip for `fflate` streaming `Unzip` with a hard + byte-abort so the bomb bound is enforced *during* inflation. +- [ ] E2E (`e2e/spreadsheet.spec.mjs`) written — verify under `make e2e` (Docker), + it can't launch Electron on the host. --- @@ -270,9 +307,9 @@ the machine where the app runs. Measures already implemented in the scaffold: Google). - **External navigation blocked**: `setWindowOpenHandler` denies all popups; `will-navigate` is restricted to the app itself. -- **No CDN assets**: Monaco (and later mammoth/pdfjs/pixelmatch) are bundled - from npm at build time. `npm install` needs network on the *dev* machine - only; the packaged app does not. +- **No CDN assets**: Monaco (and mammoth for docx, fflate + saxen for xlsx) are + bundled from npm at build time. `npm install` needs network on the *dev* + machine only; the packaged app does not. - **No telemetry, no crash reporting, no auto-update** — none included, keep it that way. When adding any dependency, check it makes no network calls. @@ -290,5 +327,4 @@ Verification checklist before each release: the editor chunk. - **Very large files**: Monaco handles a few MB well; beyond that, consider a jsdiff + virtual-scroll fallback view. -- **macOS builds require macOS**: plan on GitHub Actions early if you don't - have a Mac available. + diff --git a/README.md b/README.md index 9a0739f..8781816 100644 --- a/README.md +++ b/README.md @@ -29,17 +29,28 @@ a hard promise: it never touches the network. - **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. +- **Excel (.xlsx) comparison** — a structured **grid** diff with sheet tabs and + cell / row-level highlighting, aligned so an inserted row doesn't cascade. + Parsed entirely offline by a small custom reader (no heavyweight dependency). - **Paste mode** for quick throwaway comparisons, including pasted text against a - real file. + real file — or just hit **Ctrl/Cmd+V** to paste straight into a comparison. - **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. + highlighting and live **Mermaid** diagram rendering, in a viewer you can drag + bigger from any corner (and that fills the window when the app goes fullscreen). - **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. +- **Yours to arrange** — eight themes (incl. Nord, Sepia, and a playful Nyan with + a reward cat), a sidebar whose sections you drag to + reorder (and can lock in place), and adjustable limits, all remembered between + sessions. + +

+ Two Excel files compared as aligned grids: changed cells boxed, a removed row and an added row shown as striped gaps, with per-sheet change counts +
Excel (.xlsx) files compared as aligned grids — changed cells boxed, added/removed rows aligned. +

@@ -52,6 +63,13 @@ a hard promise: it never touches the network.

Saved diffs are encrypted on-device and auto-expire.

+ + + +
+ The start screen listing supported file types: Excel, JSON, XML, YAML, CSV, Markdown, and any text or code file +

Drop or choose two files — Excel, JSON/XML, or any text.

+
## Download @@ -96,7 +114,12 @@ No local Node? The same flow runs in Docker: `make dev`, `make check`, ## Docs - [Architecture](docs/architecture.md) — processes, trust boundary, directory map. +- [IPC & security](docs/ipc-security.md) — how renderer↔main talk, and what the + sandbox blocks (with diagrams). - [Security model](docs/security.md) — offline guarantee, sharing, keys, backup. - [Packaging & releasing](docs/packaging.md) — installers, signing notes, CI. +- [Chocolatey release](docs/chocolatey.md) — plan + package skeleton for + `choco install diffbro`. +- [Glossary](docs/glossary.md) — every term and abbreviation (IPC, CSP, GCM, …). - Coding standards live in [CLAUDE.md](CLAUDE.md); roadmap in [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md). diff --git a/THEMES_TODO.md b/THEMES_TODO.md new file mode 100644 index 0000000..b8f5fd0 --- /dev/null +++ b/THEMES_TODO.md @@ -0,0 +1,96 @@ +# Themes — backlog + +Theme concepts to implement later. Design pitch (with live mockups): +https://claude.ai/code/artifact/7228542b-2337-4506-b3b4-aeeb957b3db1 + +## How to add a theme (for each below) + +1. Add a `:root[data-theme='']` block to `src/renderer/src/styles/themes.css` + redefining **every** palette token — nothing structural, so alignment/sizing + stay identical across themes. +2. Register it in `src/renderer/src/utils/themes.js` (`THEMES` array) with a + `swatch: { bg, accent, add, del }` for the Settings → Appearance preview. +3. Verify in **both** the diff view and the empty state, in the Docker env. +4. Monaco/Mermaid ground keys off `isDarkTheme(id)` — add the id there if it's a + dark theme. + +Palette token names: `--bg --bg-panel --bg-hover --border --text --text-dim +--text-hint --accent --warning-bg/border/text --danger-bg/border/text +--success-text --favorite --text-on-accent`. + +--- + +## Useful + +- ~~**Nord** `id: nord`~~ — ✅ **SHIPPED** (themes.css + themes.js). +- ~~**Sepia** `id: sepia`~~ — ✅ **SHIPPED** (deepened to a saturated parchment so + it stands apart from Solar's pale cream). + +--- + +## Retro (need a small per-skin extension beyond tokens) + +### Windows 98 `id: win98` (light) +Teal desktop, silver bevelled panels, navy title-bar gradient. +- bg `#008080` · panel `#c0c0c0` · hover `#d4d0c8` · border `#808080` +- text `#000000` · dim `#404040` · accent `#000080` +- success/add `#008000` · danger/del `#800000` · text-on-accent `#ffffff` +- **Extra:** bevel treatment (raised `#dfdfdf` top-left / `#808080` bottom-right) + on panels + buttons; square corners (radius 0); Tahoma/MS-Sans font stack. + Gate the bevels behind a `[data-theme='win98']` skin block so base components + are untouched. + +### Mac Platinum `id: platinum` (light) +System 7/8 grayscale, pinstripe chrome, near-monochrome. +- bg `#b8b8b8` · panel `#dcdcdc` · hover `#cfcfcf` · border `#808080` +- text `#101010` · dim `#555555` · accent `#303030` +- success/add `#2f7d4f` · danger/del `#b23a3a` +- **Extra:** pinstripe background (`repeating-linear-gradient`) on title bands; + Charcoal/Geneva font stack. + +--- + +## Novelty (opt-in, more work) + +### Phosphor CRT `id: crt` (dark) +Black ground, green phosphor text, faint scanlines + glow. +- bg `#050805` · panel `#0b120b` · hover `#10200f` · border `#154a15` +- text `#33ff66` · dim `#1f9a3f` · accent `#7dffa0` +- success/add `#b6ff00` · danger/del `#ff5f5f` +- **Extra:** a scanline overlay (`::after` repeating-linear-gradient, low + opacity) + `text-shadow` glow. Keep the overlay subtle (readability). Respect + `prefers-reduced-motion` if any flicker is added. `isDarkTheme` → true. + +### Nyan `id: nyan` (dark) — ✅ **SHIPPED** (Trail × Achievement combo) + +Built as the "Trail × Achievement" combo from the variants pitch +(https://claude.ai/code/artifact/0b961526-e8e7-4a9d-b05d-4337f30d66de): +a readable deep-violet base (chaos kept out of content), plus `NyanLane.vue` — a +slim rainbow lane under the toolbar (`App.vue`, nyan theme only) where the cat +streaks across trailing a smoke-dissipating rainbow on a "No differences!" match +or a saved diff (`useNyanReward` rising-edge trigger). Honors +`prefers-reduced-motion`. + +Per feedback: NOT the unusable full-screen scrolling-rainbow-puke from the pitch. +Keep a **readable base** so diffing actually works, and confine the chaos to +non-content chrome: +- **Readable dark base:** bg `#160a20` · panel `#231033` · hover `#2e1642` + · border `#5a2b7a` · text `#f4e9ff` · dim `#b79fcf`. +- **Playful accents:** accent `#ff2ecb` (hot pink) · success/add `#63ff4d` + (lime) · danger/del `#ff5470`. +- **Where the fun lives (distracting-but-usable):** an animated rainbow accent + stripe on the toolbar band, and the Nyan cat + rainbow **only in the empty + state** (the `SupportedFormats` / "drop two files" area) — never over the diff + panes, so content stays legible. +- **Extra:** the Nyan cat (inline SVG) + rainbow trail keyframes from the pitch, + scoped to `[data-theme='nyan'] .empty`; MUST honor `prefers-reduced-motion` + (park the cat, freeze the rainbow). `isDarkTheme` → true. + +--- + +## Suggested order + +1. ~~**Nord**, **Sepia**~~ — ✅ shipped. +2. **Win98**, **Platinum** — add the shared per-skin bevel/pinstripe mechanism. +3. **CRT**, **Nyan** — the overlay + animation layer (and a stern review for Nyan). + See the expanded Nyan variant ideas artifact linked at the top. diff --git a/docs/chocolatey.md b/docs/chocolatey.md new file mode 100644 index 0000000..3b03be0 --- /dev/null +++ b/docs/chocolatey.md @@ -0,0 +1,193 @@ +# Releasing DiffBro on Chocolatey (Windows) + +**Chocolatey** (`choco`) is a Windows package manager — the closest thing to +Homebrew for Windows. Once published, users install with: + +```powershell +choco install diffbro +``` + +This is an **investigation + a ready-to-fill package skeleton**, not a shipped +package yet. DiffBro is a good fit because it already produces an **NSIS +installer** (`npm run build:win`) and attaches it to a **GitHub Release** via +`.github/workflows/release.yml` — Chocolatey's standard pattern is to download an +installer from a URL and verify its checksum, which maps onto that exactly. + +--- + +## Two ways to distribute + +| | Community repository | Your own feed | +|---|---|---| +| URL | `community.chocolatey.org` | Cloudsmith / GitHub Packages / MyGet / self-host | +| Audience | `choco install diffbro` works for everyone | users must `--source ` first | +| Cost | free | free tier / paid | +| Gate | **moderation** + automated validator & verifier | none | +| Effort | higher (strict rules, review wait) | low | + +**Recommendation:** start with a **private/Cloudsmith feed** to prove the package +installs and uninstalls cleanly, then submit the *same* package to the community +repo once it passes locally. The community repo is where "brew-like" discovery +happens, but its moderation is strict and first-time packages get extra scrutiny. + +--- + +## Package anatomy + +A Chocolatey package is a `.nupkg` (a zip) built from a `.nuspec` plus a `tools/` +folder. Proposed layout, checked into `chocolatey/`: + +``` +chocolatey/ + diffbro.nuspec + tools/ + chocolateyInstall.ps1 + chocolateyUninstall.ps1 + VERIFICATION.txt # provenance of the downloaded binary (community repo) + LICENSE.txt # copy of the app license (community repo) +``` + +### `diffbro.nuspec` + +The community validator requires the metadata URLs (rule CPMR0040 flags a missing +`packageSourceUrl`, etc.). Fill these in: + +```xml + + + + diffbro + 0.1.0 + https://github.com/mindaugaskasp/diff-bro/tree/main/chocolatey + mindaugaskasp + Diff Bro + Mindaugas Kasparavičius + https://github.com/mindaugaskasp/diff-bro + https://github.com/mindaugaskasp/diff-bro + https://github.com/mindaugaskasp/diff-bro/issues + https://github.com/mindaugaskasp/diff-bro/blob/main/README.md + https://rawcdn.githack.com/mindaugaskasp/diff-bro/main/resources/icon.png + https://github.com/mindaugaskasp/diff-bro/blob/main/LICENSE + false + diff compare offline electron json xml excel diff-tool + Offline-only desktop diff viewer with GitHub-style rendering. + Diff Bro compares text, code, JSON/XML, and Excel (.xlsx) files + fully offline — no network, no telemetry. Encrypted saved diffs, sealed + sharing, snippets, and Mermaid rendering. + https://github.com/mindaugaskasp/diff-bro/releases/tag/v0.1.0 + + + + + +``` + +### `tools/chocolateyInstall.ps1` + +Download the NSIS installer from the GitHub Release and verify its SHA-256. NSIS +(electron-builder) installs silently with `/S`. **UTF-8 with BOM** is required. + +```powershell +$ErrorActionPreference = 'Stop' +$version = '0.1.0' +$url64 = "https://github.com/mindaugaskasp/diff-bro/releases/download/v$version/Diff-Bro-$version-setup.exe" + +$packageArgs = @{ + packageName = 'diffbro' + fileType = 'exe' + url64bit = $url64 + # sha256 of the exact released .exe — paste from the release checksums + checksum64 = 'REPLACE_WITH_SHA256' + checksumType64 = 'sha256' + silentArgs = '/S' # NSIS silent + validExitCodes = @(0) + softwareName = 'Diff Bro*' +} +Install-ChocolateyPackage @packageArgs +``` + +### `tools/chocolateyUninstall.ps1` + +```powershell +$ErrorActionPreference = 'Stop' +$key = Get-UninstallRegistryKey -SoftwareName 'Diff Bro*' +if ($key) { + Uninstall-ChocolateyPackage -PackageName 'diffbro' -FileType 'exe' ` + -SilentArgs '/S' -File "$($key.UninstallString)" +} +``` + +--- + +## What DiffBro needs to change + +1. **Deterministic installer filename + checksum in the release.** The nuspec + points at a fixed URL, so `release.yml` should upload a predictably-named + `Diff-Bro--setup.exe` **and** publish its SHA-256 (electron-builder + emits a `latest.yml`/`.blockmap`; add a checksums step or read from there). +2. **Confirm silent + machine-wide install.** electron-builder's default NSIS is + a per-user one-click install. Chocolatey usually installs machine-wide (admin + shell), so set in `electron-builder.yml`: + ```yaml + nsis: + oneClick: true + perMachine: true # install for all users (choco runs elevated) + allowElevation: true + ``` + Verify `Setup.exe /S` installs and the uninstall string supports `/S`. +3. **Package source in-repo.** Add the `chocolatey/` folder above so the + community repo's `packageSourceUrl` resolves. + +--- + +## Community-repo moderation checklist + +Every version is auto-**validated** (metadata rules), auto-**verified** (test +install + uninstall on a clean Windows VM), then human-**moderated**: + +- [ ] All metadata URLs present (project, source, bugtracker, docs, license, icon, + `packageSourceUrl`). +- [ ] `id` lowercase; `` is the real app name. +- [ ] Files UTF-8; `.ps1` UTF-8 **with BOM**. +- [ ] Downloaded binary has `checksum64` + `checksumType64`. +- [ ] `tools/VERIFICATION.txt` explaining how a moderator can reproduce the + checksum (download URL + `Get-FileHash`), and `LICENSE.txt`. +- [ ] Silent install/uninstall exits `0` on the verifier VM. +- [ ] You're the software author (you are) — no third-party-copyright friction. + +Publish with an account + API key: + +```powershell +choco pack chocolatey\diffbro.nuspec +choco push diffbro.0.1.0.nupkg --source https://push.chocolatey.org/ --api-key <KEY> +``` + +--- + +## Keeping it current (optional) + +Community maintainers use the **Chocolatey-AU** PowerShell module: an `update.ps1` +watches the GitHub Releases API, rewrites the version + URL + checksum, packs, and +pushes on each new tag. This can run as a scheduled GitHub Action so a new +DiffBro release lands on Chocolatey automatically. + +--- + +## Effort & open questions + +- **Effort:** ~half a day to author + test the package on a Windows VM against a + private feed; community-repo submission adds review wait (days) and any + validator fixes. AU automation ~half a day more. +- **Open questions to decide first:** + - **Code signing.** Chocolatey doesn't require it, but an unsigned NSIS + installer still trips SmartScreen; signing (OV cert ~€70–200/yr or Azure + Trusted Signing) is the real fix and separate from packaging. + - **Per-user vs per-machine** install (see step 2) — pick one and make the + NSIS config + uninstall match. + - **winget** — worth doing alongside choco; it's Microsoft's built-in manager + and uses a similar "installer URL + SHA256" manifest, so most of the work + above (deterministic filename + published checksum + silent flags) is shared. + +**Sources:** [Create Packages](https://docs.chocolatey.org/en-us/create/create-packages/) · +[Package validator rule CPMR0040](https://docs.chocolatey.org/en-us/community-repository/moderation/package-validator/rules/cpmr0040/) · +[electron-app packages on the community repo](https://community.chocolatey.org/packages?q=tag:electron-app) diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..83b106e --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,113 @@ +# DiffBro — glossary + +Plain-language definitions of the terms and abbreviations used across the code, +comments, and the other docs. Grouped by area. If an entry names a file, that's +where the concept lives in this repo. + +## Electron & process model + +- **Main process** — the trusted Node.js process. Has `fs`, `crypto`, dialogs, + and the OS keychain. All privileged work happens here (`src/main/`). +- **Renderer process** — the sandboxed UI process (Chromium + Vue). No Node, no + `fs`, no network. Treated as untrusted (`src/renderer/`). +- **Preload** — a small script that runs in the renderer with limited bridge + access and exposes `window.api`, the *only* channel to main + (`src/preload/index.js`). +- **IPC** — *Inter-Process Communication.* Named message channels the two + processes talk over: `ipcMain.handle('channel', …)` in main, + `ipcRenderer.invoke('channel', …)` from preload. See + [ipc-security.md](ipc-security.md). +- **contextIsolation** — Electron setting that keeps the preload's and page's + JavaScript worlds separate, so a page can't reach Electron internals. +- **sandbox** — OS-level process sandbox for the renderer; blocks direct system + access even if the page is compromised. +- **contextBridge** — the Electron API the preload uses to expose a safe, + frozen `window.api` object to the page. + +## Security + +- **CSP** — *Content Security Policy.* A page-level allowlist (`connect-src + 'self'`, `object-src 'none'`) that blocks outbound requests and plugins — the + second layer behind the network kill switch. +- **Kill switch** — `webRequest.onBeforeRequest` handler that cancels every + network request that isn't `file:`/`blob:`/`data:` (`src/main/security.js`). +- **safeStorage** — Electron's OS-backed secret store (Keychain on macOS, DPAPI + on Windows, libsecret on Linux). Encrypts keys at rest. +- **DPAPI** — *Data Protection API*, the Windows secret-encryption service + `safeStorage` uses. +- **XXE** — *XML External Entity* attack: a crafted XML `DOCTYPE` that reads + local files or expands recursively ("billion laughs"). Rejected outright by + the `.xlsx` reader. +- **ReDoS** — *Regular-expression Denial of Service*: a pattern that takes + exponential time on crafted input. Why the diff-search regex is length- and + complexity-limited, and one of the SheetJS CVEs we avoided. +- **Prototype pollution** — an attack that writes to `Object.prototype` via + attacker-controlled keys (`__proto__`), poisoning every object. The `.xlsx` + reader uses `Map`/`Object.create(null)` to prevent it. +- **Decompression bomb** — a tiny archive that inflates to something enormous. + The `.xlsx` reader caps input, per-entry, total, and ratio. +- **Provenance allowlist** — main only reads a file path the user actually + chose (dialog or real drop); a path the renderer invents is refused + (`src/main/files.js`). + +## Cryptography (sharing & vault) + +- **AES-256-GCM** — the symmetric cipher used for saved diffs and shared files. + *GCM* (Galois/Counter Mode) is authenticated: tampering fails the tag. +- **AAD** — *Additional Authenticated Data.* Bytes covered by the GCM tag but + not encrypted (e.g. an entry's metadata), so editing them voids the entry. +- **Ed25519** — the elliptic-curve signature scheme; a shared file is *signed* + by the sender. +- **X25519** — the elliptic-curve key-agreement scheme; used for **ECDH**. +- **ECDH** — *Elliptic-Curve Diffie–Hellman*, deriving a shared secret between + sender and recipient without transmitting a key. +- **HKDF** — *HMAC-based Key Derivation Function*, turns the ECDH secret (plus a + random salt) into the actual AES key. +- **Sign-then-encrypt** — the sealing order: sign the payload, then encrypt, so + the ciphertext reveals nothing and only the addressed recipient can open it + (`src/main/sealing.js`). +- **Fingerprint** — a short hash of a public key, recomputed on import to + identify a trusted peer. + +## File formats & parsing + +- **Adapter** — a small module turning a raw file into a `{ kind, … }` + **comparable** the viewer understands (`src/renderer/src/adapters/`). +- **Comparable** — the normalized shape a viewer renders: `{ kind:'text', … }` + or `{ kind:'spreadsheet', … }`. +- **OOXML** — *Office Open XML*, the `.xlsx`/`.docx` format: a ZIP archive of + XML parts. +- **SAX** — *Simple API for XML*, a streaming parser that fires events per tag + instead of building a whole DOM tree (the `saxen` library). +- **DEFLATE** — the compression algorithm inside ZIP (the `fflate` library). +- **Shared strings** — an `.xlsx` de-duplicated text table (`sharedStrings.xml`) + that cells reference by index. +- **LCS** — *Longest Common Subsequence*, the classic diff algorithm; used to + align spreadsheet rows and to build the copy-as-patch output. +- **Monaco** — the VS Code editor component, used for the text diff view. +- **Mermaid** — the text-to-diagram library used to render `mermaid` snippets. + +## Packaging & distribution + +- **NSIS** — *Nullsoft Scriptable Install System*, the Windows `.exe` installer + electron-builder produces. +- **DMG** — the macOS disk-image install format. +- **AppImage / .deb** — the two Linux distribution formats built. +- **Notarization** — Apple's malware-scan step that clears the Gatekeeper + warning on macOS. +- **SmartScreen** — Windows' reputation check that warns on unsigned installers. +- **Chocolatey (choco)** — a Windows package manager (a Homebrew equivalent); + see [packaging.md](packaging.md) for release notes. + +## Project conventions + +- **Band / band-row** — a full-width horizontal strip that vertically centres + its content with flexbox and shares a height with its peers, so nothing + drifts (`styles/ui.css`). +- **Token** — a design-system variable (color, radius, type size, spacing) in + `styles/tokens.css` / `themes.css`; hardcoded literals are rejected by + `scripts/check-style-tokens.mjs`. +- **Vault** — the encrypted local store of saved diffs. +- **Sealing** — producing a shareable, signed-and-encrypted `.diffbro` file. +- **Comparable kind** — `text` vs `spreadsheet`; the content router picks the + viewer from it. diff --git a/docs/ipc-security.md b/docs/ipc-security.md new file mode 100644 index 0000000..3b113e5 --- /dev/null +++ b/docs/ipc-security.md @@ -0,0 +1,173 @@ +# DiffBro — IPC & security architecture + +DiffBro is an **offline-only** desktop app built on Electron. Its whole security +posture rests on one idea: **the renderer (the UI) is treated as hostile.** If a +bug or a malicious diff ever ran code in the renderer, it must not be able to +read your keys, touch arbitrary files, or send anything over the network. + +Electron runs two separate OS processes, and everything below is about the wall +between them: + +- **Main process** — trusted. Has Node, `fs`, `crypto`, dialogs, the OS + keychain. All real work happens here. +- **Renderer process** — sandboxed and untrusted. Vue + Pinia only. No Node, no + Electron, no `fs`, no network. + +They cannot call each other's functions directly. They communicate **only** over +**IPC** (Inter-Process Communication) — named message channels — and the renderer +can reach those channels **only** through a tiny, fixed bridge that the preload +script exposes as `window.api`. + +--- + +## The processes and the bridge + +```mermaid +flowchart LR + subgraph REND["RENDERER — sandboxed, untrusted"] + UI["Vue components & Pinia stores<br/>no Node · no Electron · no fs · no network"] + end + + subgraph PRELOAD["PRELOAD — contextBridge"] + API["window.api<br/>the ONLY surface the renderer can call"] + end + + subgraph MAIN["MAIN PROCESS — trusted (Node · fs · crypto)"] + IPC["ipcMain handlers<br/>validate every call"] + FILES["files.js<br/>path allowlist + per-type size caps"] + XLSX["xlsx reader<br/>bomb caps · no formulas · DOCTYPE reject"] + VAULT["vaultCrypt / sealing<br/>AES-256-GCM · Ed25519"] + LOGGER["logger.js<br/>local daily error log"] + SEC["security.js<br/>kill switch · deny-all perms · will-navigate"] + end + + subgraph OS["OS resources"] + DISK[("Disk & userData<br/>files · settings · logs")] + KEY[("OS keychain<br/>safeStorage")] + NET(("Network")) + end + + UI -->|"window.api.foo()"| API + API -->|"ipcRenderer.invoke('channel')"| IPC + IPC --> FILES + IPC --> XLSX + IPC --> VAULT + IPC --> LOGGER + FILES --> DISK + LOGGER --> DISK + VAULT --> KEY + SEC -->|"cancels every non-file/blob/data request"| NET +``` + +Every arrow the renderer participates in goes **through `window.api` → IPC**. +There is no other door. + +--- + +## What is blocked, and where + +These are the non-negotiables from `CLAUDE.md`, and the file that enforces each: + +| Guard | What it stops | Where | +|---|---|---| +| **Sandbox + `contextIsolation`** | Renderer has no Node/Electron globals; can't `require('fs')` | `window.js` (webPreferences) | +| **Network kill switch** | `webRequest.onBeforeRequest` cancels every request that isn't `file:`/`blob:`/`data:` (fetch, XHR, images, workers…) | `security.js` | +| **CSP** | `connect-src 'self'`, `object-src 'none'` — second layer against outbound requests | renderer CSP meta | +| **Deny-all permission handler** | `setPermissionRequestHandler` rejects camera, clipboard-read, geolocation, everything | `security.js` | +| **`will-navigate` / `setWindowOpenHandler`** | Renderer can't navigate away or open external windows | `security.js` | +| **Path provenance allowlist** | `file:read` only serves a path the user actually picked or dropped — not one the renderer invents | `files.js` | +| **Keys never cross IPC** | Vault/identity keys stay behind `safeStorage`; only ciphertext is ever returned | `vault.js`, `vaultCrypt.js` | +| **Untrusted-input caps** | Import files get size caps, shape validation, recomputed fingerprints; `.xlsx` gets decompression-bomb caps and a cell budget | `files.js`, `xlsx/*`, `share.js` | +| **No injection sinks** | `v-html`, `eval`, `new Function`, `innerHTML` are ESLint-banned | `eslint.config.mjs` | + +The renderer **cannot**: read a file by path it made up, obtain a private key, +evaluate a spreadsheet formula, or make a network request. Each of those is +either impossible (no API for it) or actively cancelled. + +--- + +## Representative flows + +### 1. Opening a file to diff (incl. `.xlsx`) + +The renderer never handles a filesystem path it could forge — main resolves the +path from a real dialog/drop, records it in an allowlist, then reads it. + +```mermaid +sequenceDiagram + autonumber + participant R as Renderer (sandbox) + participant P as Preload (window.api) + participant M as Main (files.js) + participant D as Disk + + R->>P: window.api.openFile('left') + P->>M: invoke('file:open') + M->>M: showOpenDialog → allow(path) + M->>D: read bytes (size-capped per file type) + alt looks like .xlsx (PK zip magic) + M->>M: readXlsx — bomb caps, no formulas, reject DOCTYPE + M-->>R: { kind:'spreadsheet', sheets } or { error:'xlsx' } + else text + M->>M: detect encoding, decode + M-->>R: { name, content } or { error:'binary' } + end + Note over R: renderer received data only — never a raw path primitive +``` + +### 2. Vault crypto — the key never leaves main + +Saved diffs are encrypted, but the renderer only ever sees ciphertext. The key +is unlocked from the OS keychain inside main and never crosses the bridge. + +```mermaid +sequenceDiagram + autonumber + participant R as Renderer + participant M as Main (vault.js) + participant K as OS keychain (safeStorage) + + R->>M: invoke('vault:encrypt', plaintext, aad) + M->>K: unlock the per-install vault key + M->>M: AES-256-GCM (metadata bound as AAD) + M-->>R: { iv, data } + Note over R,M: only ciphertext returns — there is no IPC channel that hands back key material +``` + +### 3. Local error logging (this feature) + +Uncaught errors are written to a **local, daily-rotated** file so you can paste +it into a bug report. Nothing is transmitted — that would violate the offline +guarantee. The renderer forwards a small record; main does all the fs work. + +```mermaid +sequenceDiagram + autonumber + participant R as Renderer (errorStore) + participant M as Main (logger.js) + participant D as Log dir (configurable) + + Note over R: window.onerror / unhandledrejection / Vue errorHandler + R->>M: invoke('log:error', { message, stack, context }) + M->>M: add app version+platform, redact home dir → ~ + M->>M: format entry, cap the day's file, pick diffbro-YYYY-MM-DD.log + M->>D: append (prune files older than 7 days) + Note over M,D: LOCAL ONLY — never sent anywhere + R->>M: (from the dialog) log:read → copy to clipboard, or reportIssue → open GitHub in OS browser +``` + +Even the "Report on GitHub" action doesn't send anything from the app: it hands a +**fixed** issue URL to the OS browser (the only outward link, chosen in main — +the renderer can only trigger it, never choose the address). + +--- + +## Why the renderer is untrusted + +A diff tool opens files from anywhere and, in future, renders rich content +(Mermaid, spreadsheets). Any of that could carry a payload that executes in the +renderer. By assuming that has already happened, the boundary above means the +worst a compromised renderer can do is misbehave **inside its sandbox** — it +still can't reach your keys, your unrelated files, or the network. That is the +whole point of keeping every privileged capability behind a small, validated set +of IPC handlers. diff --git a/docs/screenshots/empty-state.png b/docs/screenshots/empty-state.png new file mode 100644 index 0000000..7416960 Binary files /dev/null and b/docs/screenshots/empty-state.png differ diff --git a/docs/screenshots/spreadsheet-diff.png b/docs/screenshots/spreadsheet-diff.png new file mode 100644 index 0000000..03b91a3 Binary files /dev/null and b/docs/screenshots/spreadsheet-diff.png differ diff --git a/e2e/copy-diff.spec.mjs b/e2e/copy-diff.spec.mjs index 5a23f5d..08f0e43 100644 --- a/e2e/copy-diff.spec.mjs +++ b/e2e/copy-diff.spec.mjs @@ -39,9 +39,10 @@ test('Copy diff writes a unified patch to the OS clipboard', async ({ app, page 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() + // The "no differences" note is a row label over the diff panes, not a toolbar + // stat — and the toolbar shows no empty +0/−0 counts in that state. + await expect(page.locator('.diff-viewer .identical-row')).toContainText('No differences') + await expect(page.locator('.stats')).toBeHidden() // Copy diff on an identical comparison explains itself rather than copying an // empty patch. diff --git a/e2e/empty-text.spec.mjs b/e2e/empty-text.spec.mjs new file mode 100644 index 0000000..4f10338 --- /dev/null +++ b/e2e/empty-text.spec.mjs @@ -0,0 +1,24 @@ +import { test, expect } from './fixtures.mjs' + +// Each sidebar section shows an empty-state helper paragraph that must vanish the +// moment the section holds a real record — the "Saved diffs" half of that guard +// (the "External diffs" half rides on the peer import in sharing.spec.mjs, where +// a real shared diff actually lands). Store-getter logic drives the v-if, but +// this pins the observable behaviour so a future getter tweak can't leave stale +// help text sitting above the user's own diffs. +test('the Saved diffs helper text disappears after saving one diff', async ({ page }) => { + const emptyText = page.getByText('Nothing saved. Load two files') + await expect(emptyText).toBeVisible() // fresh install + + 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('E2E empty-text diff') + await dialog.getByRole('button', { name: 'Save', exact: true }).click() + + await expect(page.getByText('E2E empty-text diff')).toBeVisible() + await expect(emptyText).toBeHidden() // gone now that a diff is filed +}) diff --git a/e2e/format-hint.spec.mjs b/e2e/format-hint.spec.mjs index fa77b4e..5c4d9ad 100644 --- a/e2e/format-hint.spec.mjs +++ b/e2e/format-hint.spec.mjs @@ -2,7 +2,8 @@ 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. +// proves the single merged banner renders above the diff, formats (one side or +// both), and then clears itself — never two stacked strips. async function pasteCompare(page, left, right) { await page.getByRole('button', { name: 'Paste text' }).click() await page.getByPlaceholder('Paste original text here').fill(left) @@ -26,6 +27,22 @@ test('minified JSON offers to format, and formatting clears the banner', async ( await expect(page.locator('div.hint')).toBeHidden() }) +test('two minified sides show ONE merged banner and Format both cleans them up', async ({ + page +}) => { + await pasteCompare(page, '{"b":2,"a":1}', '{"d":4,"c":3}') + + // The whole point of the merge: a single banner, never two stacked strips. + const banner = page.locator('div.hint') + await expect(banner).toHaveCount(1) + await expect(banner).toContainText('Both sides look like JSON') + + await banner.getByRole('button', { name: 'Format both' }).click() + await expect(page.getByText('"a": 1').first()).toBeVisible() + await expect(page.getByText('"c": 3').first()).toBeVisible() + await expect(page.locator('div.hint')).toBeHidden() // both pretty → banner clears +}) + 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') diff --git a/e2e/mermaid-viewer.spec.mjs b/e2e/mermaid-viewer.spec.mjs new file mode 100644 index 0000000..687f363 --- /dev/null +++ b/e2e/mermaid-viewer.spec.mjs @@ -0,0 +1,51 @@ +import { test, expect } from './fixtures.mjs' + +// Geometry of the resizable Mermaid viewer — the parts jsdom can't measure (no +// layout). The window's own maximize follows OS fullscreen, and the panel can be +// dragged bigger from any corner. Both ride on the seeded example diagram. + +async function openViewer(page) { + const row = page.getByText('Example — Mermaid diagram') + await expect(row).toBeVisible() + await row.hover() // row actions reveal on hover + await page.getByRole('button', { name: 'View diagram' }).click() + const panel = page.locator('.viewer-backdrop .panel') + await expect(panel).toBeVisible() + return panel +} + +test('the viewer fills the window when the app enters fullscreen', async ({ app, page }) => { + const panel = await openViewer(page) + const before = await panel.boundingBox() + const innerW = await page.evaluate(() => window.innerWidth) + expect(before.width).toBeLessThan(innerW * 0.95) // starts windowed + + // window.js pushes `window:fullscreen` on enter-full-screen. Xvfb can't truly + // fullscreen, so drive the exact same main→renderer signal. + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].webContents.send('window:fullscreen', true) + }) + + await expect.poll(async () => (await panel.boundingBox()).width).toBeGreaterThan(before.width) + const after = await panel.boundingBox() + expect(after.width).toBeGreaterThanOrEqual(innerW * 0.9) +}) + +test('the viewer resizes by dragging its SE corner, pinning the NW corner', async ({ page }) => { + const panel = await openViewer(page) + const before = await panel.boundingBox() + + const handle = page.locator('.resize-handle.se') + const h = await handle.boundingBox() + await page.mouse.move(h.x + h.width / 2, h.y + h.height / 2) + await page.mouse.down() + await page.mouse.move(h.x + h.width / 2 + 90, h.y + h.height / 2 + 70, { steps: 8 }) + await page.mouse.up() + + const after = await panel.boundingBox() + expect(after.width).toBeGreaterThan(before.width + 60) + expect(after.height).toBeGreaterThan(before.height + 50) + // The opposite (NW) corner holds still while the SE corner is dragged out. + expect(Math.abs(after.x - before.x)).toBeLessThan(3) + expect(Math.abs(after.y - before.y)).toBeLessThan(3) +}) diff --git a/e2e/section-reorder.spec.mjs b/e2e/section-reorder.spec.mjs new file mode 100644 index 0000000..b79a3af --- /dev/null +++ b/e2e/section-reorder.spec.mjs @@ -0,0 +1,58 @@ +import { rmSync } from 'node:fs' +import { test, expect, launchApp, freshUserDataDir, firstReadyPage } from './fixtures.mjs' + +// The sidebar sections (Saved / External / Snippets) reorder by dragging a whole +// header, or via the per-header up/down steppers, and a single toolbar lock +// freezes the arrangement. The reorder maths and the drag guard are unit-tested; +// what only a real launch proves is that a drag actually moves a section and +// that the order + lock survive the store's file-backed persist across a +// relaunch — the seam jsdom can't see. + +const titles = (page) => page.locator('.section-head .section-title').allTextContents() +const header = (page, name) => page.locator('.section-head', { hasText: name }) + +test('reorders sections with the steppers', async ({ page }) => { + await expect(page.locator('.section-head').first()).toBeVisible() + expect(await titles(page)).toEqual(['Saved diffs', 'External diffs', 'Snippets']) + + await header(page, 'Snippets').getByRole('button', { name: 'Move section up' }).click() + expect(await titles(page)).toEqual(['Saved diffs', 'Snippets', 'External diffs']) +}) + +test('reorders sections by dragging a whole header', async ({ page }) => { + await expect(page.locator('.section-head').first()).toBeVisible() + // Drag "Snippets" (last) up onto "Saved diffs" (first) — it lands before it. + await header(page, 'Snippets').dragTo(header(page, 'Saved diffs')) + expect(await titles(page)).toEqual(['Snippets', 'Saved diffs', 'External diffs']) +}) + +test('the toolbar lock freezes reordering and both survive a relaunch', async () => { + const userDataDir = freshUserDataDir() + try { + let app = await launchApp(userDataDir) + let page = await firstReadyPage(app) + const lock = () => page.getByTitle('Lock sidebar section order') + const unlock = () => page.getByTitle('Unlock sidebar section order') + const saved = () => header(page, 'Saved diffs') + + // Unlocked: the header is a drag handle and carries the steppers. + await expect(saved()).toHaveAttribute('draggable', 'true') + await expect(page.locator('.section-head .reorder-btn').first()).toBeVisible() + + // Lock from the toolbar: steppers vanish and the headers stop being draggable. + await lock().click() + await expect(page.locator('.section-head .reorder-btn')).toHaveCount(0) + await expect(saved()).toHaveAttribute('draggable', 'false') + await app.close() + + // Relaunch the same profile: the lock is remembered, so the sidebar stays put. + app = await launchApp(userDataDir) + page = await firstReadyPage(app) + await expect(saved()).toHaveAttribute('draggable', 'false') // still locked + await unlock().click() + await expect(saved()).toHaveAttribute('draggable', 'true') // unlock restores it + await app.close() + } finally { + rmSync(userDataDir, { recursive: true, force: true }) + } +}) diff --git a/e2e/settings.spec.mjs b/e2e/settings.spec.mjs index ae77ad9..1394dbe 100644 --- a/e2e/settings.spec.mjs +++ b/e2e/settings.spec.mjs @@ -18,9 +18,13 @@ test.describe('Settings domain panes', () => { await expect(page.getByRole('heading', { name: 'Data folder' })).toBeVisible() await expect(page.getByTitle('Use the Light theme')).toBeHidden() - // Limits pane. + // Limits pane (per-file-type size caps). await page.getByRole('button', { name: 'Limits', exact: true }).click() - await expect(page.getByText('Max comparison file (MB)')).toBeVisible() + await expect(page.getByText('Max Spreadsheet (.xlsx) file (MB)')).toBeVisible() await expect(page.getByRole('heading', { name: 'Data folder' })).toBeHidden() + + // Logs pane. + await page.getByRole('button', { name: 'Logs', exact: true }).click() + await expect(page.getByRole('heading', { name: 'Logs' })).toBeVisible() }) }) diff --git a/e2e/sharing.spec.mjs b/e2e/sharing.spec.mjs index fb56ff0..dcf45a1 100644 --- a/e2e/sharing.spec.mjs +++ b/e2e/sharing.spec.mjs @@ -89,11 +89,14 @@ test('two peers exchange keys, share a sealed diff, and import it', async () => expect(sealed).toBeTruthy() // 4. Bob imports it — it lands in External diffs, credited to Alice. + const external = pageB.locator('.sidebar-section', { hasText: 'External diffs' }) + const externalHelp = external.getByText(/Diffs shared by someone else appear here/) + await expect(externalHelp).toBeVisible() // empty-state help, before any import 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() + await expect(externalHelp).toBeHidden() // the help text clears once a diff is there // 5. Manage trusted keys on Alice: rename Bob, then remove him. await openMenu(pageA, 'Security', 'Manage Trusted Keys') diff --git a/e2e/spreadsheet.spec.mjs b/e2e/spreadsheet.spec.mjs new file mode 100644 index 0000000..0030b0d --- /dev/null +++ b/e2e/spreadsheet.spec.mjs @@ -0,0 +1,100 @@ +import { test, expect, stubOpenDialog } from './fixtures.mjs' +import { zipSync, strToU8 } from 'fflate' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// The spreadsheet path can only be proven by a real launch: .xlsx is a zip that +// the main process must parse to a grid (src/main/xlsx), and the grid viewer +// needs real layout — neither the main-process parse nor the two aligned grids +// exist in a jsdom unit test. So build genuine .xlsx files on disk, open them +// through the (stubbed) native dialog, and assert the grid diff renders. + +const XML = '<?xml version="1.0" encoding="UTF-8"?>' +const inlineStr = (ref, text) => `<c r="${ref}" t="inlineStr"><is><t>${text}</t></is></c>` +const num = (ref, v) => `<c r="${ref}"><v>${v}</v></c>` + +function buildXlsx(rowsXml) { + const files = { + 'xl/workbook.xml': + `${XML}<workbook xmlns:r="r"><sheets>` + + '<sheet name="Budget" sheetId="1" r:id="rId1"/></sheets></workbook>', + 'xl/_rels/workbook.xml.rels': + `${XML}<Relationships><Relationship Id="rId1" ` + + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' + + 'Target="worksheets/sheet1.xml"/></Relationships>', + 'xl/worksheets/sheet1.xml': `${XML}<worksheet><sheetData>${rowsXml}</sheetData></worksheet>` + } + const map = {} + for (const [k, v] of Object.entries(files)) map[k] = strToU8(v) + return Buffer.from(zipSync(map)) +} + +// Left: North 120, plus a West row. Right: North 150 (changed cell), West +// replaced by Central (one removed + one added row). +const LEFT = buildXlsx( + `<row r="1">${inlineStr('A1', 'Region')}${inlineStr('B1', 'Q1')}${inlineStr('C1', 'Q2')}</row>` + + `<row r="2">${inlineStr('A2', 'North')}${num('B2', 100)}${num('C2', 120)}</row>` + + `<row r="3">${inlineStr('A3', 'West')}${num('B3', 50)}${num('C3', 60)}</row>` +) +const RIGHT = buildXlsx( + `<row r="1">${inlineStr('A1', 'Region')}${inlineStr('B1', 'Q1')}${inlineStr('C1', 'Q2')}</row>` + + `<row r="2">${inlineStr('A2', 'North')}${num('B2', 100)}${num('C2', 150)}</row>` + + `<row r="3">${inlineStr('A3', 'Central')}${num('B3', 40)}${num('C3', 55)}</row>` +) + +test('opens two .xlsx files and renders the aligned grid diff', async ({ app, page }) => { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-xlsx-')) + const leftPath = join(dir, 'budget-left.xlsx') + const rightPath = join(dir, 'budget-right.xlsx') + writeFileSync(leftPath, LEFT) + writeFileSync(rightPath, RIGHT) + + try { + // Open left, then right, through the real file:open -> readXlsx path. + await stubOpenDialog(app, [leftPath]) + await page.locator('.slot[data-side="left"]').click() + await expect(page.locator('.slot[data-side="left"] .name')).toHaveText('budget-left.xlsx') + + await stubOpenDialog(app, [rightPath]) + await page.locator('.slot[data-side="right"]').click() + + // The grid viewer took over (not Monaco): its sheet tab is present. + const tab = page.locator('.sheet-tabs .tab', { hasText: 'Budget' }) + await expect(tab).toBeVisible() + // Three total changes (1 changed cell-row + 1 added + 1 removed) on the tab. + await expect(tab.locator('.badge')).toHaveText('3') + + // Both grids painted: the old and new values of the changed cell are shown. + await expect(page.locator('.grid td.cell-chg', { hasText: '120' })).toBeVisible() + await expect(page.locator('.grid td.cell-chg', { hasText: '150' })).toBeVisible() + + // The removed and added rows each render with their own row state. + await expect(page.locator('.grid tr.removed', { hasText: 'West' })).toBeVisible() + await expect(page.locator('.grid tr.added', { hasText: 'Central' })).toBeVisible() + + // The status strip summarises the same counts the tab badge implies. + await expect(page.locator('.status .chg')).toHaveText('◆ 1 changed') + await expect(page.locator('.status .add')).toHaveText('+1 rows') + await expect(page.locator('.status .del')).toHaveText('−1 rows') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('rejects a corrupt .xlsx with a notice instead of loading it', async ({ app, page }) => { + const dir = mkdtempSync(join(tmpdir(), 'diffbro-xlsx-')) + const badPath = join(dir, 'broken.xlsx') + // Valid zip magic so it reaches the parser, but not a real workbook. + writeFileSync(badPath, Buffer.from(zipSync({ 'junk.txt': strToU8('nope') }))) + + try { + await stubOpenDialog(app, [badPath]) + await page.locator('.slot[data-side="left"]').click() + // Nothing loads into the slot; a notice explains why. + await expect(page.locator('.notice')).toContainText('broken.xlsx') + await expect(page.locator('.slot[data-side="left"] .name')).toHaveCount(0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/e2e/trusted-keys.spec.mjs b/e2e/trusted-keys.spec.mjs new file mode 100644 index 0000000..0355ee9 --- /dev/null +++ b/e2e/trusted-keys.spec.mjs @@ -0,0 +1,78 @@ +import { rmSync, writeFileSync } from 'node:fs' +import { randomBytes } from 'node:crypto' +import { join } from 'node:path' +import { test, expect, launchApp, freshUserDataDir, firstReadyPage, openMenu } from './fixtures.mjs' + +// The single-key trust round-trip is covered in sharing.spec. This one stresses +// the *manager* UI: does it stay stable and usable with a realistic org's worth +// of trusted keys (10–15)? The list is meant to bound itself (max-height + +// scroll) rather than push the dialog off-screen, and rename/remove must operate +// on the right row without disturbing the others. +const N = 15 + +// Seed the trusted-keys store directly (listing/rename/remove don't re-validate +// key material — only import does, and that's tested elsewhere). Each entry needs +// a unique fingerprint + a label; sign/box are unused by the manager. +function seedTrustedKeys(dir) { + const keys = Array.from({ length: N }, (_, i) => ({ + fingerprint: randomBytes(16).toString('hex'), + label: `Teammate ${String(i + 1).padStart(2, '0')}`, + sign: 'c2lnbg==', + box: 'Ym94' + })) + writeFileSync(join(dir, 'trusted-keys.json'), JSON.stringify(keys)) +} + +test('the trusted-keys manager stays stable with 15 keys', async () => { + test.setTimeout(60_000) + const dir = freshUserDataDir() + seedTrustedKeys(dir) + let app = await launchApp(dir) + let page = await firstReadyPage(app) + + try { + await openMenu(page, 'Security', 'Manage Trusted Keys') + const mgr = page.getByRole('dialog', { name: 'Trusted keys' }) + await expect(mgr).toBeVisible() + + // Every key renders, and the list bounds itself with a scroll rather than + // overflowing the dialog/window. + await expect(mgr.locator('li.key')).toHaveCount(N) + await expect(mgr.getByText('Teammate 01', { exact: true })).toBeVisible() + await expect(mgr.getByText('Teammate 15', { exact: true })).toBeVisible() + const scrolls = await mgr.locator('.keys').evaluate((el) => el.scrollHeight > el.clientHeight + 1) + expect(scrolls).toBe(true) + + // Rename a mid-list key: only that row changes, the count holds. + const target = mgr.locator('li.key', { hasText: 'Teammate 08' }) + await target.getByTitle('Rename').click() + await mgr.locator('input.rename').fill('Teammate 08 — laptop') + await mgr.locator('input.rename').press('Enter') + await expect(mgr.getByText('Teammate 08 — laptop')).toBeVisible() + await expect(mgr.locator('li.key')).toHaveCount(N) + + // Remove two different rows: the count drops, the removed ones vanish, the + // rest are untouched. + await mgr.locator('li.key', { hasText: 'Teammate 03' }).getByTitle('Remove').click() + await expect(mgr.locator('li.key')).toHaveCount(N - 1) + await mgr.locator('li.key', { hasText: 'Teammate 12' }).getByTitle('Remove').click() + await expect(mgr.locator('li.key')).toHaveCount(N - 2) + await expect(mgr.getByText('Teammate 03', { exact: true })).toBeHidden() + await expect(mgr.getByText('Teammate 12', { exact: true })).toBeHidden() + await expect(mgr.getByText('Teammate 01', { exact: true })).toBeVisible() + await expect(mgr.getByText('Teammate 08 — laptop')).toBeVisible() + + // The changes persist across a relaunch (the store was rewritten cleanly). + await mgr.locator('.dialog-actions button', { hasText: 'Close' }).click() + await app.close() + app = await launchApp(dir) + page = await firstReadyPage(app) + await openMenu(page, 'Security', 'Manage Trusted Keys') + const mgr2 = page.getByRole('dialog', { name: 'Trusted keys' }) + await expect(mgr2.locator('li.key')).toHaveCount(N - 2) + await expect(mgr2.getByText('Teammate 08 — laptop')).toBeVisible() + } finally { + await app.close() + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/e2e/view-toggles.spec.mjs b/e2e/view-toggles.spec.mjs index 0f749af..b66b25e 100644 --- a/e2e/view-toggles.spec.mjs +++ b/e2e/view-toggles.spec.mjs @@ -12,10 +12,22 @@ async function pasteCompare(page, left, right) { 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 + const identical = page.locator('.diff-viewer .identical-row') + await expect(identical).toBeHidden() // a change, for now await page.getByLabel('Ignore whitespace').check() - await expect(page.locator('.stats .identical')).toHaveText('No differences') + await expect(identical).toContainText('No differences') +}) + +test('the Paste text button names its destination so the toggle is explicit', async ({ page }) => { + const toggle = page.locator('.toolbar .actions button').filter({ hasText: /Paste text|File mode/ }) + await expect(toggle).toHaveText('Paste text') // files mode: offers paste + await toggle.click() + await expect(page.getByPlaceholder('Paste original text here')).toBeVisible() + await expect(toggle).toHaveText('File mode') // now offers the way back + await toggle.click() + await expect(toggle).toHaveText('Paste text') // back to files mode + await expect(page.getByPlaceholder('Paste original text here')).toBeHidden() }) test('Swap flips additions and deletions', async ({ page }) => { diff --git a/package-lock.json b/package-lock.json index df7f03c..bfd1b9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "license": "MIT", "dependencies": { "chardet": "^2.0.0", + "fflate": "^0.8.3", "iconv-lite": "^0.6.3", "mermaid": "^11.16.0", "pinia": "^2.2.6", + "saxen": "^11.1.0", "vue": "^3.5.13" }, "devDependencies": { @@ -5699,6 +5701,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8230,6 +8238,15 @@ "node": ">=11.0.0" } }, + "node_modules/saxen": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.0.tgz", + "integrity": "sha512-GOxBOAmiWVAytOHBuMlgFMZ4MAk+2Ny5QJpzM9I4IozfPLXq3FsDdIHNviTQGZGIx7G2mBkA+uySiJlYmSU1Gg==", + "license": "MIT", + "engines": { + "node": ">= 20.12" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", diff --git a/package.json b/package.json index 677923b..ff090c8 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,11 @@ }, "dependencies": { "chardet": "^2.0.0", + "fflate": "^0.8.3", "iconv-lite": "^0.6.3", "mermaid": "^11.16.0", "pinia": "^2.2.6", + "saxen": "^11.1.0", "vue": "^3.5.13" }, "devDependencies": { diff --git a/src/main/clipboard.js b/src/main/clipboard.js index 9354420..5388ad9 100644 --- a/src/main/clipboard.js +++ b/src/main/clipboard.js @@ -21,4 +21,13 @@ export function registerClipboardIpc() { clipboard.writeText(text) return { ok: true } }) + + // Reading is also main-side (navigator.clipboard is blocked, rule 1/3). Only + // ever invoked after an explicit user gesture + confirm (the Ctrl/Cmd+V + // paste-to-compare flow), and capped so a huge clipboard can't be dumped in. + ipcMain.handle('clipboard:read', () => { + const text = clipboard.readText() + if (typeof text !== 'string') return '' + return text.length > MAX_CLIPBOARD_BYTES ? text.slice(0, MAX_CLIPBOARD_BYTES) : text + }) } diff --git a/src/main/files.js b/src/main/files.js index 1bcfda4..47a270d 100644 --- a/src/main/files.js +++ b/src/main/files.js @@ -4,16 +4,31 @@ import { basename, resolve, sep } from 'path' import chardet from 'chardet' import iconv from 'iconv-lite' import { readSettings } from './appData' +import { readXlsx } from './xlsx/index' -// Warn before loading files bigger than the user's configured comparison limit -// (Monaco slows down well past it). The default is a safe 10 MB; the user can -// raise it in Settings, accepting the performance hit. Read fresh each time so -// the choice applies without a restart, and floored so a bad value can't -// disable the guard entirely. -const DEFAULT_LARGE_FILE_MB = 10 -function largeFileBytes() { - const mb = Number(readSettings().maxComparisonFileMb) - return (Number.isFinite(mb) && mb >= 1 ? mb : DEFAULT_LARGE_FILE_MB) * 1024 * 1024 +// Per-file-type size guards. Mirrors the renderer's FILE_TYPE_LIMITS +// (stores/settingsStore.js) — the renderer owns the slider UI; main enforces +// independently so a hand-edited settings.json can't wedge the app. Keep the +// numbers in sync with that file. `cap` is the hard ceiling: for .xlsx it's also +// the reader's compressed-input limit, so "Load anyway" works up to the same +// number the slider maxes at, and the two can never disagree. +const TYPE_LIMITS = { + text: { default: 10, cap: 200 }, + spreadsheet: { default: 25, cap: 100 } +} + +function fileTypeFor(name) { + return /\.xlsx$/i.test(name) ? 'spreadsheet' : 'text' +} + +// The soft prompt threshold in bytes for a type: the user's setting, floored so +// a bad value can't disable the guard and capped at the type's ceiling. Read +// fresh each time so a change applies without a restart. +function limitBytesFor(type) { + const spec = TYPE_LIMITS[type] ?? TYPE_LIMITS.text + const configured = Number(readSettings().fileSizeLimitsMb?.[type]) + const mb = Number.isFinite(configured) && configured >= 1 ? Math.min(configured, spec.cap) : spec.default + return mb * 1024 * 1024 } // Provenance allowlist. `file:read` is only ever meant to serve a path the @@ -52,11 +67,31 @@ function isUnderUserData(filePath) { return abs === base || abs.startsWith(base + sep) } +// Local zip signature ("PK\x03\x04"). Gated on the .xlsx extension too, so a +// plain .zip the user drops isn't treated as a spreadsheet. +const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]) +function looksLikeXlsx(name, buffer) { + return /\.xlsx$/i.test(name) && buffer.length >= 4 && buffer.subarray(0, 4).equals(ZIP_MAGIC) +} + +function readXlsxForRenderer(buffer, filePath, name, size) { + try { + // The reader's compressed-input ceiling is the spreadsheet cap, so it agrees + // with the size prompt: anything the user could "Load anyway" also parses. + const { sheets } = readXlsx(buffer, { maxInputBytes: TYPE_LIMITS.spreadsheet.cap * 1024 * 1024 }) + return { path: filePath, name, size, kind: 'spreadsheet', sheets } + } catch (err) { + // XlsxError (bomb / doctype / unzip / format / parse) or anything + // unexpected — not a loadable comparison; surface a polite reason. + return { error: 'xlsx', name, path: filePath, message: err?.message ?? 'unreadable spreadsheet' } + } +} + async function readFileForRenderer(win, filePath, opts = {}) { const name = basename(filePath) const { size } = await stat(filePath) - if (size > largeFileBytes()) { + if (size > limitBytesFor(fileTypeFor(name))) { // quiet mode (focus refresh) must never pop a dialog — skip the reload. if (opts.quiet) return null const { response } = await dialog.showMessageBox(win, { @@ -73,6 +108,11 @@ async function readFileForRenderer(win, filePath, opts = {}) { const buffer = await readFile(filePath) + // .xlsx is a zip (so it trips the binary sniff below): detect it first and + // parse to a spreadsheet grid in the main process. Parsing stays here, never + // in the renderer — it's hostile binary input (see src/main/xlsx/). + if (looksLikeXlsx(name, buffer)) return readXlsxForRenderer(buffer, filePath, name, size) + // Binary detection: a NUL byte in the first 8 KB means this is not text. if (buffer.subarray(0, 8192).includes(0)) { return { error: 'binary', name, path: filePath } diff --git a/src/main/index.js b/src/main/index.js index 61223ba..f5c2b27 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -18,9 +18,12 @@ import { registerFileIpc } from './files' import { registerTextToolsIpc } from './textTools' import { registerShareIpc } from './share' import { registerSnippetIpc } from './snippets' +import { installCrashHooks, registerLoggerIpc } from './logger' // Must run before app ready, while the command line is still mutable. applyHeadlessSwitches() +// Record main-process crashes as early as possible, before anything else runs. +installCrashHooks() // Only one instance/window. A second launch hands its args (and its version) // off to the running instance and quits instead of opening a second window. @@ -58,6 +61,7 @@ if (!app.requestSingleInstanceLock({ version: app.getVersion() })) { registerTextToolsIpc() registerShareIpc() registerSnippetIpc() + registerLoggerIpc() createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/src/main/logFormat.js b/src/main/logFormat.js new file mode 100644 index 0000000..4cf8026 --- /dev/null +++ b/src/main/logFormat.js @@ -0,0 +1,91 @@ +// Pure helpers for the local error log — no Electron, no fs, so the formatting, +// daily-rotation naming, size cap, and retention logic stay unit-testable. The +// fs/IPC glue lives in logger.js (CLAUDE.md: keep pure logic out of +// Electron-importing files). The log is LOCAL ONLY — nothing here ever leaves +// the machine; it exists so a user can paste it into a GitHub issue by choice. + +const LOG_PREFIX = 'diffbro-' +const LOG_SUFFIX = '.log' + +// Field caps: a single error (possibly from a compromised renderer, per the +// threat model) must not be able to bloat the file. Truncate, don't reject. +const MAX_MESSAGE = 4_000 +const MAX_STACK = 16_000 +const MAX_CONTEXT = 1_000 + +function oneLine(value, max) { + const s = String(value ?? '').replace(/\s+/g, ' ').trim() + return s.length > max ? `${s.slice(0, max)}…(truncated)` : s +} + +function clampStack(stack) { + const s = String(stack ?? '') + return s.length > MAX_STACK ? `${s.slice(0, MAX_STACK)}\n…(truncated)` : s +} + +// `diffbro-2026-07-23.log` — one file per calendar day (local time). +export function dailyLogName(date = new Date()) { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${LOG_PREFIX}${y}-${m}-${d}${LOG_SUFFIX}` +} + +export function isLogFileName(name) { + return typeof name === 'string' && name.startsWith(LOG_PREFIX) && name.endsWith(LOG_SUFFIX) +} + +// One newline-terminated block per error. Kept greppable: an entry always starts +// at column 0 with "[timestamp]", which capLog relies on to split on boundaries. +export function formatLogEntry(record = {}, now = new Date()) { + const source = oneLine(record.source || 'app', 40) + const head = `[${now.toISOString()}] [${source}] ${oneLine(record.message, MAX_MESSAGE)}` + const lines = [head] + if (record.context) lines.push(` context: ${oneLine(record.context, MAX_CONTEXT)}`) + if (record.appVersion || record.platform) { + lines.push(` env: ${oneLine(`${record.appVersion ?? ''} ${record.platform ?? ''}`, 100)}`) + } + if (record.stack) { + lines.push( + clampStack(record.stack) + .split('\n') + .map((l) => ` ${l}`) + .join('\n') + ) + } + return lines.join('\n') + '\n' +} + +// Replace the user's home directory with ~ so a pasted log doesn't leak the +// account name / full home path. Applied to the whole entry before it's written. +export function redactHome(text, homeDir) { + if (!homeDir) return text + // Escape regex metacharacters in the path, match all occurrences. + const escaped = homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return String(text).replace(new RegExp(escaped, 'g'), '~') +} + +// Keep one day's file bounded too: if appending would blow the cap, drop whole +// oldest entries (each starts with "[") until it fits. Never splits an entry. +export function capLog(existing, entry, maxBytes) { + let combined = (existing ?? '') + entry + if (combined.length <= maxBytes) return combined + // If a single entry already exceeds the cap, keep just that entry. + if (entry.length >= maxBytes) return entry + while (combined.length > maxBytes) { + // Drop everything up to the second entry's start. + const next = combined.indexOf('\n[', 1) + if (next === -1) break + combined = combined.slice(next + 1) + } + return combined +} + +// Given the log directory's file names, return the ones to delete: anything +// that isn't one of the most recent `keepDays` daily files. Sorting is by name, +// which is chronological because the date is zero-padded ISO. +export function staleLogFiles(names, keepDays = 7) { + const logs = names.filter(isLogFileName).sort() + if (logs.length <= keepDays) return [] + return logs.slice(0, logs.length - keepDays) +} diff --git a/src/main/logger.js b/src/main/logger.js new file mode 100644 index 0000000..96300a7 --- /dev/null +++ b/src/main/logger.js @@ -0,0 +1,197 @@ +// Local error/crash logger. LOCAL ONLY — it writes a daily-rotated text file so +// a user can paste it into a GitHub issue by choice; it NEVER sends anything +// (that would break the offline guarantee). All fs lives here in the main +// process; the renderer only forwards error records over IPC. Pure formatting, +// rotation, and retention live in logFormat.js (unit-tested). +import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync +} from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { + capLog, + dailyLogName, + formatLogEntry, + isLogFileName, + redactHome, + staleLogFiles +} from './logFormat' + +const MAX_DAY_BYTES = 512 * 1024 // one day's file +const KEEP_DAYS = 7 +// A pointer file (like data-location.json) so the log directory is main-managed +// and readable at startup without the renderer. Lives in userData, which is +// always writable; the default target is Electron's per-app logs path. +const pointerPath = () => join(app.getPath('userData'), 'log-location.json') + +function defaultLogDir() { + try { + return app.getPath('logs') + } catch { + return join(app.getPath('userData'), 'logs') + } +} + +export function getLogDir() { + try { + const { dir } = JSON.parse(readFileSync(pointerPath(), 'utf-8')) + if (typeof dir === 'string' && dir) return dir + } catch { + // no pointer yet, or unreadable — fall through to the default + } + return defaultLogDir() +} + +function isDefaultDir() { + return getLogDir() === defaultLogDir() +} + +function ensureDir(dir) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) +} + +function currentLogPath() { + return join(getLogDir(), dailyLogName()) +} + +// Append one record to today's file, capping the day's size and pruning files +// older than the retention window. Best-effort and self-contained: a logging +// failure must never throw into a crash handler. +export function appendLog(record) { + try { + const dir = getLogDir() + ensureDir(dir) + const file = join(dir, dailyLogName()) + const entry = redactHome(formatLogEntry({ ...record, ...envInfo() }), homedir()) + const existing = existsSync(file) ? readFileSync(file, 'utf-8') : '' + writeFileSync(file, capLog(existing, entry, MAX_DAY_BYTES)) + pruneOldLogs(dir) + } catch { + // swallow — a broken log path can't be allowed to take the app down + } +} + +function envInfo() { + return { appVersion: app.getVersion(), platform: `${process.platform} ${process.arch}` } +} + +function pruneOldLogs(dir) { + try { + for (const name of staleLogFiles(readdirSync(dir), KEEP_DAYS)) { + unlinkSync(join(dir, name)) + } + } catch { + // directory vanished mid-run, etc. — retention is best-effort + } +} + +// Read the most recent day's log for display/copy in the report dialog, capped +// to the last slice so a big day doesn't flood the clipboard/UI. +const READ_TAIL_BYTES = 64 * 1024 +export function readCurrentLog() { + try { + const file = currentLogPath() + if (!existsSync(file)) return { path: file, content: '' } + const content = readFileSync(file, 'utf-8') + return { path: file, content: content.slice(-READ_TAIL_BYTES) } + } catch { + return { path: currentLogPath(), content: '' } + } +} + +function clearLogs() { + try { + const dir = getLogDir() + if (!existsSync(dir)) return + for (const name of readdirSync(dir)) { + if (isLogFileName(name)) unlinkSync(join(dir, name)) + } + } catch { + // best-effort + } +} + +async function chooseLogDir(win) { + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + title: 'Choose a folder for logs', + properties: ['openDirectory', 'createDirectory'] + }) + if (canceled || !filePaths.length) return { ok: false } + const dir = filePaths[0] + ensureDir(dir) + writeFileSync(pointerPath(), JSON.stringify({ dir })) + return { ok: true, dir } +} + +function resetLogDir() { + try { + if (existsSync(pointerPath())) rmSync(pointerPath()) + } catch { + // ignore + } + return { ok: true, dir: getLogDir() } +} + +// Catch what the app itself can't recover from. Renderer JS errors arrive via +// the log:error IPC (see App.vue's global handlers); these are the main-process +// and process-level failures. +export function installCrashHooks() { + process.on('uncaughtException', (err) => { + appendLog({ source: 'main', message: err?.message ?? String(err), stack: err?.stack }) + }) + process.on('unhandledRejection', (reason) => { + appendLog({ + source: 'main', + message: `Unhandled rejection: ${reason?.message ?? reason}`, + stack: reason?.stack + }) + }) + app.on('render-process-gone', (_e, _wc, details) => { + appendLog({ + source: 'renderer-process', + message: `Renderer gone: ${details.reason} (exit ${details.exitCode})`, + context: 'render-process-gone' + }) + }) + app.on('child-process-gone', (_e, details) => { + appendLog({ + source: 'child-process', + message: `${details.type} gone: ${details.reason}`, + context: 'child-process-gone' + }) + }) +} + +export function registerLoggerIpc() { + // The renderer forwards its own uncaught errors here. Treat the record as + // untrusted: only the known string fields are read, and logFormat caps them. + ipcMain.handle('log:error', (_e, record = {}) => { + appendLog({ + source: 'renderer', + message: record.message, + stack: record.stack, + context: record.context + }) + return true + }) + ipcMain.handle('log:read', () => readCurrentLog()) + ipcMain.handle('log:clear', () => { + clearLogs() + return true + }) + ipcMain.handle('log:reveal', () => { + const { path } = readCurrentLog() + if (existsSync(path)) shell.showItemInFolder(path) + else shell.openPath(getLogDir()) + }) + ipcMain.handle('log:getDir', () => ({ dir: getLogDir(), isDefault: isDefaultDir() })) + ipcMain.handle('log:chooseDir', (e) => chooseLogDir(BrowserWindow.fromWebContents(e.sender))) + ipcMain.handle('log:resetDir', () => resetLogDir()) +} diff --git a/src/main/window.js b/src/main/window.js index adaf8fd..84d5361 100644 --- a/src/main/window.js +++ b/src/main/window.js @@ -119,6 +119,14 @@ export function createWindow() { if (state.maximized) win.maximize() trackWindowState(win) + // Push the app-window fullscreen state to the renderer so views that fill the + // window (the Mermaid viewer) can maximise themselves in step with it. No + // renderer-observable DOM signal exists for OS-level window fullscreen, so it + // comes from these BrowserWindow events. + const sendFullScreen = () => win.webContents.send('window:fullscreen', win.isFullScreen()) + win.on('enter-full-screen', sendFullScreen) + win.on('leave-full-screen', sendFullScreen) + if (DEV_URL) { win.loadURL(DEV_URL) } else { diff --git a/src/main/xlsx/errors.js b/src/main/xlsx/errors.js new file mode 100644 index 0000000..482fd0d --- /dev/null +++ b/src/main/xlsx/errors.js @@ -0,0 +1,25 @@ +// Shared error type + guards for the OOXML reader. No Electron, no fs — the +// whole reader is pure so it stays unit-testable (see tests/main/xlsx/). + +export class XlsxError extends Error { + constructor(code, message) { + super(message) + this.name = 'XlsxError' + // 'bomb' | 'unzip' | 'format' | 'parse' | 'doctype' + this.code = code + } +} + +// OOXML is always DTD-free. A DOCTYPE is the entry point for XXE and +// billion-laughs entity expansion, so any declaration rejects the whole file +// before a parser ever touches it. +export function rejectDoctype(xml) { + if (/<!doctype/i.test(xml)) { + throw new XlsxError('doctype', 'XML DOCTYPE declarations are not allowed in .xlsx') + } +} + +const utf8 = new TextDecoder('utf-8') +export function decodeUtf8(bytes) { + return utf8.decode(bytes) +} diff --git a/src/main/xlsx/index.js b/src/main/xlsx/index.js new file mode 100644 index 0000000..edf27d9 --- /dev/null +++ b/src/main/xlsx/index.js @@ -0,0 +1,34 @@ +import { extractXlsxEntries } from './unzip' +import { decodeUtf8, XlsxError } from './errors' +import { parseSharedStrings, parseWorkbook, parseRels, resolveTarget } from './parse' +import { parseSheet } from './sheet' + +// Read an .xlsx buffer into a comparable grid. Read-only, no formula +// evaluation, no external resources — the security posture lives in unzip.js +// (entry allowlist + decompression-bomb caps) and errors.js (DOCTYPE rejection). +// Returns { sheets: [{ name, rows }] }; each row is a dense array of cell values +// (string | number | boolean | null). Throws XlsxError on refusal. +export function readXlsx(buffer, opts = {}) { + const entries = extractXlsxEntries(buffer, opts) + const textOf = (name) => (entries.has(name) ? decodeUtf8(entries.get(name)) : null) + + const wbXml = textOf('xl/workbook.xml') + if (!wbXml) throw new XlsxError('format', 'workbook.xml is missing') + + const ssXml = textOf('xl/sharedStrings.xml') + const sharedStrings = ssXml ? parseSharedStrings(ssXml) : [] + const relsXml = textOf('xl/_rels/workbook.xml.rels') + const rels = relsXml ? parseRels(relsXml) : new Map() + + const sheets = parseWorkbook(wbXml).map((s) => ({ + name: s.name, + rows: sheetRows(textOf, rels.get(s.rid), sharedStrings, opts) + })) + return { sheets } +} + +function sheetRows(textOf, target, sharedStrings, opts) { + if (!target) return [] + const xml = textOf(resolveTarget(target)) + return xml ? parseSheet(xml, sharedStrings, opts) : [] +} diff --git a/src/main/xlsx/parse.js b/src/main/xlsx/parse.js new file mode 100644 index 0000000..1fc3012 --- /dev/null +++ b/src/main/xlsx/parse.js @@ -0,0 +1,94 @@ +import { Parser, decode } from 'saxen' +import { XlsxError, rejectDoctype } from './errors' + +// One place to build a SAX parser whose XML errors surface as XlsxError('parse') +// rather than saxen's default throw of a bare string. +function newParser(onOpen, onClose, onText) { + const p = new Parser() + if (onOpen) p.on('openTag', onOpen) + if (onClose) p.on('closeTag', onClose) + if (onText) p.on('text', onText) + p.on('error', (err) => { + throw new XlsxError('parse', `malformed XML: ${err}`) + }) + return p +} + +// The shared string table: <sst><si>…</si>…</sst>. An <si> may hold rich-text +// runs (<r><t>…</t></r>), whose <t> fragments concatenate; phonetic runs +// (<rPh>) carry pronunciation, not content, so their <t> is skipped. +export function parseSharedStrings(xml) { + rejectDoctype(xml) + const strings = [] + let cur = null + let inT = false + let phonetic = 0 + const p = newParser( + (name) => { + if (name === 'si') cur = '' + else if (name === 'rPh') phonetic++ + else if (name === 't' && !phonetic) inT = true + }, + (name) => { + if (name === 't') inT = false + else if (name === 'rPh') phonetic-- + else if (name === 'si') { + strings.push(cur ?? '') + cur = null + } + }, + (val, decodeEntities) => { + if (inT) cur += decodeEntities(val) + } + ) + p.parse(xml) + return strings +} + +// workbook.xml lists sheets in display order with a relationship id that +// _rels/workbook.xml.rels maps to the actual worksheet path. +export function parseWorkbook(xml) { + rejectDoctype(xml) + const sheets = [] + const p = newParser((name, getAttrs) => { + if (name !== 'sheet') return + const a = getAttrs() + sheets.push({ name: decode(a['name'] ?? ''), rid: a['r:id'] ?? a['r:Id'] ?? '' }) + }) + p.parse(xml) + return sheets +} + +// Relationship ids are attacker-controlled strings used as keys, so they go in a +// Map — never plain-object property assignment (prototype-pollution safe). +export function parseRels(xml) { + rejectDoctype(xml) + const map = new Map() + const p = newParser((name, getAttrs) => { + if (name !== 'Relationship') return + const a = getAttrs() + if (a['Id'] && a['Target']) map.set(a['Id'], a['Target']) + }) + p.parse(xml) + return map +} + +// "B12" -> 1 (0-based column). Bijective base-26 over the leading letters; +// returns -1 when the ref has no letter prefix. +export function colToIndex(ref) { + let col = 0 + let seen = false + for (let i = 0; i < ref.length; i++) { + const ch = ref.charCodeAt(i) + if (ch < 65 || ch > 90) break + col = col * 26 + (ch - 64) + seen = true + } + return seen ? col - 1 : -1 +} + +// Relationship targets are relative to xl/ ("worksheets/sheet1.xml") or absolute +// from the package root ("/xl/worksheets/sheet1.xml"). +export function resolveTarget(target) { + return target.startsWith('/') ? target.slice(1) : `xl/${target}` +} diff --git a/src/main/xlsx/sheet.js b/src/main/xlsx/sheet.js new file mode 100644 index 0000000..2f9406f --- /dev/null +++ b/src/main/xlsx/sheet.js @@ -0,0 +1,100 @@ +import { Parser } from 'saxen' +import { XlsxError, rejectDoctype } from './errors' +import { colToIndex } from './parse' + +export const SHEET_DEFAULTS = { maxCells: 2_000_000 } + +// Turn a cell's raw <v>/<t> text into a typed JS value. A formula cell reaches +// here with only its cached <v> result — <f> is dropped during parsing (see +// onText) — so nothing is ever re-evaluated. Dates are numbers: without reading +// xl/styles.xml (deliberately skipped) a date serial can't be distinguished +// from a plain number; that's a documented limitation of the value diff. +export function resolveCellValue(type, v, t, sharedStrings) { + switch (type) { + case 's': { + const i = Number.parseInt(v, 10) + return i >= 0 && i < sharedStrings.length ? sharedStrings[i] : '' + } + case 'inlineStr': + return t + case 'str': + return v + case 'b': + return v === '1' + case 'e': + return v + default: { + if (v === '') return '' + const n = Number(v) + return Number.isFinite(n) ? n : v + } + } +} + +function densify(row) { + const out = [] + for (let i = 0; i < row.length; i++) out.push(row[i] === undefined ? null : row[i]) + return out +} + +function onOpen(st, name, getAttrs) { + if (name === 'c') { + const a = getAttrs() + st.type = a['t'] ?? '' + st.col = colToIndex(a['r'] ?? '') + st.v = '' + st.t = '' + } else if (name === 'row') st.row = [] + else if (name === 'v') st.inV = true + else if (name === 'f') st.inF = true + else if (name === 't' && st.type === 'inlineStr') st.inT = true +} + +function onText(st, val, decodeEntities) { + if (st.inF) return // formula text is deliberately never captured + if (st.inV) st.v += val + else if (st.inT) st.t += decodeEntities(val) +} + +// Returns true to signal the caller to stop parsing (cell budget exceeded). +// `ctx` carries { rows, sharedStrings, maxCells } to stay within max-params. +function onClose(st, name, ctx) { + if (name === 'v') st.inV = false + else if (name === 'f') st.inF = false + else if (name === 't') st.inT = false + else if (name === 'c') return commitCell(st, ctx.sharedStrings, ctx.maxCells) + else if (name === 'row') { + ctx.rows.push(densify(st.row ?? [])) + st.row = null + } + return false +} + +function commitCell(st, sharedStrings, maxCells) { + if (!st.row || st.col < 0) return false + st.cells++ + if (st.cells > maxCells) return true + st.row[st.col] = resolveCellValue(st.type, st.v, st.t, sharedStrings) + return false +} + +// Walk <sheetData> into an array of rows, each a dense array of cell values. +export function parseSheet(xml, sharedStrings, opts = {}) { + rejectDoctype(xml) + const maxCells = opts.maxCells ?? SHEET_DEFAULTS.maxCells + const rows = [] + const st = { row: null, col: -1, type: '', v: '', t: '', inV: false, inT: false, inF: false, cells: 0 } + const ctx = { rows, sharedStrings, maxCells } + const p = new Parser() + p.on('openTag', (name, getAttrs) => onOpen(st, name, getAttrs)) + p.on('text', (val, decodeEntities) => onText(st, val, decodeEntities)) + p.on('closeTag', (name) => { + if (onClose(st, name, ctx)) p.stop() + }) + p.on('error', (err) => { + throw new XlsxError('parse', `malformed sheet XML: ${err}`) + }) + p.parse(xml) + if (st.cells > maxCells) throw new XlsxError('bomb', 'sheet exceeds the maximum cell count') + return rows +} diff --git a/src/main/xlsx/unzip.js b/src/main/xlsx/unzip.js new file mode 100644 index 0000000..3894fcc --- /dev/null +++ b/src/main/xlsx/unzip.js @@ -0,0 +1,79 @@ +import { unzipSync } from 'fflate' +import { XlsxError } from './errors' + +// Only the parts a value diff interprets are ever inflated. Everything else — +// formulas' external links (xl/externalLinks/*), VBA (xl/vbaProject.bin), +// drawings, media, styles, connections — is left compressed and untouched, so +// the code that would parse it is never a reachable attack surface. +const ALLOWED = + /^xl\/(workbook\.xml|_rels\/workbook\.xml\.rels|sharedStrings\.xml|worksheets\/sheet\d+\.xml)$/ + +export function isAllowedEntry(name) { + return ALLOWED.test(name) +} + +export const UNZIP_DEFAULTS = { + // Compressed-input ceiling. files.js overrides this with the spreadsheet + // size cap so the reader agrees with the Settings slider; this default is the + // same value for direct/test callers. + maxInputBytes: 100 * 1024 * 1024, // whole .xlsx (compressed) + maxEntryBytes: 256 * 1024 * 1024, // one inflated part + maxTotalBytes: 512 * 1024 * 1024, // all inflated parts together + maxRatio: 500 // inflated / compressed, per entry +} + +// Inflate ONLY the allowlisted entries, refusing decompression bombs. The zip +// header's sizes are attacker-controlled, so they gate inflation cheaply in the +// filter; the ACTUAL inflated lengths are re-checked afterwards in case a header +// lied. NOTE (follow-up): the synchronous unzip still fully inflates an accepted +// entry before the post-check sees it — a production build should switch to +// fflate's streaming `Unzip` with a hard byte-abort so the bound is enforced +// during inflation, not after. +export function extractXlsxEntries(buffer, opts = {}) { + const cfg = { ...UNZIP_DEFAULTS, ...opts } + const input = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + if (input.length > cfg.maxInputBytes) { + throw new XlsxError('bomb', 'file exceeds the maximum .xlsx size') + } + + let headerTotal = 0 + let capError = null + const filter = (file) => { + if (capError || !isAllowedEntry(file.name)) return false + if (file.originalSize > cfg.maxEntryBytes) { + capError = 'an entry is too large to inflate' + } else if (file.size > 0 && file.originalSize / file.size > cfg.maxRatio) { + capError = 'an entry has a suspicious compression ratio' + } else { + headerTotal += file.originalSize + if (headerTotal > cfg.maxTotalBytes) capError = 'the archive inflates to too much data' + } + return !capError + } + + let unzipped + try { + unzipped = unzipSync(input, { filter }) + } catch { + throw new XlsxError('unzip', 'not a readable .xlsx archive') + } + if (capError) throw new XlsxError('bomb', capError) + return checkInflated(unzipped, cfg) +} + +function checkInflated(unzipped, cfg) { + const entries = new Map() + let total = 0 + for (const name of Object.keys(unzipped)) { + const bytes = unzipped[name] + if (bytes.length > cfg.maxEntryBytes) { + throw new XlsxError('bomb', 'an inflated entry is too large') + } + total += bytes.length + if (total > cfg.maxTotalBytes) { + throw new XlsxError('bomb', 'the archive inflates to too much data') + } + entries.set(name, bytes) + } + return entries +} diff --git a/src/preload/index.js b/src/preload/index.js index 3c680b7..de0a34d 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -65,14 +65,25 @@ contextBridge.exposeInMainWorld('api', { // Opens the project's "new issue" page (a fixed URL, chosen in main) in the // OS browser. No URL crosses from the renderer. reportIssue: () => ipcRenderer.invoke('app:reportIssue'), + // Local error log (written by the main process, never sent anywhere). The + // renderer forwards its own uncaught errors and can read/clear/reveal the log + // and choose where it's stored. + logError: (record) => ipcRenderer.invoke('log:error', record), + readLog: () => ipcRenderer.invoke('log:read'), + clearLog: () => ipcRenderer.invoke('log:clear'), + revealLog: () => ipcRenderer.invoke('log:reveal'), + logDirGet: () => ipcRenderer.invoke('log:getDir'), + logDirChoose: () => ipcRenderer.invoke('log:chooseDir'), + logDirReset: () => ipcRenderer.invoke('log:resetDir'), // Durable key/value store backed by files in the configurable data directory // (so data survives a reinstall). Loads are synchronous so the Pinia stores // can read their state during setup, exactly like localStorage did. storeLoad: (name) => ipcRenderer.sendSync('store:load', name), storeSave: (name, contents) => ipcRenderer.invoke('store:save', name, contents), - // Write text to the OS clipboard from the main process (navigator.clipboard - // is blocked by the deny-all permission handler; see src/main/clipboard.js). + // Write/read the OS clipboard from the main process (navigator.clipboard is + // blocked by the deny-all permission handler; see src/main/clipboard.js). copyText: (text) => ipcRenderer.invoke('clipboard:write', text), + readText: () => ipcRenderer.invoke('clipboard:read'), // Data-location settings. dataDirGet: () => ipcRenderer.invoke('datadir:get'), dataDirChoose: () => ipcRenderer.invoke('datadir:choose'), @@ -82,5 +93,10 @@ contextBridge.exposeInMainWorld('api', { // App-menu actions (Open Left, Swap, …) arrive from the main process. onMenuAction: (handler) => { ipcRenderer.on('menu:action', (_e, action) => handler(action)) + }, + // App-window fullscreen state changes (main pushes true/false). Read by the + // Mermaid viewer so it can fill the window when the app goes fullscreen. + onFullScreenChange: (handler) => { + ipcRenderer.on('window:fullscreen', (_e, value) => handler(value)) } }) diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index f83f6ad..839eaa1 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -3,8 +3,12 @@ import { computed, onMounted } from 'vue' import { useDiffStore } from './stores/diffStore' import { useSettingsStore } from './stores/settingsStore' import { useWindowFileDrop } from './composables/useFileDrop' +import { usePasteShortcut } from './composables/usePasteShortcut' import FileSlot from './components/FileSlot.vue' import DiffViewer from './components/DiffViewer.vue' +import SpreadsheetDiffViewer from './components/SpreadsheetDiffViewer.vue' +import SupportedFormats from './components/SupportedFormats.vue' +import NyanLane from './components/NyanLane.vue' import PasteInput from './components/PasteInput.vue' import ShortcutBar from './components/ShortcutBar.vue' import MenuBar from './components/MenuBar.vue' @@ -21,9 +25,16 @@ const settings = useSettingsStore() store.initTheme() window.api.onMenuAction((action) => store.handleMenuAction(action)) +// Ctrl/Cmd+V outside a text field offers to jump into paste mode (two-step +// confirm before the clipboard is read — see the store's paste actions). +usePasteShortcut(() => store.requestPasteFromClipboard()) // Live re-diff: whenever the window regains focus, re-read loaded files so // external edits show up without reopening anything. -window.addEventListener('focus', () => store.refreshFromDisk()) +window.addEventListener('focus', () => { + store.refreshFromDisk() + // Roll the daily theme over if the date changed while the app sat open. + store.resolveActiveTheme() +}) // First run: greet a brand-new, empty library with the example snippet, then // record the one-time decision so it is never re-seeded — and never injected @@ -72,6 +83,9 @@ const { <MenuBar v-if="!isMac" /> <AppToolbar /> + <!-- Nyan theme only: a slim rainbow lane where the reward cat flies on a + match/save. Self-contained; absent in every other theme. --> + <NyanLane v-if="store.theme === 'nyan'" /> <div class="body"> <SavedDiffs /> @@ -104,10 +118,13 @@ const { </div> <PasteInput v-if="store.mode === 'paste'" /> + <!-- Content router: pick the viewer by comparable kind. --> <template v-else-if="store.ready"> - <FormatHintBanner side="left" /> - <FormatHintBanner side="right" /> - <DiffViewer /> + <template v-if="store.comparableKind === 'text'"> + <FormatHintBanner /> + <DiffViewer /> + </template> + <SpreadsheetDiffViewer v-else /> </template> <!-- One side loaded: make it obvious a second file is still needed. --> <div v-else-if="store.left || store.right" class="empty waiting"> @@ -120,7 +137,8 @@ const { </p> </div> <div v-else class="empty"> - <p>Choose or drop two files to compare.</p> + <p class="empty-title">Choose or drop two files to compare.</p> + <SupportedFormats /> </div> <ShortcutBar /> diff --git a/src/renderer/src/adapters/index.js b/src/renderer/src/adapters/index.js index e2afdd3..63e5103 100644 --- a/src/renderer/src/adapters/index.js +++ b/src/renderer/src/adapters/index.js @@ -1,9 +1,12 @@ // Adapter registry. Every adapter turns a raw file into comparable content: -// { kind: 'text', text: string, language: string } -// Future adapters (docx, pdf, image) plug in here without touching the viewer. +// text: { kind: 'text', text: string, language: string } +// spreadsheet: { kind: 'spreadsheet', sheets: SheetGrid[] } +// Order matters: the first matching adapter wins, and textAdapter matches +// everything, so it stays last. Future adapters (docx, …) plug in ahead of it. +import { xlsxAdapter } from './xlsxAdapter' import { textAdapter } from './textAdapter' -const adapters = [textAdapter] +const adapters = [xlsxAdapter, textAdapter] export function resolveAdapter(file) { return adapters.find((a) => a.matches(file)) ?? textAdapter diff --git a/src/renderer/src/adapters/xlsxAdapter.js b/src/renderer/src/adapters/xlsxAdapter.js new file mode 100644 index 0000000..2121f39 --- /dev/null +++ b/src/renderer/src/adapters/xlsxAdapter.js @@ -0,0 +1,14 @@ +// Spreadsheet adapter. The heavy lifting (unzip + parse + security caps) already +// happened in the main process (src/main/xlsx/) because .xlsx is hostile binary +// input and the renderer never touches Node — so by the time a file reaches +// here it already carries { kind:'spreadsheet', sheets }. This adapter just +// hands that grid to the viewer as a comparable. + +export const xlsxAdapter = { + id: 'xlsx', + matches: (file) => file?.kind === 'spreadsheet', + /** @returns {import('../types').SpreadsheetComparable} */ + toComparable(file) { + return { kind: 'spreadsheet', sheets: file.sheets ?? [] } + } +} diff --git a/src/renderer/src/components/AppDialogs.vue b/src/renderer/src/components/AppDialogs.vue index ada51e4..8ab7cd9 100644 --- a/src/renderer/src/components/AppDialogs.vue +++ b/src/renderer/src/components/AppDialogs.vue @@ -5,6 +5,9 @@ import { useDiffStore } from '../stores/diffStore' import { useSnippetStore } from '../stores/snippetStore' import { useVaultStore } from '../stores/vaultStore' +import { useErrorStore } from '../stores/errorStore' +import ErrorReportDialog from './ErrorReportDialog.vue' +import PasteConfirmDialog from './PasteConfirmDialog.vue' import SaveDiffDialog from './SaveDiffDialog.vue' import ShareDiffDialog from './ShareDiffDialog.vue' import ReplaceDiffDialog from './ReplaceDiffDialog.vue' @@ -26,6 +29,7 @@ import MermaidViewerDialog from './MermaidViewerDialog.vue' const store = useDiffStore() const snippets = useSnippetStore() const vault = useVaultStore() +const errors = useErrorStore() </script> <template> @@ -46,4 +50,6 @@ const vault = useVaultStore() <SnippetPassphraseDialog v-if="snippets.pendingExport || snippets.pendingImport" /> <SnippetDeleteDialog v-if="snippets.pendingDelete" /> <VaultCategoryDeleteDialog v-if="vault.pendingDelete" /> + <ErrorReportDialog v-if="errors.visible" /> + <PasteConfirmDialog v-if="store.pastePrompt" /> </template> diff --git a/src/renderer/src/components/AppToolbar.vue b/src/renderer/src/components/AppToolbar.vue index 0f86e72..63a801e 100644 --- a/src/renderer/src/components/AppToolbar.vue +++ b/src/renderer/src/components/AppToolbar.vue @@ -2,21 +2,31 @@ // Top bar: diff stats, the display toggles, the document actions, and the // theme switch. Every action here has a menu twin (src/main/menu.js and // MenuBar.vue) — this is the pointer-friendly half. +import { computed } from 'vue' import { useDiffStore } from '../stores/diffStore' +import { useSettingsStore } from '../stores/settingsStore' import { MOD } from '../keys' import AppIcon from './AppIcon.vue' const store = useDiffStore() +const settings = useSettingsStore() + +// The button names its destination, so it's explicit that pressing it again in +// paste mode returns to comparing files. +const inPaste = computed(() => store.mode === 'paste') +const pasteToggleLabel = computed(() => (inPaste.value ? 'File mode' : 'Paste text')) +const pasteToggleTitle = computed(() => + inPaste.value ? `Back to comparing files (${MOD}+T)` : `Compare pasted text (${MOD}+T)` +) </script> <template> <header class="toolbar band"> - <span v-if="store.ready && store.stats" class="stats"> - <span v-if="store.identical" class="identical">No differences</span> - <template v-else> - <span class="add">+{{ store.stats.additions }}</span> - <span class="del">−{{ store.stats.deletions }}</span> - </template> + <!-- Change counts only; the "no differences" state reads as a row label over + the diff panes (DiffViewer), not as an empty +0/−0 here. --> + <span v-if="store.ready && store.stats && !store.identical" class="stats"> + <span class="add">+{{ store.stats.additions }}</span> + <span class="del">−{{ store.stats.deletions }}</span> </span> <div class="options"> @@ -38,11 +48,11 @@ const store = useDiffStore() <div class="group actions"> <button class="btn btn-ghost" - :class="{ active: store.mode === 'paste' }" - :title="`Compare pasted text (${MOD}+T)`" + :class="{ active: inPaste }" + :title="pasteToggleTitle" @click="store.togglePasteMode" > - Paste text + {{ pasteToggleLabel }} </button> <button class="btn btn-primary" @@ -72,6 +82,24 @@ const store = useDiffStore() Clear </button> </div> + + <span class="divider" /> + + <!-- Sidebar controls --> + <div class="group"> + <button + class="icon-btn" + :class="{ active: settings.sectionsLocked }" + :title=" + settings.sectionsLocked + ? 'Unlock sidebar section order' + : 'Lock sidebar section order' + " + @click="settings.toggleSectionsLock()" + > + <AppIcon :name="settings.sectionsLocked ? 'lock' : 'unlock'" /> + </button> + </div> </div> </header> </template> diff --git a/src/renderer/src/components/DiffViewer.vue b/src/renderer/src/components/DiffViewer.vue index 598a9ee..b96c3a3 100644 --- a/src/renderer/src/components/DiffViewer.vue +++ b/src/renderer/src/components/DiffViewer.vue @@ -99,6 +99,12 @@ onBeforeUnmount(() => { <template> <div class="diff-viewer"> + <!-- Identical sides: a row label right over the panes says so, rather than a + far-off toolbar note (the panes themselves show no change markers). --> + <div v-if="store.identical" class="identical-row"> + <AppIcon name="check" class="ok" /> + <span>No differences — both sides are identical</span> + </div> <div class="search"> <div v-for="s in [ diff --git a/src/renderer/src/components/ErrorReportDialog.vue b/src/renderer/src/components/ErrorReportDialog.vue new file mode 100644 index 0000000..6326b48 --- /dev/null +++ b/src/renderer/src/components/ErrorReportDialog.vue @@ -0,0 +1,47 @@ +<script setup> +import { ref } from 'vue' +import { useErrorStore } from '../stores/errorStore' +import BaseDialog from './BaseDialog.vue' + +const store = useErrorStore() +const copied = ref(false) + +function close() { + store.dismiss() +} + +// Copy today's log through the main-process clipboard (navigator.clipboard is +// blocked by the deny-all permission handler — see clipboard.js). +async function copyLog() { + const { content } = await window.api.readLog() + await window.api.copyText(content || '(the log is empty)') + copied.value = true + setTimeout(() => (copied.value = false), 1500) +} +function reveal() { + window.api.revealLog() +} +function report() { + window.api.reportIssue() +} +</script> + +<template> + <BaseDialog width="460px" title="Something went wrong" @close="close"> + <p class="dialog-note"> + Diff Bro hit an unexpected error. It's been written to a log + <strong>on your machine only</strong> — nothing was sent anywhere. If it keeps happening, + please report it so it can be fixed: copy the log and paste it into the issue. + </p> + <p v-if="store.lastError" class="err-msg">{{ store.lastError.message }}</p> + + <template #actions> + <button class="btn btn-ghost" @click="reveal">Reveal log</button> + <button class="btn btn-ghost" @click="copyLog">{{ copied ? 'Copied' : 'Copy log' }}</button> + <button class="btn btn-ghost" @click="report">Report on GitHub</button> + <button class="btn btn-primary" @click="close">Dismiss</button> + </template> + </BaseDialog> +</template> + +<style scoped src="./styles/ErrorReportDialog.css"></style> diff --git a/src/renderer/src/components/FileSlot.vue b/src/renderer/src/components/FileSlot.vue index c7ce0f6..0f8139a 100644 --- a/src/renderer/src/components/FileSlot.vue +++ b/src/renderer/src/components/FileSlot.vue @@ -4,9 +4,11 @@ defineProps({ side: { type: String, required: true }, /** @type {import('vue').PropType<import('../types').LoadedFile|null>} */ file: { + // A text file carries `content`; a spreadsheet carries `sheets` instead — + // the slot only ever reads `name`/`path`, so that's all it requires. type: Object, default: null, - validator: (v) => v === null || shaped('name', 'content')(v) + validator: (v) => v === null || shaped('name')(v) }, // True when this slot is empty and the other side already has a file, so we // can visibly prompt for the second file. diff --git a/src/renderer/src/components/FormatHintBanner.vue b/src/renderer/src/components/FormatHintBanner.vue index 1396b74..f5b83f9 100644 --- a/src/renderer/src/components/FormatHintBanner.vue +++ b/src/renderer/src/components/FormatHintBanner.vue @@ -1,30 +1,28 @@ <script setup> +// One banner for both sides. When both are minified JSON/XML it reads "Both +// sides look like JSON — pretty-print?" with a single Format-both action; a +// mixed or single-side case names the side(s). The merge lives in the store +// (diffStore.formatBanner) so this stays a thin renderer. import { computed } from 'vue' import { useDiffStore } from '../stores/diffStore' -const props = defineProps({ side: { type: String, required: true } }) const store = useDiffStore() - -const hint = computed(() => (props.side === 'left' ? store.leftFormatHint : store.rightFormatHint)) -const sideLabel = computed(() => (props.side === 'left' ? 'Left' : 'Right')) -const kindLabel = computed(() => (hint.value?.kind === 'json' ? 'JSON' : 'XML')) -const location = computed(() => - hint.value?.line ? ` at line ${hint.value.line}, column ${hint.value.column}` : '' -) +const banner = computed(() => store.formatBanner) </script> <template> - <div v-if="hint" class="hint" :class="{ invalid: !hint.valid }"> - <span v-if="hint.valid"> - <strong>{{ sideLabel }}</strong> looks like {{ kindLabel }} — pretty-print it? - </span> - <span v-else> - <strong>{{ sideLabel }}</strong> looks like {{ kindLabel }} but doesn't parse{{ location - }}{{ hint.error ? `: ${hint.error}` : '' }} - </span> + <div v-if="banner" class="hint" :class="{ invalid: banner.invalid }"> + <span class="msg">{{ banner.message }}</span> <div class="actions"> - <button v-if="hint.valid" class="format" @click="store.formatSide(side)">Format</button> - <button class="dismiss" @click="store.dismissFormatHint(side)">Dismiss</button> + <button v-if="banner.formatBoth" class="format" @click="store.formatBoth()">Format both</button> + <button + v-else-if="banner.formatSide" + class="format" + @click="store.formatSide(banner.formatSide)" + > + {{ banner.formatLabel }} + </button> + <button class="dismiss" @click="store.dismissFormatHints(banner.dismissSides)">Dismiss</button> </div> </div> </template> diff --git a/src/renderer/src/components/LogSettings.vue b/src/renderer/src/components/LogSettings.vue new file mode 100644 index 0000000..8de01bd --- /dev/null +++ b/src/renderer/src/components/LogSettings.vue @@ -0,0 +1,87 @@ +<script setup> +import { computed, onMounted, ref } from 'vue' +import { useArmedAction } from '../composables/useArmedAction' + +// The "Logs" settings pane: where the local error log lives, plus reveal/clear. +// All fs work is in the main process (window.api.log*) — the renderer only +// triggers it and shows the current path. +const dir = ref('') +const isDefault = ref(true) +const busy = ref(false) +const cleared = ref(false) + +async function refresh() { + const res = await window.api.logDirGet() + dir.value = res.dir + isDefault.value = res.isDefault +} +onMounted(refresh) + +async function choose() { + busy.value = true + try { + const res = await window.api.logDirChoose() + if (res.ok) await refresh() + } finally { + busy.value = false + } +} +async function reset() { + busy.value = true + try { + await window.api.logDirReset() + await refresh() + } finally { + busy.value = false + } +} +function reveal() { + window.api.revealLog() +} + +// Deleting the logs is destructive and irreversible, so it's a two-step confirm +// (first click arms, second click within the window actually clears). +async function doClear() { + await window.api.clearLog() + cleared.value = true + setTimeout(() => (cleared.value = false), 1500) +} +const { armed: clearArmed, trigger: requestClear } = useArmedAction(doClear) +const clearLabel = computed(() => + cleared.value ? 'Cleared' : clearArmed.value ? 'Confirm clear' : 'Clear logs' +) +</script> + +<template> + <section> + <h4>Logs</h4> + <p class="dialog-note"> + Diff Bro writes unexpected errors to a local log so you can paste it into a bug report. One + file per day is kept for a week, and <strong>nothing is ever sent anywhere</strong>. Choose + where the log files are stored. + </p> + <div class="path"> + <code :title="dir">{{ dir }}</code> + <span v-if="isDefault" class="badge">default</span> + </div> + <div class="dialog-actions"> + <button class="btn btn-ghost" :disabled="busy" @click="reveal">Reveal</button> + <button + class="btn btn-ghost" + :class="{ armed: clearArmed }" + :title=" + clearArmed + ? 'Click again to permanently delete every log file' + : 'Delete all local log files' + " + @click="requestClear" + > + {{ clearLabel }} + </button> + <button class="btn btn-ghost" :disabled="busy || isDefault" @click="reset">Use default</button> + <button class="btn btn-primary" :disabled="busy" @click="choose">Change folder…</button> + </div> + </section> +</template> + +<style scoped src="./styles/SettingsDialog.css"></style> diff --git a/src/renderer/src/components/MermaidViewerDialog.vue b/src/renderer/src/components/MermaidViewerDialog.vue index 5ac0aef..2ad53d7 100644 --- a/src/renderer/src/components/MermaidViewerDialog.vue +++ b/src/renderer/src/components/MermaidViewerDialog.vue @@ -1,61 +1,53 @@ <script setup> -// Full-screen-ish, resizable Mermaid viewer. Opened from the snippet editor's +// Full-screen-ish, movable Mermaid viewer. Opened from the snippet editor's // live preview or a Mermaid snippet row (diffStore.mermaidView). Supports -// zoom (buttons + Ctrl/⌘-wheel), drag-to-pan, Fit, and a maximize toggle; the -// panel itself is CSS-resizable so users can size it to the diagram. -import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +// zoom (buttons + Ctrl/⌘-wheel), drag-to-pan, Fit, a maximize toggle, and +// drag-resize from any of its four corners (useResizable). When the app window +// itself goes fullscreen the panel maximises to fill it (useFullScreen). +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useDiffStore } from '../stores/diffStore' import { useBackdropClose } from '../composables/useBackdropClose' +import { useResizable } from '../composables/useResizable' +import { useFullScreen } from '../composables/useFullScreen' +import { useZoomPan } from '../composables/useZoomPan' import MermaidDiagram from './MermaidDiagram.vue' import AppIcon from './AppIcon.vue' const diff = useDiffStore() const view = computed(() => diff.mermaidView) // { name, code } +const isFullScreen = useFullScreen() -const scale = ref(1) -const tx = ref(0) -const ty = ref(0) -const maxed = ref(false) +const DEFAULT_W = 880 +const DEFAULT_H = 620 +const MIN = { width: 360, height: 260 } +const CORNERS = ['nw', 'ne', 'sw', 'se'] -const SCALE_MIN = 0.2 -const SCALE_MAX = 8 -const pct = computed(() => Math.round(scale.value * 100)) +const { rect, setCentered, beginResize } = useResizable({ min: MIN }) +const { scale, tx, ty, pct, zoom, fit, onWheel, onDown, onMove, onUp } = useZoomPan() +const maxed = ref(false) -function clamp(v) { - return Math.min(SCALE_MAX, Math.max(SCALE_MIN, v)) -} -function zoom(factor) { - scale.value = clamp(scale.value * factor) -} -function fit() { - // The diagram is constrained to the viewport by CSS (max-width/height 100%), - // so scale 1 with no offset is the fitted view. - scale.value = 1 - tx.value = 0 - ty.value = 0 -} -function onWheel(e) { - if (!e.ctrlKey && !e.metaKey) return // plain scroll left for the OS/trackpad - e.preventDefault() - zoom(e.deltaY < 0 ? 1.1 : 1 / 1.1) -} +const panelStyle = computed(() => ({ + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px` +})) -// Drag to pan. -let dragging = false -let startX = 0 -let startY = 0 -function onDown(e) { - dragging = true - startX = e.clientX - tx.value - startY = e.clientY - ty.value +function fitPanel() { + const vw = window.innerWidth + const vh = window.innerHeight + if (maxed.value) setCentered(vw * 0.96, vh * 0.92) + else setCentered(Math.min(DEFAULT_W, vw * 0.9), Math.min(DEFAULT_H, vh * 0.82)) } -function onMove(e) { - if (!dragging) return - tx.value = e.clientX - startX - ty.value = e.clientY - startY +function toggleMaxed() { + maxed.value = !maxed.value + fitPanel() } -function onUp() { - dragging = false +// A manual corner drag means the user wants a custom size; drop the maxed flag +// so the maximize button offers to fill again rather than to restore. +function startResize(corner, e) { + maxed.value = false + beginResize(corner, e) } function close() { @@ -68,13 +60,34 @@ const { onPointerDown: onBackdropDown, onClick: onBackdropClick } = useBackdropC function onKey(e) { if (e.key === 'Escape') close() } -onMounted(() => window.addEventListener('keydown', onKey)) -onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) +// Keep a maximised panel filling the window as it (or the OS fullscreen) resizes. +function onWindowResize() { + if (maxed.value) fitPanel() +} +// Entering app fullscreen fills the window; leaving it does not shrink a panel +// the user may have grown, so only react on the way in. +watch(isFullScreen, (on) => { + if (on) { + maxed.value = true + fitPanel() + } +}) + +onMounted(() => { + maxed.value = isFullScreen.value + fitPanel() + window.addEventListener('keydown', onKey) + window.addEventListener('resize', onWindowResize) +}) +onBeforeUnmount(() => { + window.removeEventListener('keydown', onKey) + window.removeEventListener('resize', onWindowResize) +}) </script> <template> <div v-if="view" class="viewer-backdrop" @pointerdown="onBackdropDown" @click="onBackdropClick"> - <div class="panel" :class="{ maxed }"> + <div class="panel" :style="panelStyle"> <div class="head"> <span class="title">{{ view.name || 'Diagram' }}</span> <div class="tools"> @@ -84,7 +97,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) <span class="pct" @click="fit">{{ pct }}%</span> <button class="tbtn" title="Zoom in" @click="zoom(1.2)"><AppIcon name="plus" /></button> <button class="tbtn wide" title="Fit to window" @click="fit">Fit</button> - <button class="tbtn" :title="maxed ? 'Restore size' : 'Maximize'" @click="maxed = !maxed"> + <button + class="tbtn" + :title="maxed ? 'Restore size' : 'Maximize'" + @click="toggleMaxed" + > <AppIcon :name="maxed ? 'restore' : 'maximize'" /> </button> <button class="tbtn close" title="Close (Esc)" @click="close"> @@ -109,8 +126,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) </div> <div class="foot"> <span class="hint">Drag to pan · Ctrl/⌘ + scroll to zoom · click % to fit</span> - <span class="resize-hint">↘ drag corner to resize</span> + <span class="resize-hint">drag any corner to resize</span> </div> + <span + v-for="c in CORNERS" + :key="c" + class="resize-handle" + :class="c" + @pointerdown="startResize(c, $event)" + /> </div> </div> </template> diff --git a/src/renderer/src/components/NyanLane.vue b/src/renderer/src/components/NyanLane.vue new file mode 100644 index 0000000..08abedd --- /dev/null +++ b/src/renderer/src/components/NyanLane.vue @@ -0,0 +1,30 @@ +<script setup> +// A slim rainbow lane under the toolbar, present only in the Nyan theme (App.vue +// mounts it with v-if). The cat loops across it continuously, trailing a rainbow +// that dissipates like smoke behind it — pure ambient whimsy, kept in the chrome +// so it never touches the diff. Decorative → aria-hidden, no logic to test. +</script> + +<template> + <div class="nyan-lane" aria-hidden="true"> + <div class="flyer"> + <div class="trail"></div> + <svg class="nyan-body" viewBox="0 0 54 30"> + <rect x="14" y="7" width="27" height="16" rx="3" fill="#f7c8d8" stroke="#c04a6a" stroke-width="2" /> + <circle cx="22" cy="13" r="1.5" fill="#3a2a30" /> + <circle cx="31" cy="13" r="1.5" fill="#3a2a30" /> + <circle cx="20" cy="16.5" r="1.5" fill="#ff9dc0" /> + <circle cx="33" cy="16.5" r="1.5" fill="#ff9dc0" /> + <rect x="24" y="16.5" width="4" height="2" fill="#c04a6a" /> + <rect x="39" y="9" width="11" height="13" rx="3" fill="#9aa0a6" /> + <path d="M40 9 l3 -4 l3 4 z M47 9 l3 -4 l3 4 z" fill="#9aa0a6" /> + <circle cx="45" cy="14.5" r="1.3" fill="#111" /> + <circle cx="49" cy="14.5" r="1.3" fill="#111" /> + <rect x="16" y="23" width="4" height="4" fill="#6b7075" /> + <rect x="34" y="23" width="4" height="4" fill="#6b7075" /> + </svg> + </div> + </div> +</template> + +<style scoped src="./styles/NyanLane.css"></style> diff --git a/src/renderer/src/components/PasteConfirmDialog.vue b/src/renderer/src/components/PasteConfirmDialog.vue new file mode 100644 index 0000000..5c65d4d --- /dev/null +++ b/src/renderer/src/components/PasteConfirmDialog.vue @@ -0,0 +1,34 @@ +<script setup> +import { computed } from 'vue' +import { useDiffStore } from '../stores/diffStore' +import BaseDialog from './BaseDialog.vue' + +const store = useDiffStore() +const overwrite = computed(() => store.pastePrompt === 'overwrite') + +function confirm() { + if (overwrite.value) store.confirmPasteOverwrite() + else store.confirmPasteEnter() +} +</script> + +<template> + <BaseDialog + width="420px" + :title="overwrite ? 'Overwrite pasted text?' : 'Paste detected'" + @close="store.cancelPaste()" + > + <p v-if="overwrite" class="dialog-note"> + Both paste fields already have content. Paste your clipboard into the left side, replacing it? + The unsaved text there will be lost. + </p> + <p v-else class="dialog-note"> + Switch to paste mode and paste your clipboard into the comparison? Your clipboard is only read + after you confirm. + </p> + <template #actions> + <button class="btn btn-ghost" @click="store.cancelPaste()">Cancel</button> + <button class="btn btn-primary" @click="confirm">{{ overwrite ? 'Overwrite' : 'Paste' }}</button> + </template> + </BaseDialog> +</template> diff --git a/src/renderer/src/components/SectionHeader.vue b/src/renderer/src/components/SectionHeader.vue index 9cd0dad..fa41cf7 100644 --- a/src/renderer/src/components/SectionHeader.vue +++ b/src/renderer/src/components/SectionHeader.vue @@ -1,10 +1,11 @@ <script setup> // The band at the top of each reorderable sidebar section: a collapse chevron, -// the title, an optional actions slot, and up/down controls that move the whole -// section (persisted in settings). Shared so Saved / External / Snippets read -// and behave identically. +// the title, an optional actions slot, and up/down steppers. The whole header is +// the drag handle for reordering (unless locked); the single lock lives in the +// top toolbar, not here. Shared so Saved / External / Snippets behave identically. import { computed } from 'vue' import { useSettingsStore } from '../stores/settingsStore' +import { useSectionReorder } from '../composables/useSectionReorder' import AppIcon from './AppIcon.vue' const props = defineProps({ @@ -17,19 +18,33 @@ const props = defineProps({ defineEmits(['toggle']) const settings = useSettingsStore() +const { onDragStart, onDragEnd, onDrop, isDropTarget } = useSectionReorder() + +const locked = computed(() => settings.sectionsLocked) const index = computed(() => settings.orderedSections.indexOf(props.sectionId)) -const canUp = computed(() => index.value > 0) +const canUp = computed(() => !locked.value && index.value > 0) const canDown = computed( - () => index.value > -1 && index.value < settings.orderedSections.length - 1 + () => !locked.value && index.value > -1 && index.value < settings.orderedSections.length - 1 ) +const dropTarget = computed(() => isDropTarget(props.sectionId)) </script> <template> - <div class="head section-head band band-row" :class="{ first }" @click="$emit('toggle')"> + <div + class="head section-head band band-row" + :class="{ first, 'drop-target': dropTarget, draggable: !locked }" + :draggable="!locked" + :title="locked ? null : 'Drag to reorder — lock in the toolbar to freeze'" + @click="$emit('toggle')" + @dragstart="onDragStart(sectionId, $event)" + @dragend="onDragEnd" + @dragover.prevent + @drop.prevent="onDrop(sectionId)" + > <AppIcon class="chev" :class="{ open }" name="chevron-right" /> <span class="section-title">{{ title }}</span> <span v-if="$slots.actions" class="actions-slot"><slot name="actions" /></span> - <span class="reorder"> + <span v-if="!locked" class="reorder"> <button class="reorder-btn" :disabled="!canUp" diff --git a/src/renderer/src/components/SettingsDialog.vue b/src/renderer/src/components/SettingsDialog.vue index 7542112..7b8499c 100644 --- a/src/renderer/src/components/SettingsDialog.vue +++ b/src/renderer/src/components/SettingsDialog.vue @@ -3,11 +3,12 @@ import { onMounted, ref } from 'vue' import { useDiffStore } from '../stores/diffStore' import { useSettingsStore, - MAX_COMPARISON_FILE_MB_CAP, + FILE_TYPE_LIMITS, MAX_SNIPPET_SIZE_KB_CAP } from '../stores/settingsStore' import { THEMES } from '../utils/themes' import BaseDialog from './BaseDialog.vue' +import LogSettings from './LogSettings.vue' const diff = useDiffStore() const settings = useSettingsStore() @@ -20,8 +21,17 @@ const busy = ref(false) const TABS = [ { id: 'appearance', label: 'Appearance' }, { id: 'storage', label: 'Storage' }, - { id: 'limits', label: 'Limits' } + { id: 'limits', label: 'Limits' }, + { id: 'logs', label: 'Logs' }, + { id: 'fun', label: 'Fun' } ] + +// Toggle daily theme rotation, then re-resolve the active theme so it applies +// (or reverts to the Appearance choice) immediately. +function toggleDailyTheme(on) { + settings.setRotateThemeDaily(on) + diff.resolveActiveTheme() +} const tab = ref('appearance') async function refresh() { @@ -128,16 +138,34 @@ function close() { <p class="hint">Changing the folder restarts Diff Bro.</p> </section> + <LogSettings v-else-if="tab === 'logs'" /> + + <section v-else-if="tab === 'fun'"> + <h4>Fun</h4> + <label class="row toggle"> + <input + type="checkbox" + :checked="settings.rotateThemeDaily" + @change="toggleDailyTheme($event.target.checked)" + /> + <span>Rotate the app theme daily — a new random theme each day</span> + </label> + <p class="hint"> + Off by default. When off, Diff Bro uses the theme you picked under Appearance. The daily + theme is the same all day and changes at midnight. + </p> + </section> + <section v-else> <h4>Limits</h4> - <label class="row"> - <span>Max comparison file (MB)</span> + <label v-for="(spec, type) in FILE_TYPE_LIMITS" :key="type" class="row"> + <span>Max {{ spec.label }} file (MB)</span> <input type="number" min="1" - :max="MAX_COMPARISON_FILE_MB_CAP" - :value="settings.maxComparisonFileMb" - @change="settings.setMaxComparisonFileMb($event.target.value)" + :max="spec.cap" + :value="settings.fileSizeLimitMb(type)" + @change="settings.setFileSizeLimitMb(type, $event.target.value)" /> </label> <label class="row"> diff --git a/src/renderer/src/components/SheetTabBar.vue b/src/renderer/src/components/SheetTabBar.vue new file mode 100644 index 0000000..e5b0610 --- /dev/null +++ b/src/renderer/src/components/SheetTabBar.vue @@ -0,0 +1,37 @@ +<script setup> +import { arrayOfShape } from '../utils/props' + +defineProps({ + /** @type {import('vue').PropType<Array<object>>} */ + sheets: { type: Array, required: true, validator: arrayOfShape('name', 'present', 'changes') }, + active: { type: Number, required: true } +}) +defineEmits(['select']) +</script> + +<template> + <div class="sheet-tabs band" role="tablist"> + <button + v-for="(sheet, i) in sheets" + :key="sheet.name" + class="tab" + :class="{ active: i === active, missing: sheet.present !== 'both' }" + role="tab" + :aria-selected="i === active" + :title=" + sheet.present === 'left' + ? 'Only in the left file' + : sheet.present === 'right' + ? 'Only in the right file' + : `${sheet.changes} change${sheet.changes === 1 ? '' : 's'}` + " + @click="$emit('select', i)" + > + <span class="name">{{ sheet.name || '(unnamed)' }}</span> + <span v-if="sheet.present !== 'both'" class="badge only">only {{ sheet.present }}</span> + <span v-else-if="sheet.changes" class="badge">{{ sheet.changes }}</span> + </button> + </div> +</template> + +<style scoped src="./styles/SheetTabBar.css"></style> diff --git a/src/renderer/src/components/SpreadsheetDiffViewer.vue b/src/renderer/src/components/SpreadsheetDiffViewer.vue new file mode 100644 index 0000000..b14c1c0 --- /dev/null +++ b/src/renderer/src/components/SpreadsheetDiffViewer.vue @@ -0,0 +1,53 @@ +<script setup> +import { computed, onMounted } from 'vue' +import { useDiffStore } from '../stores/diffStore' +import { useSpreadsheetDiff } from '../composables/useSpreadsheetDiff' +import { pageRows } from '../utils/spreadsheetDiff' +import SheetTabBar from './SheetTabBar.vue' +import SpreadsheetGrid from './SpreadsheetGrid.vue' +import AppIcon from './AppIcon.vue' + +const store = useDiffStore() +const { sheets, active, activeSheet, totals, identical, select } = useSpreadsheetDiff() + +// Cap rendered rows so a huge sheet can't freeze the window (no virtualization +// yet — see pageRows). +const view = computed(() => pageRows(activeSheet.value?.rows ?? [])) + +// The +/− toolbar stat is Monaco line-diff language; the grid owns a richer +// changed/added/removed strip instead, so clear the shared stat while it's up. +onMounted(() => { + store.stats = null +}) +</script> + +<template> + <div class="sheet-viewer"> + <SheetTabBar :sheets="sheets" :active="active" @select="select" /> + + <div v-if="identical" class="identical-row"> + <AppIcon name="check" class="ok" /> + <span>No differences — every sheet matches</span> + </div> + + <div v-if="activeSheet" class="grids"> + <SpreadsheetGrid :rows="view.rows" side="left" :columns="activeSheet.columns" /> + <SpreadsheetGrid :rows="view.rows" side="right" :columns="activeSheet.columns" /> + </div> + + <div class="status band"> + <span v-if="activeSheet && activeSheet.present !== 'both'" class="only"> + “{{ activeSheet.name }}” is only in the {{ activeSheet.present }} file + </span> + <template v-else> + <span class="chg">◆ {{ totals.changed }} changed</span> + <span class="add">+{{ totals.added }} rows</span> + <span class="del">−{{ totals.removed }} rows</span> + </template> + <span v-if="view.hidden" class="capped">first {{ view.rows.length }} rows shown</span> + <span class="right">{{ sheets.length }} sheet{{ sheets.length === 1 ? '' : 's' }}</span> + </div> + </div> +</template> + +<style scoped src="./styles/SpreadsheetDiffViewer.css"></style> diff --git a/src/renderer/src/components/SpreadsheetGrid.vue b/src/renderer/src/components/SpreadsheetGrid.vue new file mode 100644 index 0000000..33d6dad --- /dev/null +++ b/src/renderer/src/components/SpreadsheetGrid.vue @@ -0,0 +1,57 @@ +<script setup> +import { columnName } from '../utils/spreadsheetDiff' + +const props = defineProps({ + /** @type {import('vue').PropType<Array<object>>} */ + rows: { type: Array, required: true }, + side: { type: String, required: true }, // 'left' | 'right' + columns: { type: Number, required: true } +}) + +const rowData = (entry) => (props.side === 'left' ? entry.left : entry.right) +const rowIndex = (entry) => (props.side === 'left' ? entry.leftIndex : entry.rightIndex) + +// A row is a "ghost" (striped gap) on the side that has no counterpart, so both +// grids keep the same height row-for-row and stay aligned. +function rowClass(entry) { + if (rowData(entry) === null) return 'ghost' + if (entry.status === 'changed') return 'changed' + if (entry.status === 'removed' && props.side === 'left') return 'removed' + if (entry.status === 'added' && props.side === 'right') return 'added' + return '' +} + +function cellClass(entry, col) { + const value = rowData(entry)?.[col] + const numeric = typeof value === 'number' + const changed = entry.status === 'changed' && entry.changed.includes(col) + return { num: numeric, 'cell-chg': changed } +} + +function formatCell(value) { + if (value === null || value === undefined) return '' + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE' + return String(value) +} +</script> + +<template> + <table class="grid"> + <thead> + <tr> + <th class="rownum"></th> + <th v-for="c in columns" :key="c">{{ columnName(c - 1) }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(entry, r) in rows" :key="r" :class="rowClass(entry)"> + <th class="rownum">{{ rowData(entry) === null ? '·' : rowIndex(entry) + 1 }}</th> + <td v-for="c in columns" :key="c" :class="cellClass(entry, c - 1)"> + {{ formatCell(rowData(entry)?.[c - 1]) }} + </td> + </tr> + </tbody> + </table> +</template> + +<style scoped src="./styles/SpreadsheetGrid.css"></style> diff --git a/src/renderer/src/components/SupportedFormats.vue b/src/renderer/src/components/SupportedFormats.vue new file mode 100644 index 0000000..c484d90 --- /dev/null +++ b/src/renderer/src/components/SupportedFormats.vue @@ -0,0 +1,32 @@ +<script setup> +import AppIcon from './AppIcon.vue' + +// Shown under the empty-state prompt so it's obvious what can be dropped. Only +// .xlsx gets the structural grid diff; everything else diffs as text, so this +// list is illustrative, not exhaustive — hence the trailing "& any text or code +// file". `featured` highlights the headline capability (Excel). +const FORMATS = [ + { label: 'Excel', ext: '.xlsx', icon: 'table', featured: true }, + { label: 'JSON', icon: 'code' }, + { label: 'XML', icon: 'code' }, + { label: 'YAML', icon: 'code' }, + { label: 'CSV', icon: 'table' }, + { label: 'Markdown', icon: 'file' } +] +</script> + +<template> + <div class="formats"> + <span class="eyebrow">Supported files</span> + <ul class="chips"> + <li v-for="f in FORMATS" :key="f.label" class="chip" :class="{ featured: f.featured }"> + <AppIcon :name="f.icon" /> + <span>{{ f.label }}</span> + <span v-if="f.ext" class="ext">{{ f.ext }}</span> + </li> + </ul> + <p class="tail">…and any text or code file — binary files aren’t supported</p> + </div> +</template> + +<style scoped src="./styles/SupportedFormats.css"></style> diff --git a/src/renderer/src/components/styles/App.css b/src/renderer/src/components/styles/App.css index 9a33304..8799f29 100644 --- a/src/renderer/src/components/styles/App.css +++ b/src/renderer/src/components/styles/App.css @@ -41,6 +41,10 @@ gap: 4px; color: var(--text-dim); } +.empty-title { + font-size: var(--font-xl); + color: var(--text); +} .empty.waiting .waiting-title { font-size: var(--font-xl); color: var(--text); diff --git a/src/renderer/src/components/styles/AppToolbar.css b/src/renderer/src/components/styles/AppToolbar.css index 156121c..33cd59b 100644 --- a/src/renderer/src/components/styles/AppToolbar.css +++ b/src/renderer/src/components/styles/AppToolbar.css @@ -19,9 +19,6 @@ .stats .del { color: var(--danger-bg); } -.stats .identical { - color: var(--text-dim); -} .options { display: flex; align-items: center; @@ -69,3 +66,8 @@ border-color: var(--border); background: var(--bg-hover); } +/* Locked state: the padlock reads as "on" in the accent colour. */ +.icon-btn.active { + color: var(--accent); + border-color: var(--accent); +} diff --git a/src/renderer/src/components/styles/DiffViewer.css b/src/renderer/src/components/styles/DiffViewer.css index 50885ef..c6f746d 100644 --- a/src/renderer/src/components/styles/DiffViewer.css +++ b/src/renderer/src/components/styles/DiffViewer.css @@ -4,6 +4,22 @@ display: flex; flex-direction: column; } +/* Sits directly over the diff panes: a quiet confirmation that there is nothing + to compare, in the success accent so it reads as a benign end-state. */ +.identical-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + font-size: var(--font-sm); + font-weight: 600; + color: var(--success-text); +} +.identical-row .ok { + flex: none; +} .search { display: flex; gap: 10px; diff --git a/src/renderer/src/components/styles/ErrorReportDialog.css b/src/renderer/src/components/styles/ErrorReportDialog.css new file mode 100644 index 0000000..4f8e678 --- /dev/null +++ b/src/renderer/src/components/styles/ErrorReportDialog.css @@ -0,0 +1,13 @@ +.err-msg { + margin: var(--space-3) 0 0; + padding: var(--space-3); + border: 1px solid var(--danger-border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--danger-bg) 12%, transparent); + color: var(--text); + font-family: ui-monospace, monospace; + font-size: var(--font-sm); + word-break: break-word; + max-height: 120px; + overflow-y: auto; +} diff --git a/src/renderer/src/components/styles/FormatHintBanner.css b/src/renderer/src/components/styles/FormatHintBanner.css index 567fd64..2e9158c 100644 --- a/src/renderer/src/components/styles/FormatHintBanner.css +++ b/src/renderer/src/components/styles/FormatHintBanner.css @@ -1,3 +1,8 @@ +/* Theme-tied: a subtle strip tinted from the active theme's ACCENT (a helpful + suggestion, not a loud warning), so it adopts each theme's identity — blue in + Light, pink in Nyan, orange in Solar — instead of a fixed amber. The invalid + state tints from danger instead. Colour lives on color-mix lines (allowed by + the style-token guard) and theme tokens. */ .hint { display: flex; align-items: center; @@ -5,14 +10,16 @@ gap: 12px; padding: 6px 14px; font-size: var(--font-md); - background: var(--warning-bg); - border-bottom: 1px solid var(--warning-border); - color: var(--warning-text); + color: var(--text); + background: color-mix(in srgb, var(--accent) 15%, var(--bg-panel)); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 50%, transparent); } .hint.invalid { - background: var(--danger-bg); - border-bottom-color: var(--danger-border); - color: var(--danger-text); + background: color-mix(in srgb, var(--danger-bg) 16%, var(--bg-panel)); + border-bottom-color: color-mix(in srgb, var(--danger-border) 55%, transparent); +} +.msg { + min-width: 0; /* let a long invalid-parse message wrap instead of shoving the actions off */ } .actions { display: flex; @@ -27,18 +34,23 @@ font-size: var(--font-md); font-weight: 600; } +/* The action carries the strong theme colour; the strip stays subtle. */ .format { - background: var(--warning-text); - border: none; - color: var(--warning-bg); + background: var(--accent); + border: 1px solid var(--accent); + color: var(--text-on-accent); } .hint.invalid .format { - background: var(--danger-text); - color: var(--danger-bg); + background: var(--danger-bg); + border-color: var(--danger-border); + color: var(--danger-text); } .dismiss { background: none; - border: 1px solid currentColor; - color: inherit; - opacity: 0.85; + border: 1px solid var(--border); + color: var(--text-dim); +} +.dismiss:hover { + border-color: var(--accent); + color: var(--accent); } diff --git a/src/renderer/src/components/styles/MermaidViewerDialog.css b/src/renderer/src/components/styles/MermaidViewerDialog.css index cc65fb5..09945d4 100644 --- a/src/renderer/src/components/styles/MermaidViewerDialog.css +++ b/src/renderer/src/components/styles/MermaidViewerDialog.css @@ -11,25 +11,48 @@ standalone window that should cover the diff panes and any dialog. */ z-index: 60; } +/* JS-positioned (useResizable): the panel carries an inline left/top/width/height + so it can be dragged bigger from any corner and moved to fill the window. The + backdrop's flex-centering is only the fallback before the first layout tick. */ .panel { + position: absolute; display: flex; flex-direction: column; - width: min(880px, 90vw); - height: min(620px, 82vh); - min-width: 360px; - min-height: 260px; - max-width: 96vw; - max-height: 92vh; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5); overflow: hidden; - resize: both; } -.panel.maxed { - width: 96vw; - height: 92vh; +/* Four invisible corner grips. Each sits just inside its corner and shows the + matching diagonal resize cursor; the opposite corner stays pinned as you drag + (geometry in utils/resizeRect). */ +.resize-handle { + position: absolute; + width: 16px; + height: 16px; + z-index: 1; + touch-action: none; +} +.resize-handle.nw { + top: 0; + left: 0; + cursor: nwse-resize; +} +.resize-handle.se { + bottom: 0; + right: 0; + cursor: nwse-resize; +} +.resize-handle.ne { + top: 0; + right: 0; + cursor: nesw-resize; +} +.resize-handle.sw { + bottom: 0; + left: 0; + cursor: nesw-resize; } .head { display: flex; diff --git a/src/renderer/src/components/styles/NyanLane.css b/src/renderer/src/components/styles/NyanLane.css new file mode 100644 index 0000000..f7d4463 --- /dev/null +++ b/src/renderer/src/components/styles/NyanLane.css @@ -0,0 +1,78 @@ +.nyan-lane { + position: relative; + height: 26px; + flex: none; + overflow: hidden; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); +} +/* Faint idle rainbow hairline. All color literals sit on the gradient lines, + which the style-token guard allows (kept single-line so the guard sees it). */ +.nyan-lane::before { + content: ''; + position: absolute; + inset: 0; + opacity: 0.14; + background: repeating-linear-gradient(90deg, #ff2e2e 0 16px, #ff9f2e 16px 32px, #ffe62e 32px 48px, #39ff14 48px 64px, #2ec9ff 64px 80px, #b14fff 80px 96px); +} + +/* The cat + a fixed-length rainbow ribbon glide across as one, looping. Animated + via TRANSFORM (not left) so it's GPU-composited — crisp, no sub-pixel blur — + and slow enough to read clearly. The ribbon's trailing end masks to + transparent so it dissipates like smoke behind the cat. */ +.flyer { + position: absolute; + top: 50%; + left: 0; + display: flex; + align-items: center; + /* Keep the cat on its own GPU layer so the transform glide is composited (not + repainted) each frame — the prerequisite for hitting the display's refresh + rate (60 Hz+) without janking against main-thread work like Monaco diffing. */ + will-change: transform; + backface-visibility: hidden; + animation: nyan-fly 10s linear infinite; +} +@keyframes nyan-fly { + from { + transform: translate(-260px, -50%); + } + to { + transform: translate(calc(100vw + 60px), -50%); + } +} +.flyer .trail { + width: 210px; + height: 18px; + background: repeating-linear-gradient(180deg, #ff2e2e 0 3px, #ff9f2e 3px 6px, #ffe62e 6px 9px, #39ff14 9px 12px, #2ec9ff 12px 15px, #b14fff 15px 18px); + -webkit-mask-image: linear-gradient(90deg, transparent, #000 55%); + mask-image: linear-gradient(90deg, transparent, #000 55%); +} +/* Pull the cat left so its pop-tart body (which starts ~26% into the SVG box) + meets the trail — no gap between rainbow and cat. */ +.flyer .nyan-body { + width: 44px; + margin-left: -12px; + animation: nyan-bob 1.1s ease-in-out infinite alternate; +} +/* Smooth (not stepped), gentle low-amplitude bob. */ +@keyframes nyan-bob { + from { + transform: translateY(-1.5px); + } + to { + transform: translateY(1.5px); + } +} + +/* Reduced motion: no glide or bob — park the cat statically in the lane so the + theme still shows its personality without movement. */ +@media (prefers-reduced-motion: reduce) { + .flyer { + animation: none; + transform: translate(42vw, -50%); + } + .flyer .nyan-body { + animation: none; + } +} diff --git a/src/renderer/src/components/styles/SectionHeader.css b/src/renderer/src/components/styles/SectionHeader.css index 65f1d19..2e28f76 100644 --- a/src/renderer/src/components/styles/SectionHeader.css +++ b/src/renderer/src/components/styles/SectionHeader.css @@ -51,6 +51,19 @@ .reorder { gap: 2px; } +/* The whole header is the drag handle when unlocked — the grab cursor is the + only affordance needed. A plain click still toggles the section collapsed. */ +.head.draggable { + cursor: grab; +} +.head.draggable:active { + cursor: grabbing; +} +/* The header a dragged section would drop before: a top accent rule marks the + insertion point. */ +.head.drop-target { + box-shadow: inset 0 2px 0 0 var(--accent); +} .reorder-btn { display: inline-flex; align-items: center; diff --git a/src/renderer/src/components/styles/SettingsDialog.css b/src/renderer/src/components/styles/SettingsDialog.css index caa10f4..d0cd8c7 100644 --- a/src/renderer/src/components/styles/SettingsDialog.css +++ b/src/renderer/src/components/styles/SettingsDialog.css @@ -1,3 +1,11 @@ +/* An armed destructive button (two-step confirm — see useArmedAction), used by + Clear logs: turns danger-coloured while it waits for the confirming click. */ +.btn.armed { + border-color: var(--danger-border); + color: var(--danger-bg); + font-weight: 600; +} + /* Left rail navigates domains; the pane shows one at a time so the window stays scannable instead of one long scroll. */ .settings-body { diff --git a/src/renderer/src/components/styles/SheetTabBar.css b/src/renderer/src/components/styles/SheetTabBar.css new file mode 100644 index 0000000..c2714b2 --- /dev/null +++ b/src/renderer/src/components/styles/SheetTabBar.css @@ -0,0 +1,48 @@ +.sheet-tabs { + gap: 2px; + padding: 0 var(--space-2); + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex: none; +} +.sheet-tabs .tab { + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: var(--control-h); + padding: 0 var(--space-3); + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--text-dim); + font-size: var(--font-md); + white-space: nowrap; + cursor: pointer; +} +.sheet-tabs .tab:hover { + color: var(--text); + background: var(--bg-hover); +} +.sheet-tabs .tab.active { + color: var(--text); + border-bottom-color: var(--accent); + font-weight: 600; +} +.sheet-tabs .tab .badge { + font-size: var(--font-2xs); + font-weight: 700; + padding: 1px var(--space-2); + border-radius: var(--radius-pill); + /* changed-count chip: the warning bg/text pairing is contrast-guaranteed in + both themes (that's what the tokens are for). */ + background: var(--warning-bg); + color: var(--warning-text); +} +.sheet-tabs .tab .badge.only { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); +} +.sheet-tabs .tab.missing .name { + font-style: italic; +} diff --git a/src/renderer/src/components/styles/SpreadsheetDiffViewer.css b/src/renderer/src/components/styles/SpreadsheetDiffViewer.css new file mode 100644 index 0000000..4874c09 --- /dev/null +++ b/src/renderer/src/components/styles/SpreadsheetDiffViewer.css @@ -0,0 +1,67 @@ +.sheet-viewer { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* The two grids share one horizontal scroll region so a wide sheet scrolls as a + unit and the sides never drift out of column alignment. */ +.grids { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + overflow: auto; + align-content: start; +} +.grids > :first-child { + border-right: 2px solid var(--border); +} + +.identical-row { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + color: var(--success-text); + font-size: var(--font-md); +} +.identical-row .ok { + flex: none; +} + +.status { + gap: var(--space-4); + padding: 0 var(--space-3); + min-height: var(--control-h); + background: var(--bg-panel); + border-top: 1px solid var(--border); + color: var(--text-dim); + font-size: var(--font-sm); +} +.status .chg { + color: var(--warning-border); + font-weight: 600; +} +.status .add { + color: var(--success-text); + font-weight: 600; +} +.status .del { + color: var(--danger-border); + font-weight: 600; +} +.status .only { + font-style: italic; +} +.status .capped { + color: var(--warning-border); + font-style: italic; +} +.status .right { + margin-left: auto; +} diff --git a/src/renderer/src/components/styles/SpreadsheetGrid.css b/src/renderer/src/components/styles/SpreadsheetGrid.css new file mode 100644 index 0000000..2bfc8fc --- /dev/null +++ b/src/renderer/src/components/styles/SpreadsheetGrid.css @@ -0,0 +1,69 @@ +.grid { + border-collapse: collapse; + font-size: var(--font-md); + font-variant-numeric: tabular-nums; + table-layout: auto; +} +.grid th, +.grid td { + border: 1px solid var(--border); + padding: 3px var(--space-3); + text-align: left; + white-space: nowrap; + color: var(--text); + height: 24px; +} +.grid thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-panel); + color: var(--text-dim); + text-align: center; + font-weight: 600; + font-size: var(--font-sm); +} +.grid .rownum { + background: var(--bg-panel); + color: var(--text-dim); + text-align: center; + font-weight: 500; + font-size: var(--font-sm); + position: sticky; + left: 0; + z-index: 1; +} +.grid td.num { + text-align: right; +} + +/* Changed cell: a boxed warm tint so the exact moved value stands out even + inside an otherwise-unchanged row. */ +.grid td.cell-chg { + background: color-mix(in srgb, var(--warning-bg) 26%, transparent); + box-shadow: inset 0 0 0 1.5px var(--warning-border); +} +/* Whole-row states — added on the right side, removed on the left. */ +.grid tr.added td, +.grid tr.added .rownum { + background: color-mix(in srgb, var(--success-text) 16%, transparent); + color: var(--success-text); +} +.grid tr.removed td, +.grid tr.removed .rownum { + background: color-mix(in srgb, var(--danger-border) 16%, transparent); + color: var(--danger-border); +} +/* Ghost: the aligned gap opposite an added/removed row, striped so it reads as + "nothing here" while keeping both grids row-for-row aligned. */ +.grid tr.ghost td, +.grid tr.ghost .rownum { + color: transparent; + background: repeating-linear-gradient( + 45deg, + transparent, + transparent 5px, + color-mix(in srgb, var(--border) 55%, transparent) 5px, + color-mix(in srgb, var(--border) 55%, transparent) 10px + ); +} diff --git a/src/renderer/src/components/styles/SupportedFormats.css b/src/renderer/src/components/styles/SupportedFormats.css new file mode 100644 index 0000000..b33b0a0 --- /dev/null +++ b/src/renderer/src/components/styles/SupportedFormats.css @@ -0,0 +1,56 @@ +.formats { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-2); +} +.eyebrow { + font-size: var(--font-2xs); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-dim); +} +.chips { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-2); + max-width: 460px; +} +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: var(--control-h); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--bg-panel); + color: var(--text); + font-size: var(--font-sm); +} +.chip .ext { + color: var(--text-dim); + font-size: var(--font-xs); +} +/* Excel is the headline capability (the only structural grid diff) — tint it + with the spreadsheet green so it reads as the standout. */ +.chip.featured { + background: color-mix(in srgb, var(--success-text) 14%, transparent); + border-color: color-mix(in srgb, var(--success-text) 45%, transparent); + color: var(--success-text); + font-weight: 600; +} +.chip.featured .ext { + color: color-mix(in srgb, var(--success-text) 80%, var(--text-dim)); +} +.tail { + margin: 0; + font-size: var(--font-xs); + color: var(--text-dim); + font-style: italic; +} diff --git a/src/renderer/src/composables/useFullScreen.js b/src/renderer/src/composables/useFullScreen.js new file mode 100644 index 0000000..0719101 --- /dev/null +++ b/src/renderer/src/composables/useFullScreen.js @@ -0,0 +1,19 @@ +import { ref } from 'vue' + +// App-window fullscreen state, pushed from the main process (window.js emits +// `window:fullscreen` on enter/leave-full-screen). Kept as a module-level +// singleton so the IPC listener is wired exactly once, no matter how many +// components read it or how often they mount — preload's onFullScreenChange +// has no removal, so registering per-instance would leak listeners. +const isFullScreen = ref(false) +let wired = false + +export function useFullScreen() { + if (!wired) { + wired = true + window.api?.onFullScreenChange?.((value) => { + isFullScreen.value = !!value + }) + } + return isFullScreen +} diff --git a/src/renderer/src/composables/usePasteShortcut.js b/src/renderer/src/composables/usePasteShortcut.js new file mode 100644 index 0000000..d3449d1 --- /dev/null +++ b/src/renderer/src/composables/usePasteShortcut.js @@ -0,0 +1,31 @@ +import { onBeforeUnmount, onMounted } from 'vue' + +// True for fields where Ctrl/Cmd+V should do a NORMAL paste — we must never +// hijack it there (typing in the paste textareas, search boxes, dialog inputs). +export function isEditableTarget(el) { + if (!el) return false + const tag = el.tagName + return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true +} + +// The paste chord: Ctrl+V (or Cmd+V on macOS), no other modifiers. +export function isPasteChord(e) { + const mod = e.metaKey || e.ctrlKey + return mod && !e.altKey && !e.shiftKey && (e.key === 'v' || e.key === 'V') +} + +// Ctrl/Cmd+V while focus is NOT in a text field is a "paste to compare" gesture: +// it runs `onPaste`. When focus IS in an input/textarea the browser's normal +// paste is left completely alone. Deliberately a renderer-only gesture, NOT a +// menu accelerator — a global accelerator would fire regardless of focus and +// break pasting into fields. +export function usePasteShortcut(onPaste) { + function handler(e) { + if (!isPasteChord(e)) return + if (isEditableTarget(e.target) || isEditableTarget(document.activeElement)) return + e.preventDefault() + onPaste() + } + onMounted(() => window.addEventListener('keydown', handler)) + onBeforeUnmount(() => window.removeEventListener('keydown', handler)) +} diff --git a/src/renderer/src/composables/useResizable.js b/src/renderer/src/composables/useResizable.js new file mode 100644 index 0000000..41f3bfa --- /dev/null +++ b/src/renderer/src/composables/useResizable.js @@ -0,0 +1,53 @@ +import { reactive } from 'vue' +import { resizeRect, centeredRect } from '../utils/resizeRect' + +// A panel that can be dragged bigger/smaller from any of its four corners and +// stays inside the viewport. Geometry is in utils/resizeRect (unit-tested); +// this wires the pointer drag and keeps the live rect reactive. + +// Keep at least this much of the window edge free so the panel can never be +// dragged fully off-screen. +const MARGIN = 8 + +export function useResizable({ min }) { + const rect = reactive({ left: 0, top: 0, width: 0, height: 0 }) + + const bounds = () => ({ + minX: MARGIN, + minY: MARGIN, + maxX: window.innerWidth - MARGIN, + maxY: window.innerHeight - MARGIN + }) + + // Place a rect of the given size centred in the current window. + function setCentered(width, height) { + const w = Math.min(width, window.innerWidth - 2 * MARGIN) + const h = Math.min(height, window.innerHeight - 2 * MARGIN) + Object.assign(rect, centeredRect(w, h, window.innerWidth, window.innerHeight)) + } + + // Begin a corner drag. The opposite corner is pinned for the whole gesture, so + // the start rect and bounds are captured once and every move is computed from + // the original press point. + function beginResize(corner, e) { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startY = e.clientY + const start = { ...rect } + const b = bounds() + const onMove = (ev) => { + const dx = ev.clientX - startX + const dy = ev.clientY - startY + Object.assign(rect, resizeRect({ rect: start, corner, dx, dy, min, bounds: b })) + } + const onUp = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + } + + return { rect, setCentered, beginResize } +} diff --git a/src/renderer/src/composables/useSectionReorder.js b/src/renderer/src/composables/useSectionReorder.js new file mode 100644 index 0000000..3661c01 --- /dev/null +++ b/src/renderer/src/composables/useSectionReorder.js @@ -0,0 +1,38 @@ +import { ref } from 'vue' +import { useSettingsStore } from '../stores/settingsStore' + +// Drag-and-drop reordering of the sidebar sections. The drag source and the drop +// target are different SectionHeader instances, so the id of the section being +// dragged lives in a module-level ref shared across them all. The reorder maths +// itself is the store's reorderSections (unit-tested); this is only the wiring, +// pulled out of the .vue so the drag guards can be exercised without mounting. +const dragId = ref(null) + +export function useSectionReorder() { + const settings = useSettingsStore() + + function onDragStart(id, e) { + if (settings.sectionsLocked) return + dragId.value = id + // Some browsers only start a drag once dataTransfer is populated. + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', id) + } + } + function onDragEnd() { + dragId.value = null + } + function onDrop(targetId) { + const from = dragId.value + dragId.value = null + if (from) settings.reorderSections(from, targetId) + } + // True for a section that is a live drop target (a drag is in flight and this + // isn't the section being dragged) — drives the drop-highlight class. + function isDropTarget(id) { + return dragId.value !== null && dragId.value !== id + } + + return { dragId, onDragStart, onDragEnd, onDrop, isDropTarget } +} diff --git a/src/renderer/src/composables/useSpreadsheetDiff.js b/src/renderer/src/composables/useSpreadsheetDiff.js new file mode 100644 index 0000000..9dca69b --- /dev/null +++ b/src/renderer/src/composables/useSpreadsheetDiff.js @@ -0,0 +1,43 @@ +import { computed, ref } from 'vue' +import { useDiffStore } from '../stores/diffStore' +import { diffWorkbooks } from '../utils/spreadsheetDiff' + +// Binds the two loaded spreadsheet comparables to a per-sheet diff and tracks +// which sheet tab is active. Kept out of the .vue so the selection/clamping +// logic is unit-testable (see tests/renderer/composables/). +export function useSpreadsheetDiff() { + const store = useDiffStore() + const active = ref(0) + + const sheets = computed(() => + diffWorkbooks(store.leftComparable?.sheets ?? [], store.rightComparable?.sheets ?? []) + ) + + // Clamp rather than reset: if the active index falls out of range (sheet count + // shrank), fall back to the last sheet instead of throwing. + const activeSheet = computed(() => { + if (!sheets.value.length) return null + return sheets.value[Math.min(active.value, sheets.value.length - 1)] + }) + + const totals = computed(() => + sheets.value.reduce( + (t, s) => ({ + changed: t.changed + s.stats.changed, + added: t.added + s.stats.added, + removed: t.removed + s.stats.removed + }), + { changed: 0, added: 0, removed: 0 } + ) + ) + + const identical = computed( + () => totals.value.changed === 0 && totals.value.added === 0 && totals.value.removed === 0 + ) + + function select(index) { + if (index >= 0 && index < sheets.value.length) active.value = index + } + + return { sheets, active, activeSheet, totals, identical, select } +} diff --git a/src/renderer/src/composables/useZoomPan.js b/src/renderer/src/composables/useZoomPan.js new file mode 100644 index 0000000..b57a446 --- /dev/null +++ b/src/renderer/src/composables/useZoomPan.js @@ -0,0 +1,48 @@ +import { computed, ref } from 'vue' + +// Zoom + drag-to-pan for the Mermaid viewer stage: scale via buttons or +// Ctrl/⌘-wheel, translate by dragging. Kept out of the .vue so the panel stays +// under its size cap and the transform maths is reusable. +const SCALE_MIN = 0.2 +const SCALE_MAX = 8 + +export function useZoomPan() { + const scale = ref(1) + const tx = ref(0) + const ty = ref(0) + const pct = computed(() => Math.round(scale.value * 100)) + + const clamp = (v) => Math.min(SCALE_MAX, Math.max(SCALE_MIN, v)) + function zoom(factor) { + scale.value = clamp(scale.value * factor) + } + function fit() { + scale.value = 1 + tx.value = 0 + ty.value = 0 + } + function onWheel(e) { + if (!e.ctrlKey && !e.metaKey) return // plain scroll left for the OS/trackpad + e.preventDefault() + zoom(e.deltaY < 0 ? 1.1 : 1 / 1.1) + } + + let dragging = false + let startX = 0 + let startY = 0 + function onDown(e) { + dragging = true + startX = e.clientX - tx.value + startY = e.clientY - ty.value + } + function onMove(e) { + if (!dragging) return + tx.value = e.clientX - startX + ty.value = e.clientY - startY + } + function onUp() { + dragging = false + } + + return { scale, tx, ty, pct, zoom, fit, onWheel, onDown, onMove, onUp } +} diff --git a/src/renderer/src/errorHandlers.js b/src/renderer/src/errorHandlers.js new file mode 100644 index 0000000..8ee3d5b --- /dev/null +++ b/src/renderer/src/errorHandlers.js @@ -0,0 +1,16 @@ +import { useErrorStore } from './stores/errorStore' + +// Wire the browser + Vue global error channels into the error store so nothing +// fails silently: a crash lands in the local log and raises the report dialog. +// Installed once at startup (main.js) with the app's own pinia instance. +export function installErrorHandlers(app, pinia) { + const store = useErrorStore(pinia) + app.config.errorHandler = (err, _instance, info) => store.capture(err, `vue: ${info}`) + // Only script errors reach a non-capture 'error' listener (resource-load + // failures don't bubble here), so this is genuine uncaught-exception territory. + window.addEventListener('error', (e) => { + if (e.error) store.capture(e.error, 'window.error') + else if (e.message) store.capture(e.message, 'window.error') + }) + window.addEventListener('unhandledrejection', (e) => store.capture(e.reason, 'unhandledrejection')) +} diff --git a/src/renderer/src/icons.js b/src/renderer/src/icons.js index fd0a061..dd865a7 100644 --- a/src/renderer/src/icons.js +++ b/src/renderer/src/icons.js @@ -60,6 +60,11 @@ export const ICONS = { { t: 'rect', x: 4, y: 11, width: 16, height: 10, rx: 2 }, { t: 'path', d: 'M8 11V7a4 4 0 0 1 8 0v4' } ], + // Open padlock — the shackle swung clear of the body. + unlock: [ + { t: 'rect', x: 4, y: 11, width: 16, height: 10, rx: 2 }, + { t: 'path', d: 'M8 11V7a4 4 0 0 1 7.7-2.3' } + ], edit: [ { t: 'path', d: 'M12 20h9' }, { t: 'path', d: 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z' } @@ -95,6 +100,7 @@ export const ICONS = { { t: 'path', d: 'M3 16h3a2 2 0 0 1 2 2v3' }, { t: 'path', d: 'M16 21v-3a2 2 0 0 1 2-2h3' } ], + check: [{ t: 'path', d: 'M20 6 9 17l-5-5' }], minus: [{ t: 'path', d: 'M5 12h14' }], plus: [ { t: 'path', d: 'M5 12h14' }, @@ -110,5 +116,12 @@ export const ICONS = { t: 'path', d: 'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z' } - ] + ], + // Grid — marks the spreadsheet (.xlsx) format in the supported-types hint. + table: [ + { t: 'rect', x: 3, y: 4, width: 18, height: 16, rx: 2 }, + { t: 'path', d: 'M3 10h18M3 15h18M9 4v16M15 4v16' } + ], + // Angle brackets — marks code/markup formats (JSON, XML, and the like). + code: [{ t: 'path', d: 'm8 8-4 4 4 4M16 8l4 4-4 4' }] } diff --git a/src/renderer/src/main.js b/src/renderer/src/main.js index 45ce172..2716b68 100644 --- a/src/renderer/src/main.js +++ b/src/renderer/src/main.js @@ -1,10 +1,16 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' +import { installErrorHandlers } from './errorHandlers' import './monaco-setup' import './styles/tokens.css' import './styles/themes.css' import './styles/base.css' import './styles/ui.css' -createApp(App).use(createPinia()).mount('#app') +const pinia = createPinia() +const app = createApp(App) +app.use(pinia) +// Catch uncaught errors before the UI mounts, so even a startup failure logs. +installErrorHandlers(app, pinia) +app.mount('#app') diff --git a/src/renderer/src/stores/diffStore.js b/src/renderer/src/stores/diffStore.js index b717840..4a50e46 100644 --- a/src/renderer/src/stores/diffStore.js +++ b/src/renderer/src/stores/diffStore.js @@ -6,7 +6,8 @@ import { useSnippetStore } from './snippetStore' import { detectTextFormat, formatJson, formatXml } from '../utils/textFormats' import { toUnifiedDiff } from '../utils/unifiedDiff' import { loadPersisted, savePersisted } from '../persist' -import { isDarkTheme, normalizeTheme } from '../utils/themes' +import { isDarkTheme, normalizeTheme, themeForDay } from '../utils/themes' +import { useSettingsStore } from './settingsStore' const SHARE_ERRORS = { 'not-a-share-file': 'That file is not a Diff Bro shared diff.', @@ -46,6 +47,45 @@ function formatHintFor(file, dismissedContent) { return { kind: detected.kind, valid: true } } +// Merge the two per-side format hints into ONE banner so the diff never carries +// two stacked strips. Pure (takes the two hints, returns render data or null) so +// the store getter stays thin and this stays unit-testable. +function mergeFormatBanner(left, right) { + const shown = [] + if (left) shown.push({ side: 'left', label: 'Left', hint: left }) + if (right) shown.push({ side: 'right', label: 'Right', hint: right }) + if (!shown.length) return null + + const kindLabel = (h) => (h.kind === 'json' ? 'JSON' : 'XML') + const loc = (h) => (h.line ? ` at line ${h.line}, column ${h.column}` : '') + const clause = ({ label, hint }) => + hint.valid + ? `${label} looks like ${kindLabel(hint)} — pretty-print?` + : `${label} looks like ${kindLabel(hint)} but doesn't parse${loc(hint)}${ + hint.error ? `: ${hint.error}` : '' + }` + + const valid = shown.filter((x) => x.hint.valid) + let message + if (valid.length === 2) { + message = + left.kind === right.kind + ? `Both sides look like ${kindLabel(left)} — pretty-print?` + : 'Both sides look like structured data — pretty-print?' + } else { + message = shown.map(clause).join(' · ') + } + + return { + message, + invalid: valid.length === 0, // red only when nothing here is actionable + formatBoth: valid.length === 2, + formatSide: valid.length === 1 ? valid[0].side : null, + formatLabel: valid.length === 1 && shown.length === 2 ? `Format ${valid[0].label}` : 'Format', + dismissSides: shown.map((x) => x.side) + } +} + // Menu action → what it does to the store. A table rather than a switch: the // accelerators in src/main/menu.js and MenuBar.vue name these strings, so this // is the single list of everything a menu can trigger. @@ -93,6 +133,11 @@ export const useDiffStore = defineStore('diff', { // real file on the other ("partial paste"). null = that side is a textarea. pasteLeftFile: null, pasteRightFile: null, + // Ctrl/Cmd+V paste-to-compare flow: null | 'enter' | 'overwrite' controls + // which confirmation is showing; pendingPasteText holds the clipboard text + // between the "both sides full" confirm and the overwrite. + pastePrompt: null, + pendingPasteText: '', // { additions, deletions } from the diff editor, null before first diff stats: null, // transient user-facing message (binary file rejected, etc.) @@ -118,10 +163,13 @@ export const useDiffStore = defineStore('diff', { // nothing and the replace prompt is skipped. Any edit to either side clears // it (see receive/comparePasted/swap/formatSide). diffSaved: false, - // Persisted through the durable data-dir store (persist.js), same as vault - // and snippets, so the choice survives a reinstall that wipes userData; the - // old localStorage 'diffbro.theme' key is migrated forward automatically. - // Default is Light (see utils/themes.js); an unknown stored id normalizes. + // The user's PERSISTED choice (Appearance picker), through the durable + // data-dir store — survives a reinstall; the old localStorage 'diffbro.theme' + // key migrates forward. Default Light; unknown ids normalize. + userTheme: normalizeTheme(loadPersisted('theme')), + // The ACTIVE, applied theme components read. Equals userTheme, unless the + // "rotate daily" Fun option is on, when it's the day's random theme + // (resolveActiveTheme). Kept separate so turning rotation off reverts cleanly. theme: normalizeTheme(loadPersisted('theme')), // entry id currently in the share dialog (null = closed) shareEntryId: null, @@ -164,8 +212,18 @@ export const useDiffStore = defineStore('diff', { : s.ready, leftComparable: (s) => (s.left ? resolveAdapter(s.left).toComparable(s.left) : null), rightComparable: (s) => (s.right ? resolveAdapter(s.right).toComparable(s.right) : null), + // Which viewer the loaded comparison needs: 'text' (Monaco) or 'spreadsheet' + // (grid). Text is the default so an empty/paste state routes to Monaco. + comparableKind() { + return this.leftComparable?.kind ?? this.rightComparable?.kind ?? 'text' + }, leftFormatHint: (s) => formatHintFor(s.left, s.dismissedFormatHint.left), - rightFormatHint: (s) => formatHintFor(s.right, s.dismissedFormatHint.right) + rightFormatHint: (s) => formatHintFor(s.right, s.dismissedFormatHint.right), + // One merged banner for both sides (see mergeFormatBanner) — null when neither + // side has a pending hint. + formatBanner() { + return mergeFormatBanner(this.leftFormatHint, this.rightFormatHint) + } }, actions: { async pick(side) { @@ -276,6 +334,10 @@ export const useDiffStore = defineStore('diff', { ) return } + if (file.error === 'xlsx') { + this.showNotice(`"${file.name}" could not be read as a spreadsheet — ${file.message}.`) + return + } // Any other error shape (e.g. a path main refused to serve) is not a // loadable file — never assign it to a side. if (file.error) return @@ -297,6 +359,48 @@ export const useDiffStore = defineStore('diff', { togglePasteMode() { this.mode = this.mode === 'paste' ? 'files' : 'paste' }, + // --- Ctrl/Cmd+V paste-to-compare (see usePasteShortcut) --- + // Step 1: a paste gesture landed outside any input. Ask before touching the + // clipboard — it's only read once the user confirms. + requestPasteFromClipboard() { + if (this.pastePrompt) return // a confirm is already up + this.pastePrompt = 'enter' + }, + // Step 2: confirmed. Read the clipboard (main process), enter paste mode, and + // drop the text into the first empty side. If BOTH sides already hold text, + // escalate to the overwrite confirm rather than clobbering unsaved work. + async confirmPasteEnter() { + const text = (await window.api?.readText?.()) ?? '' + if (!text.trim()) { + this.pastePrompt = null + this.showNotice('The clipboard is empty — nothing to paste.') + return + } + this.mode = 'paste' + const leftFull = !!(this.pasteLeft || this.pasteLeftFile) + const rightFull = !!(this.pasteRight || this.pasteRightFile) + if (!leftFull) { + this.pasteLeft = text + this.pastePrompt = null + } else if (!rightFull) { + this.pasteRight = text + this.pastePrompt = null + } else { + this.pendingPasteText = text + this.pastePrompt = 'overwrite' + } + }, + // Both sides were full and the user agreed to overwrite: replace the left + // side with the pasted text, keeping the right to compare against. + confirmPasteOverwrite() { + this.pasteLeft = this.pendingPasteText + this.pendingPasteText = '' + this.pastePrompt = null + }, + cancelPaste() { + this.pendingPasteText = '' + this.pastePrompt = null + }, // Load a file into one paste side without leaving paste mode (partial // paste). `file` is a LoadedFile from the open dialog or a dropped file. receivePasteFile(side, file) { @@ -307,6 +411,10 @@ export const useDiffStore = defineStore('diff', { ) return } + if (file.kind === 'spreadsheet') { + this.showNotice(`"${file.name}" is a spreadsheet — open it as a file comparison, not in paste mode.`) + return + } if (file.error) return // Keep the path so a partial-paste file follows external edits on focus // (refreshFromDisk), exactly like a loaded comparison side does. @@ -323,6 +431,14 @@ export const useDiffStore = defineStore('diff', { this[side === 'left' ? 'pasteLeftFile' : 'pasteRightFile'] = null }, initTheme() { + this.resolveActiveTheme() + }, + // Compute and apply the active theme: the day's random theme when the "rotate + // daily" Fun option is on, otherwise the user's saved choice. Idempotent — + // safe to call on window focus so the theme rolls over at midnight. + resolveActiveTheme() { + const rotate = useSettingsStore().rotateThemeDaily + this.theme = rotate ? themeForDay() : this.userTheme applyTheme(this.theme) }, // Open the Mermaid viewer for a diagram's decrypted source. @@ -335,14 +451,16 @@ export const useDiffStore = defineStore('diff', { // Select any of the named themes (Settings picker). Unknown ids fall back // to the default rather than leaving the app unstyled. setTheme(id) { - this.theme = normalizeTheme(id) - savePersisted('theme', this.theme) - applyTheme(this.theme) + this.userTheme = normalizeTheme(id) + savePersisted('theme', this.userTheme) + // While rotating, the pick is saved for later but the active theme stays + // the day's; otherwise it applies immediately. + this.resolveActiveTheme() }, // Quick light/dark flip for the View menu + Ctrl+D: flips the ground, so a // dark-ground theme (Dark, Neon) goes Light and a light-ground one goes Dark. toggleTheme() { - this.setTheme(isDarkTheme(this.theme) ? 'light' : 'dark') + this.setTheme(isDarkTheme(this.userTheme) ? 'light' : 'dark') }, // Re-read one slot quietly (no large-file prompt); returns the file name if // its on-disk content actually changed, else null. Paste-file slots keep @@ -415,6 +533,15 @@ export const useDiffStore = defineStore('diff', { dismissFormatHint(side) { this.dismissedFormatHint[side] = this[side]?.content ?? null }, + // Format every side the merged banner offers (both are valid). + formatBoth() { + this.formatSide('left') + this.formatSide('right') + }, + // Dismiss the merged banner: silence each side it currently covers. + dismissFormatHints(sides) { + for (const side of sides) this.dismissFormatHint(side) + }, // Snapshot of everything a saved diff needs to be restored later — // including in-progress paste-mode text. snapshot() { @@ -575,7 +702,7 @@ export const useDiffStore = defineStore('diff', { // --- configuration backup / restore --- async runConfigBackup(passphrase) { const snippets = await useSnippetStore().fullBundle() - const settings = { theme: this.theme } + const settings = { theme: this.userTheme } const res = await window.api.backupConfig(snippets, settings, passphrase) if (res.ok) this.showNotice(`Configuration backed up to ${res.path}`) else if (!res.canceled) this.showNotice('Backup failed.') diff --git a/src/renderer/src/stores/errorStore.js b/src/renderer/src/stores/errorStore.js new file mode 100644 index 0000000..697f03a --- /dev/null +++ b/src/renderer/src/stores/errorStore.js @@ -0,0 +1,67 @@ +import { defineStore } from 'pinia' + +// Surfaces uncaught renderer errors: forwards each to the main process for the +// LOCAL log (window.api.logError — nothing leaves the machine) and raises a +// dialog suggesting the user report it. Identical errors are throttled so a +// render loop can't spam the log or respawn the dialog. +const THROTTLE_MS = 3000 + +function messageOf(reason) { + if (reason?.message) return reason.message + return String(reason ?? 'Unknown error') +} + +// Benign framework noise that is NOT a crash and must never log or raise the +// dialog: Monaco cancels in-flight work by rejecting with a Canceled / +// CancellationError on normal model switches/disposal; ResizeObserver emits a +// harmless loop notice; a cross-origin script error arrives as an opaque +// "Script error." with no detail worth reporting. Matched on name+message so a +// cancellation is caught however Monaco labels it. +const IGNORED = [/cancell?ed/i, /CancellationError/i, /ResizeObserver loop/i, /^Script error\.?$/i] + +function isIgnorable(err) { + const reason = err?.reason ?? err?.error ?? err + const text = `${reason?.name ?? ''} ${reason?.message ?? reason ?? ''}`.trim() + return IGNORED.some((re) => re.test(text)) +} + +// Normalise the many shapes an error arrives as (Error, string, ErrorEvent, +// PromiseRejectionEvent) into { message, stack, context }. +function toRecord(err, context) { + if (err instanceof Error) return { message: err.message || String(err), stack: err.stack, context } + if (typeof err === 'string') return { message: err, context } + const reason = err?.reason ?? err?.error ?? err + return { message: messageOf(reason), stack: reason?.stack, context } +} + +export const useErrorStore = defineStore('error', { + state: () => ({ + visible: false, + lastError: null, // { message, when } + lastSignature: '', + lastAt: 0 + }), + actions: { + capture(err, context = '') { + if (isIgnorable(err)) return + const record = toRecord(err, context) + const sig = `${record.context}|${record.message}` + const now = Date.now() + // Drop an identical repeat inside the window — no log, no re-raise. + if (sig === this.lastSignature && now - this.lastAt < THROTTLE_MS) return + this.lastSignature = sig + this.lastAt = now + try { + window.api?.logError?.(record) + } catch { + // logging must never itself throw back into the error path + } + this.lastError = { message: record.message, when: now } + // Don't stack dialogs — if one is already up, keep it. + if (!this.visible) this.visible = true + }, + dismiss() { + this.visible = false + } + } +}) diff --git a/src/renderer/src/stores/settingsStore.js b/src/renderer/src/stores/settingsStore.js index 3ba8032..e080c21 100644 --- a/src/renderer/src/stores/settingsStore.js +++ b/src/renderer/src/stores/settingsStore.js @@ -11,20 +11,41 @@ import { loadPersisted, savePersisted } from '../persist' // The three reorderable sidebar sections, in their default top-to-bottom order. export const SECTIONS = ['saved', 'external', 'snippets'] -// Safe defaults that keep the app responsive. Both are user-raisable (the point -// of exposing them), but each has a hard ceiling so a typo can't wedge the app -// trying to diff or render something enormous. -export const DEFAULT_MAX_COMPARISON_FILE_MB = 10 +// Comparison-file size limits, PER FILE TYPE. Each is a soft "load anyway?" +// prompt threshold the user can raise, and `cap` is the hard ceiling the app +// enforces (also the slider max). Different types degrade differently: Monaco +// gets sluggish with large text well before a .xlsx (compressed, and the grid +// only renders a capped window), so their sensible middle grounds differ. Add a +// new type here (e.g. `docx`) and the Settings pane + main-process guard both +// pick it up. Main mirrors these numbers in src/main/files.js (it can't import a +// Pinia store) — keep the two in sync. +export const FILE_TYPE_LIMITS = { + text: { label: 'Text & code', default: 10, cap: 200 }, + spreadsheet: { label: 'Spreadsheet (.xlsx)', default: 25, cap: 100 } +} + +// Snippet size guard: user-raisable with a hard ceiling, same rationale. export const DEFAULT_MAX_SNIPPET_SIZE_KB = 512 -export const MAX_COMPARISON_FILE_MB_CAP = 500 export const MAX_SNIPPET_SIZE_KB_CAP = 8192 +function defaultFileLimits() { + const out = {} + for (const [type, spec] of Object.entries(FILE_TYPE_LIMITS)) out[type] = spec.default + return out +} + export const DEFAULT_SETTINGS = { sectionOrder: [...SECTIONS], + // Freezes section reordering (both the drag-and-drop and the up/down controls) + // so a settled sidebar layout can't be nudged by accident. + sectionsLocked: false, // { [sectionId]: [shelfId, …] } — a section absent here uses its natural order. shelfOrder: {}, showShortcutBar: true, - maxComparisonFileMb: DEFAULT_MAX_COMPARISON_FILE_MB, + // Fun: pick a new random theme each day (overrides the Appearance choice while + // on; turning it off reverts to that choice). Off by default. + rotateThemeDaily: false, + fileSizeLimitsMb: defaultFileLimits(), maxSnippetSizeKb: DEFAULT_MAX_SNIPPET_SIZE_KB, // Whether the one-time first-run example snippet decision has been made (see // App.vue). Recorded for everyone once, so the example is never re-seeded. @@ -46,6 +67,24 @@ function sanitizeSectionOrder(order) { return kept } +// Resolve each type's limit from the stored map, migrating the pre-per-type +// single `maxComparisonFileMb` into the text bucket. Every value is clamped to +// its type's [1, cap], so a stale or hand-edited file can't exceed the ceiling. +function readFileLimits(parsed) { + const stored = + parsed.fileSizeLimitsMb && typeof parsed.fileSizeLimitsMb === 'object' + ? parsed.fileSizeLimitsMb + : {} + const legacy = Number(parsed.maxComparisonFileMb) + const out = {} + for (const [type, spec] of Object.entries(FILE_TYPE_LIMITS)) { + const legacySeed = type === 'text' && Number.isFinite(legacy) ? legacy : spec.default + const raw = stored[type] ?? legacySeed + out[type] = clampNumber(raw, spec.default, 1, spec.cap) + } + return out +} + function readState() { let parsed try { @@ -60,15 +99,12 @@ function readState() { } return { sectionOrder: sanitizeSectionOrder(parsed.sectionOrder), + sectionsLocked: parsed.sectionsLocked === true, shelfOrder: parsed.shelfOrder && typeof parsed.shelfOrder === 'object' ? { ...parsed.shelfOrder } : {}, showShortcutBar, - maxComparisonFileMb: clampNumber( - parsed.maxComparisonFileMb, - DEFAULT_MAX_COMPARISON_FILE_MB, - 1, - MAX_COMPARISON_FILE_MB_CAP - ), + rotateThemeDaily: parsed.rotateThemeDaily === true, + fileSizeLimitsMb: readFileLimits(parsed), maxSnippetSizeKb: clampNumber( parsed.maxSnippetSizeKb, DEFAULT_MAX_SNIPPET_SIZE_KB, @@ -84,7 +120,13 @@ export const useSettingsStore = defineStore('settings', { getters: { // Sections in the user's chosen order (always all three, sanitized). orderedSections: (s) => s.sectionOrder, - maxSnippetSizeBytes: (s) => s.maxSnippetSizeKb * 1024 + maxSnippetSizeBytes: (s) => s.maxSnippetSizeKb * 1024, + // The configured MB limit for a file type (falls back to its default). + fileSizeLimitMb: (s) => (type) => + s.fileSizeLimitsMb[type] ?? FILE_TYPE_LIMITS[type]?.default ?? 10, + fileSizeLimitBytes() { + return (type) => this.fileSizeLimitMb(type) * 1024 * 1024 + } }, actions: { persist() { @@ -92,9 +134,14 @@ export const useSettingsStore = defineStore('settings', { 'settings', JSON.stringify({ sectionOrder: this.sectionOrder, + sectionsLocked: this.sectionsLocked, shelfOrder: this.shelfOrder, showShortcutBar: this.showShortcutBar, - maxComparisonFileMb: this.maxComparisonFileMb, + rotateThemeDaily: this.rotateThemeDaily, + fileSizeLimitsMb: this.fileSizeLimitsMb, + // Legacy mirror: an older build (or main's fallback) still honours the + // text limit through the pre-per-type key. + maxComparisonFileMb: this.fileSizeLimitsMb.text, maxSnippetSizeKb: this.maxSnippetSizeKb, examplesSeeded: this.examplesSeeded }) @@ -108,6 +155,7 @@ export const useSettingsStore = defineStore('settings', { }, // Move a section one step up or down (delta -1 / +1). No-op at the ends. moveSection(id, delta) { + if (this.sectionsLocked) return const order = [...this.sectionOrder] const from = order.indexOf(id) const to = from + delta @@ -116,6 +164,20 @@ export const useSettingsStore = defineStore('settings', { this.sectionOrder = order this.persist() }, + // Drag-and-drop reorder: drop section `fromId` so it lands just before + // `toId`. No-op when locked or when either id is unknown. + reorderSections(fromId, toId) { + if (this.sectionsLocked || fromId === toId) return + if (!this.sectionOrder.includes(fromId) || !this.sectionOrder.includes(toId)) return + const order = this.sectionOrder.filter((id) => id !== fromId) + order.splice(order.indexOf(toId), 0, fromId) + this.sectionOrder = order + this.persist() + }, + toggleSectionsLock() { + this.sectionsLocked = !this.sectionsLocked + this.persist() + }, // Persist the shelf order for a section (item drag-reorder). ids is the full // ordered list of that section's shelf ids. setShelfOrder(sectionId, ids) { @@ -135,13 +197,17 @@ export const useSettingsStore = defineStore('settings', { this.showShortcutBar = !!value this.persist() }, - setMaxComparisonFileMb(value) { - this.maxComparisonFileMb = clampNumber( - value, - DEFAULT_MAX_COMPARISON_FILE_MB, - 1, - MAX_COMPARISON_FILE_MB_CAP - ) + setRotateThemeDaily(value) { + this.rotateThemeDaily = !!value + this.persist() + }, + setFileSizeLimitMb(type, value) { + const spec = FILE_TYPE_LIMITS[type] + if (!spec) return + this.fileSizeLimitsMb = { + ...this.fileSizeLimitsMb, + [type]: clampNumber(value, spec.default, 1, spec.cap) + } this.persist() }, setMaxSnippetSizeKb(value) { diff --git a/src/renderer/src/styles/themes.css b/src/renderer/src/styles/themes.css index adc78a1..772e5c9 100644 --- a/src/renderer/src/styles/themes.css +++ b/src/renderer/src/styles/themes.css @@ -102,6 +102,79 @@ --text-on-accent: #04121a; } +/* Nord — the low-saturation arctic palette; calm frost-blue accent on slate, + soft green/red for add/remove. Easy on the eyes for long sessions. */ +:root[data-theme='nord'] { + --bg: #2e3440; + --bg-panel: #3b4252; + --bg-hover: #434c5e; + --border: #4c566a; + --text: #eceff4; + --text-dim: #9aa5b8; + --text-hint: #d8dee9; + --accent: #88c0d0; + --warning-bg: #ebcb8b; + --warning-border: #d0a85f; + --warning-text: #2e3440; + --danger-bg: #bf616a; + --danger-border: #a54c55; + --danger-text: #eceff4; + --success-text: #a3be8c; + --scrim: rgba(0, 0, 0, 0.5); + --favorite: #ebcb8b; + --text-on-accent: #2e3440; +} + +/* Sepia — a deep, saturated PARCHMENT (distinct from Solar's pale sunny cream): + walnut ink on aged tan, burnt-sienna accent, moss-green additions. A vintage + low-glare pick for long reading. */ +:root[data-theme='sepia'] { + --bg: #e9dcbe; + --bg-panel: #dfcea6; + --bg-hover: #d3bf90; + --border: #c3ad7e; + --text: #3a2e15; + --text-dim: #7d6840; + --text-hint: #574524; + --accent: #9c4f1f; + --warning-bg: #cc9324; + --warning-border: #9a6712; + --warning-text: #33260a; + --danger-bg: #ab4429; + --danger-border: #933a22; + --danger-text: #ffffff; + --success-text: #5a6f28; + --scrim: rgba(42, 32, 14, 0.44); + --favorite: #a3760a; + --text-on-accent: #ffffff; +} + +/* Nyan — playful but usable: a readable deep-violet base with hot-pink accent + and lime/rose diff colors. The chaos (the rainbow lane + reward cat) lives in + non-content chrome (see NyanLane), so diffs stay legible. */ +:root[data-theme='nyan'] { + --bg: #160a20; + --bg-panel: #231033; + --bg-hover: #2e1642; + /* Brighter than the ground so button/input/divider edges read clearly on the + deep-violet panels (the muted violet was too low-contrast). */ + --border: #7a3fa6; + --text: #f4e9ff; + --text-dim: #b79fcf; + --text-hint: #dcc9f0; + --accent: #ff2ecb; + --warning-bg: #ffd23f; + --warning-border: #d9a400; + --warning-text: #2a1400; + --danger-bg: #ff5470; + --danger-border: #e03a58; + --danger-text: #1a0410; + --success-text: #63ff4d; + --scrim: rgba(10, 2, 16, 0.55); + --favorite: #ffd23f; + --text-on-accent: #1a0410; +} + /* Contrast — maximum legibility: black on white, saturated accent, heavy borders. An accessibility-grade pick. */ :root[data-theme='contrast'] { diff --git a/src/renderer/src/types.js b/src/renderer/src/types.js index 3deb8af..6d0da8a 100644 --- a/src/renderer/src/types.js +++ b/src/renderer/src/types.js @@ -6,15 +6,33 @@ // gets a typedef here and a `@type {import('../types').X}` on the prop. /** - * A file loaded into one side of the comparison. + * A file loaded into one side of the comparison. Text files carry `content`; + * spreadsheets (parsed in the main process) carry `kind:'spreadsheet'` + `sheets` + * instead — the adapter registry keys off that (see adapters/). * @typedef {object} LoadedFile * @property {string} path absolute path it was read from * @property {string} name basename, shown in the slot - * @property {string} content decoded text + * @property {string} [content] decoded text (text files; absent for spreadsheets) + * @property {'spreadsheet'} [kind] non-text format, set by main + * @property {SheetGrid[]} [sheets] parsed worksheets when kind === 'spreadsheet' * @property {string} [encoding] * @property {number} [size] bytes on disk */ +/** + * One worksheet from a spreadsheet: a dense grid of cell values. + * @typedef {object} SheetGrid + * @property {string} name + * @property {Array<Array<string|number|boolean|null>>} rows + */ + +/** + * The comparable produced by xlsxAdapter for the (upcoming) grid viewer. + * @typedef {object} SpreadsheetComparable + * @property {'spreadsheet'} kind + * @property {SheetGrid[]} sheets + */ + /** * A saved diff, as stored in the vault (content encrypted at rest). * @typedef {object} VaultEntry @@ -101,4 +119,13 @@ * @property {number} [column] */ +/** + * A viewport-space rectangle in CSS pixels (the Mermaid viewer panel). + * @typedef {object} Rect + * @property {number} left + * @property {number} top + * @property {number} width + * @property {number} height + */ + export {} diff --git a/src/renderer/src/utils/alignRows.js b/src/renderer/src/utils/alignRows.js new file mode 100644 index 0000000..f49d3f1 --- /dev/null +++ b/src/renderer/src/utils/alignRows.js @@ -0,0 +1,153 @@ +// Row alignment for the spreadsheet diff. Pure (no Vue/store) so it stays +// unit-testable. The goal: an inserted or deleted row must NOT cascade every row +// below it into a false "changed", the way a naive index-by-index compare would. +// +// Strategy: an LCS over whole-row signatures anchors the rows that are identical +// on both sides. Each remaining gap between anchors holds some removed (left) and +// some added (right) rows; within a gap we pair a removed row with an added row +// ONLY when they share a key (column A by default) — that pair is a "changed" +// row with cell-level marks. Unpaired rows stay genuinely removed/added. So a +// row whose id is unchanged but whose numbers moved reads as one changed row, +// while a truly new/deleted row reads as added/removed. + +function normCell(v) { + return v === null || v === undefined ? '' : v +} + +export function cellsEqual(a, b) { + return normCell(a) === normCell(b) +} + +// Column indices whose values differ between two rows. +export function changedCells(left, right) { + const n = Math.max(left.length, right.length) + const cols = [] + for (let i = 0; i < n; i++) if (!cellsEqual(left[i], right[i])) cols.push(i) + return cols +} + +// Cheap deep-equality handle: JSON of the row with trailing empties trimmed, so +// ["a",1] and ["a",1,null,""] compare equal. +function rowSignature(row) { + let end = row.length + while (end > 0 && normCell(row[end - 1]) === '') end-- + const trimmed = [] + for (let i = 0; i < end; i++) trimmed.push(normCell(row[i])) + return JSON.stringify(trimmed) +} + +function keyOf(row, keyColumn) { + return String(normCell(row[keyColumn])) +} + +// Classic LCS backtrace over signature arrays -> ops of { t:'eq'|'del'|'ins' }. +function lcsOps(leftSig, rightSig) { + const n = leftSig.length + const m = rightSig.length + const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = + leftSig[i] === rightSig[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + } + } + const ops = [] + let i = 0 + let j = 0 + while (i < n && j < m) { + if (leftSig[i] === rightSig[j]) ops.push({ t: 'eq', i: i++, j: j++ }) + else if (dp[i + 1][j] >= dp[i][j + 1]) ops.push({ t: 'del', i: i++ }) + else ops.push({ t: 'ins', j: j++ }) + } + while (i < n) ops.push({ t: 'del', i: i++ }) + while (j < m) ops.push({ t: 'ins', j: j++ }) + return ops +} + +// Fallback when the LCS table would be too large: align by position, letting a +// differing row at the same index become a del+ins gap (paired by key below). +function positionalOps(leftSig, rightSig) { + const ops = [] + const min = Math.min(leftSig.length, rightSig.length) + for (let k = 0; k < min; k++) { + if (leftSig[k] === rightSig[k]) ops.push({ t: 'eq', i: k, j: k }) + else ops.push({ t: 'del', i: k }, { t: 'ins', j: k }) + } + for (let i = min; i < leftSig.length; i++) ops.push({ t: 'del', i }) + for (let j = min; j < rightSig.length; j++) ops.push({ t: 'ins', j }) + return ops +} + +// Turn one gap (consecutive del/ins ops) into changed/removed/added entries, +// pairing a removed row with an added row when their key columns match. +function emitGap(gap, leftRows, rightRows, keyColumn) { + const dels = gap.filter((o) => o.t === 'del').map((o) => o.i) + const ins = gap.filter((o) => o.t === 'ins').map((o) => o.j) + const byKey = new Map() + for (const j of ins) { + const k = keyOf(rightRows[j], keyColumn) + if (!byKey.has(k)) byKey.set(k, []) + byKey.get(k).push(j) + } + const used = new Set() + const out = [] + for (const i of dels) { + const queue = byKey.get(keyOf(leftRows[i], keyColumn)) + if (queue && queue.length) { + const j = queue.shift() + used.add(j) + const changed = changedCells(leftRows[i], rightRows[j]) + out.push({ status: 'changed', left: leftRows[i], right: rightRows[j], leftIndex: i, rightIndex: j, changed }) + } else { + out.push({ status: 'removed', left: leftRows[i], right: null, leftIndex: i, rightIndex: null, changed: [] }) + } + } + for (const j of ins) { + if (!used.has(j)) { + out.push({ status: 'added', left: null, right: rightRows[j], leftIndex: null, rightIndex: j, changed: [] }) + } + } + return out +} + +function buildEntries(ops, leftRows, rightRows, keyColumn) { + const out = [] + let gap = [] + const flush = () => { + if (gap.length) out.push(...emitGap(gap, leftRows, rightRows, keyColumn)) + gap = [] + } + for (const op of ops) { + if (op.t !== 'eq') gap.push(op) + else { + flush() + out.push({ + status: 'same', + left: leftRows[op.i], + right: rightRows[op.j], + leftIndex: op.i, + rightIndex: op.j, + changed: [] + }) + } + } + flush() + return out +} + +/** + * Align two sheets' rows into a list of paired entries. + * @returns {Array<{status:'same'|'changed'|'added'|'removed', left, right, + * leftIndex:number|null, rightIndex:number|null, changed:number[]}>} + */ +export function alignRows(leftRows = [], rightRows = [], opts = {}) { + const keyColumn = opts.keyColumn ?? 0 + const budget = opts.maxProduct ?? 4_000_000 + const leftSig = leftRows.map(rowSignature) + const rightSig = rightRows.map(rowSignature) + const ops = + leftRows.length * rightRows.length > budget + ? positionalOps(leftSig, rightSig) + : lcsOps(leftSig, rightSig) + return buildEntries(ops, leftRows, rightRows, keyColumn) +} diff --git a/src/renderer/src/utils/resizeRect.js b/src/renderer/src/utils/resizeRect.js new file mode 100644 index 0000000..e0c4ea3 --- /dev/null +++ b/src/renderer/src/utils/resizeRect.js @@ -0,0 +1,48 @@ +// Pure geometry for the corner-resize of the Mermaid viewer panel. No DOM, no +// Vue — the composable (useResizable) owns the pointer wiring; this owns only +// the maths so it stays unit-testable. + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)) + +/** + * Resize a rect by dragging one of its corners, pinning the opposite corner and + * clamping to a minimum size and the given bounds. + * + * @param {object} o + * @param {import('../types').Rect} o.rect the rect at drag start + * @param {'nw'|'ne'|'sw'|'se'} o.corner which corner is being dragged + * @param {number} o.dx pointer x delta since drag start + * @param {number} o.dy pointer y delta since drag start + * @param {{width:number,height:number}} o.min minimum panel size + * @param {{minX:number,minY:number,maxX:number,maxY:number}} o.bounds viewport limits + * @returns {import('../types').Rect} + */ +export function resizeRect({ rect, corner, dx, dy, min, bounds }) { + const right = rect.left + rect.width + const bottom = rect.top + rect.height + const west = corner.includes('w') + const north = corner.includes('n') + let left, top, width, height + + if (west) { + // West edge moves; the east edge (right) stays pinned. + left = clamp(rect.left + dx, bounds.minX, right - min.width) + width = right - left + } else { + left = rect.left + width = clamp(rect.width + dx, min.width, bounds.maxX - rect.left) + } + if (north) { + top = clamp(rect.top + dy, bounds.minY, bottom - min.height) + height = bottom - top + } else { + top = rect.top + height = clamp(rect.height + dy, min.height, bounds.maxY - rect.top) + } + return { left, top, width, height } +} + +/** A rect of the given size centred in a vw×vh viewport. */ +export function centeredRect(width, height, vw, vh) { + return { left: Math.round((vw - width) / 2), top: Math.round((vh - height) / 2), width, height } +} diff --git a/src/renderer/src/utils/spreadsheetDiff.js b/src/renderer/src/utils/spreadsheetDiff.js new file mode 100644 index 0000000..7075f1b --- /dev/null +++ b/src/renderer/src/utils/spreadsheetDiff.js @@ -0,0 +1,85 @@ +// Workbook-level diff: pair sheets by name across the two files, align each +// pair's rows (utils/alignRows), and roll up per-sheet stats. Pure — the viewer +// and its composable render whatever this returns. +import { alignRows } from './alignRows' + +// 0 -> "A", 25 -> "Z", 26 -> "AA" (bijective base-26), for the grid's column +// headers. +export function columnName(index) { + let n = index + 1 + let name = '' + while (n > 0) { + const rem = (n - 1) % 26 + name = String.fromCharCode(65 + rem) + name + n = Math.floor((n - 1) / 26) + } + return name +} + +function statsOf(rows) { + const stats = { changed: 0, added: 0, removed: 0 } + let columns = 0 + for (const r of rows) { + if (r.status !== 'same') stats[r.status]++ + columns = Math.max(columns, r.left?.length ?? 0, r.right?.length ?? 0) + } + return { stats, columns } +} + +function bothSides(name, left, right, opts) { + const rows = alignRows(left.rows ?? [], right.rows ?? [], opts) + const { stats, columns } = statsOf(rows) + return { name, present: 'both', rows, stats, columns, changes: total(stats) } +} + +function oneSide(name, sheet, side) { + const status = side === 'left' ? 'removed' : 'added' + const rows = (sheet.rows ?? []).map((r, idx) => ({ + status, + left: side === 'left' ? r : null, + right: side === 'left' ? null : r, + leftIndex: side === 'left' ? idx : null, + rightIndex: side === 'left' ? null : idx, + changed: [] + })) + const { stats, columns } = statsOf(rows) + return { name, present: side, rows, stats, columns, changes: total(stats) } +} + +function total(stats) { + return stats.changed + stats.added + stats.removed +} + +// The grid renders real DOM rows, so an enormous sheet would freeze the window +// (there's no virtualization yet). Cap what we hand the viewer and report how +// many rows are held back, rather than letting the app hang. A follow-up can +// swap this for true row virtualization. +export const RENDER_ROW_CAP = 3000 +export function pageRows(rows, cap = RENDER_ROW_CAP) { + if (rows.length <= cap) return { rows, hidden: 0 } + return { rows: rows.slice(0, cap), hidden: rows.length - cap } +} + +/** + * @returns {Array<{name:string, present:'both'|'left'|'right', rows:Array, + * stats:{changed:number,added:number,removed:number}, columns:number, + * changes:number}>} + */ +export function diffWorkbooks(leftSheets = [], rightSheets = [], opts = {}) { + const rightByName = new Map(rightSheets.map((s) => [s.name, s])) + const seen = new Set() + const out = [] + for (const left of leftSheets) { + const right = rightByName.get(left.name) + if (right) { + seen.add(left.name) + out.push(bothSides(left.name, left, right, opts)) + } else { + out.push(oneSide(left.name, left, 'left')) + } + } + for (const right of rightSheets) { + if (!seen.has(right.name)) out.push(oneSide(right.name, right, 'right')) + } + return out +} diff --git a/src/renderer/src/utils/themes.js b/src/renderer/src/utils/themes.js index 77221cd..caf3dfe 100644 --- a/src/renderer/src/utils/themes.js +++ b/src/renderer/src/utils/themes.js @@ -36,6 +36,24 @@ export const THEMES = [ label: 'Contrast', dark: false, swatch: { bg: '#ffffff', accent: '#1633d4', add: '#05702f', del: '#c20000' } + }, + { + id: 'nord', + label: 'Nord', + dark: true, + swatch: { bg: '#3b4252', accent: '#88c0d0', add: '#a3be8c', del: '#bf616a' } + }, + { + id: 'sepia', + label: 'Sepia', + dark: false, + swatch: { bg: '#dfcea6', accent: '#9c4f1f', add: '#5a6f28', del: '#933a22' } + }, + { + id: 'nyan', + label: 'Nyan', + dark: true, + swatch: { bg: '#231033', accent: '#ff2ecb', add: '#63ff4d', del: '#ff5470' } } ] @@ -47,3 +65,13 @@ export const isValidTheme = (id) => BY_ID.has(id) // Unknown/absent ids fall back to the default rather than breaking the UI. export const normalizeTheme = (id) => (BY_ID.has(id) ? id : DEFAULT_THEME) export const isDarkTheme = (id) => BY_ID.get(id)?.dark ?? false + +// The theme for a given calendar day — random-looking but STABLE within a day, +// so the "rotate daily" option doesn't reshuffle on every launch, only at the +// date rollover. Deterministic hash of the local Y-M-D → an index into THEMES. +export function themeForDay(date = new Date()) { + const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` + let hash = 0 + for (let i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) >>> 0 + return THEMES[hash % THEMES.length].id +} diff --git a/tests/main/logFormat.test.js b/tests/main/logFormat.test.js new file mode 100644 index 0000000..5e4fcf7 --- /dev/null +++ b/tests/main/logFormat.test.js @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + formatLogEntry, + dailyLogName, + isLogFileName, + redactHome, + capLog, + staleLogFiles +} from '../../src/main/logFormat' + +const AT = new Date('2026-07-23T10:20:30.000Z') + +describe('formatLogEntry', () => { + it('formats a one-line, timestamped, source-tagged head with an indented stack', () => { + const out = formatLogEntry( + { source: 'renderer', message: 'Boom happened', stack: 'Error: Boom\n at foo (a.js:1)' }, + AT + ) + expect(out).toMatch(/^\[2026-07-23T10:20:30\.000Z\] \[renderer\] Boom happened\n/) + expect(out).toContain('\n Error: Boom') + expect(out.endsWith('\n')).toBe(true) + }) + + it('collapses newlines in the message so entries stay greppable', () => { + const out = formatLogEntry({ message: 'line one\nline two' }, AT) + expect(out.split('\n')[0]).toBe('[2026-07-23T10:20:30.000Z] [app] line one line two') + }) + + it('truncates an enormous message and stack instead of rejecting them', () => { + const out = formatLogEntry({ message: 'x'.repeat(10_000), stack: 'y'.repeat(50_000) }, AT) + expect(out).toContain('…(truncated)') + expect(out.length).toBeLessThan(25_000) + }) + + it('includes context and env when present', () => { + const out = formatLogEntry( + { message: 'm', context: 'window.onerror', appVersion: '0.1.0', platform: 'darwin' }, + AT + ) + expect(out).toContain('context: window.onerror') + expect(out).toContain('env: 0.1.0 darwin') + }) +}) + +describe('dailyLogName / isLogFileName', () => { + it('names one file per calendar day', () => { + expect(dailyLogName(new Date(2026, 6, 5))).toBe('diffbro-2026-07-05.log') + }) + it('recognises its own files and rejects others', () => { + expect(isLogFileName('diffbro-2026-07-05.log')).toBe(true) + expect(isLogFileName('vault.key')).toBe(false) + expect(isLogFileName('diffbro-2026-07-05.txt')).toBe(false) + }) +}) + +describe('redactHome', () => { + it('replaces every occurrence of the home path with ~', () => { + const text = '/Users/jane/Docs/a.txt and /Users/jane/b' + expect(redactHome(text, '/Users/jane')).toBe('~/Docs/a.txt and ~/b') + }) + it('is a no-op without a home dir', () => { + expect(redactHome('untouched', '')).toBe('untouched') + }) +}) + +describe('capLog', () => { + const entry = (n) => `[t${n}] msg${n}\n` + it('appends when under the cap', () => { + expect(capLog(entry(1), entry(2), 1000)).toBe(entry(1) + entry(2)) + }) + it('drops whole oldest entries to fit, never splitting one', () => { + const existing = entry(1) + entry(2) + entry(3) + const out = capLog(existing, entry(4), entry(1).length * 2 + 1) + expect(out).not.toContain('msg1') + expect(out).toContain('msg4') + // Every retained line is a complete entry head or body. + expect(out.startsWith('[')).toBe(true) + }) + it('keeps a single oversized entry rather than emptying the file', () => { + const big = `[t] ${'z'.repeat(100)}\n` + expect(capLog('', big, 10)).toBe(big) + }) +}) + +describe('staleLogFiles', () => { + it('returns the oldest daily files beyond the retention window', () => { + const names = [ + 'diffbro-2026-07-20.log', + 'diffbro-2026-07-21.log', + 'diffbro-2026-07-22.log', + 'diffbro-2026-07-23.log', + 'other.txt' + ] + expect(staleLogFiles(names, 2)).toEqual(['diffbro-2026-07-20.log', 'diffbro-2026-07-21.log']) + }) + it('keeps everything when within the window and never touches non-log files', () => { + expect(staleLogFiles(['diffbro-2026-07-23.log', 'vault.key'], 7)).toEqual([]) + }) +}) diff --git a/tests/main/xlsx/reader.test.js b/tests/main/xlsx/reader.test.js new file mode 100644 index 0000000..6daec88 --- /dev/null +++ b/tests/main/xlsx/reader.test.js @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'vitest' +import { zipSync, strToU8 } from 'fflate' +import { readXlsx } from '../../../src/main/xlsx/index' +import { extractXlsxEntries, isAllowedEntry } from '../../../src/main/xlsx/unzip' +import { colToIndex, resolveTarget } from '../../../src/main/xlsx/parse' +import { resolveCellValue } from '../../../src/main/xlsx/sheet' + +const XML = '<?xml version="1.0" encoding="UTF-8"?>' +const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet' + +// Build a real .xlsx buffer from XML parts via fflate — no binary fixture on +// disk, and it exercises the actual unzip path. +function buildXlsx(parts) { + const files = {} + for (const [name, xml] of Object.entries(parts)) files[name] = strToU8(xml) + return Buffer.from(zipSync(files)) +} + +function workbook(sheets) { + const rows = sheets + .map((s, i) => `<sheet name="${s}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`) + .join('') + return `${XML}<workbook xmlns:r="r"><sheets>${rows}</sheets></workbook>` +} +function rels(targets) { + const r = targets + .map((t, i) => `<Relationship Id="rId${i + 1}" Type="${REL}" Target="${t}"/>`) + .join('') + return `${XML}<Relationships>${r}</Relationships>` +} +function sst(items) { + return `${XML}<sst>${items.map((s) => `<si><t>${s}</t></si>`).join('')}</sst>` +} +function sheet(body) { + return `${XML}<worksheet><sheetData>${body}</sheetData></worksheet>` +} + +// A canonical one-sheet workbook: two string cells, a number, and a formula +// cell whose cached value must be read but whose <f> must never be. +function sampleWorkbook(extra = {}) { + return buildXlsx({ + 'xl/workbook.xml': workbook(['Budget']), + 'xl/_rels/workbook.xml.rels': rels(['worksheets/sheet1.xml']), + 'xl/sharedStrings.xml': sst(['Region', 'North']), + 'xl/worksheets/sheet1.xml': sheet( + '<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>100</v></c></row>' + + '<row r="2"><c r="A2" t="s"><v>1</v></c><c r="B2"><f>B1+50</f><v>150</v></c></row>' + ), + ...extra + }) +} + +describe('readXlsx — happy path', () => { + it('extracts rows with shared strings, numbers, and cached formula values', () => { + const { sheets } = readXlsx(sampleWorkbook()) + expect(sheets).toHaveLength(1) + expect(sheets[0].name).toBe('Budget') + expect(sheets[0].rows).toEqual([ + ['Region', 100], + ['North', 150] + ]) + }) + + it('maps sheet names to worksheets through the rels, respecting order', () => { + const buf = buildXlsx({ + 'xl/workbook.xml': workbook(['First', 'Second']), + // rId1 -> sheet2.xml, rId2 -> sheet1.xml: order comes from rels, not names + 'xl/_rels/workbook.xml.rels': rels(['worksheets/sheet2.xml', 'worksheets/sheet1.xml']), + 'xl/sharedStrings.xml': sst(['a', 'b']), + 'xl/worksheets/sheet1.xml': sheet('<row r="1"><c r="A1" t="s"><v>1</v></c></row>'), + 'xl/worksheets/sheet2.xml': sheet('<row r="1"><c r="A1" t="s"><v>0</v></c></row>') + }) + const { sheets } = readXlsx(buf) + expect(sheets.map((s) => s.name)).toEqual(['First', 'Second']) + expect(sheets[0].rows).toEqual([['a']]) // First -> rId1 -> sheet2 -> shared[0] + expect(sheets[1].rows).toEqual([['b']]) // Second -> rId2 -> sheet1 -> shared[1] + }) + + it('reads inline strings, booleans, and leaves gaps as null', () => { + const buf = sampleWorkbook({ + 'xl/worksheets/sheet1.xml': sheet( + '<row r="1">' + + '<c r="A1" t="inlineStr"><is><t>Hi</t></is></c>' + + '<c r="C1" t="b"><v>1</v></c>' + // B1 skipped -> null gap + '</row>' + ) + }) + expect(readXlsx(buf).sheets[0].rows).toEqual([['Hi', null, true]]) + }) + + it('works with no sharedStrings part', () => { + const buf = buildXlsx({ + 'xl/workbook.xml': workbook(['S']), + 'xl/_rels/workbook.xml.rels': rels(['worksheets/sheet1.xml']), + 'xl/worksheets/sheet1.xml': sheet('<row r="1"><c r="A1"><v>42</v></c></row>') + }) + expect(readXlsx(buf).sheets[0].rows).toEqual([[42]]) + }) +}) + +describe('readXlsx — security refusals', () => { + it('rejects a DOCTYPE (XXE / billion-laughs guard)', () => { + const buf = sampleWorkbook({ + 'xl/sharedStrings.xml': `${XML}<!DOCTYPE x [<!ENTITY a "boom">]><sst><si><t>x</t></si></sst>` + }) + expect(() => readXlsx(buf)).toThrowError(/DOCTYPE/i) + }) + + it('refuses a decompression bomb via the total-size cap', () => { + expect(() => readXlsx(sampleWorkbook(), { maxTotalBytes: 10 })).toThrowError(/too much data/i) + }) + + it('refuses when a single entry exceeds the per-entry cap', () => { + expect(() => readXlsx(sampleWorkbook(), { maxEntryBytes: 5 })).toThrowError(/too large/i) + }) + + it('refuses when the whole file exceeds the input cap', () => { + expect(() => readXlsx(sampleWorkbook(), { maxInputBytes: 5 })).toThrowError(/maximum .xlsx size/i) + }) + + it('enforces the per-sheet cell budget', () => { + const cells = Array.from({ length: 20 }, (_, i) => `<c r="A${i + 1}"><v>${i}</v></c>`) + .map((c) => `<row>${c}</row>`) + .join('') + const buf = sampleWorkbook({ 'xl/worksheets/sheet1.xml': sheet(cells) }) + expect(() => readXlsx(buf, { maxCells: 5 })).toThrowError(/cell count/i) + }) + + it('never inflates non-allowlisted entries (VBA, external links, media)', () => { + const buf = sampleWorkbook({ + 'xl/vbaProject.bin': 'MZ\x00\x00 not really vba', + 'xl/externalLinks/externalLink1.xml': `${XML}<externalLink/>`, + 'xl/media/image1.png': 'PNGDATA' + }) + const entries = extractXlsxEntries(buf) + expect([...entries.keys()].some((n) => n.includes('vba'))).toBe(false) + expect([...entries.keys()].some((n) => n.includes('externalLinks'))).toBe(false) + expect([...entries.keys()].some((n) => n.includes('media'))).toBe(false) + // and it still reads fine + expect(readXlsx(buf).sheets[0].rows[0]).toEqual(['Region', 100]) + }) + + it('rejects data that is not a zip archive', () => { + expect(() => readXlsx(Buffer.from('not a zip at all'))).toThrowError(/readable .xlsx/i) + }) + + it('does not pollute Object.prototype from hostile relationship ids or names', () => { + const buf = buildXlsx({ + 'xl/workbook.xml': `${XML}<workbook xmlns:r="r"><sheets>` + + '<sheet name="__proto__" sheetId="1" r:id="__proto__"/></sheets></workbook>', + 'xl/_rels/workbook.xml.rels': + `${XML}<Relationships><Relationship Id="__proto__" Type="${REL}" ` + + 'Target="worksheets/sheet1.xml"/></Relationships>', + 'xl/worksheets/sheet1.xml': sheet('<row r="1"><c r="A1"><v>1</v></c></row>') + }) + readXlsx(buf) + expect({}.polluted).toBeUndefined() + expect(Object.prototype.polluted).toBeUndefined() + expect(Object.getPrototypeOf({})).toBe(Object.prototype) + }) +}) + +describe('unit helpers', () => { + it('isAllowedEntry allows exactly the interpreted parts', () => { + expect(isAllowedEntry('xl/workbook.xml')).toBe(true) + expect(isAllowedEntry('xl/worksheets/sheet12.xml')).toBe(true) + expect(isAllowedEntry('xl/sharedStrings.xml')).toBe(true) + expect(isAllowedEntry('xl/_rels/workbook.xml.rels')).toBe(true) + expect(isAllowedEntry('xl/vbaProject.bin')).toBe(false) + expect(isAllowedEntry('xl/styles.xml')).toBe(false) + expect(isAllowedEntry('xl/worksheets/_rels/sheet1.xml.rels')).toBe(false) + }) + + it('colToIndex maps A1-style refs to 0-based columns', () => { + expect(colToIndex('A1')).toBe(0) + expect(colToIndex('B12')).toBe(1) + expect(colToIndex('Z9')).toBe(25) + expect(colToIndex('AA1')).toBe(26) + expect(colToIndex('AB2')).toBe(27) + expect(colToIndex('123')).toBe(-1) + }) + + it('resolveTarget handles relative and package-absolute targets', () => { + expect(resolveTarget('worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml') + expect(resolveTarget('/xl/worksheets/sheet3.xml')).toBe('xl/worksheets/sheet3.xml') + }) + + it('resolveCellValue types values and bounds shared-string indices', () => { + expect(resolveCellValue('s', '1', '', ['a', 'b'])).toBe('b') + expect(resolveCellValue('s', '9', '', ['a'])).toBe('') // out of range -> empty + expect(resolveCellValue('b', '1', '', [])).toBe(true) + expect(resolveCellValue('b', '0', '', [])).toBe(false) + expect(resolveCellValue('inlineStr', '', 'hi', [])).toBe('hi') + expect(resolveCellValue('', '3.5', '', [])).toBe(3.5) + expect(resolveCellValue('', '', '', [])).toBe('') + }) +}) diff --git a/tests/renderer/adapters/xlsxAdapter.test.js b/tests/renderer/adapters/xlsxAdapter.test.js new file mode 100644 index 0000000..1561ed5 --- /dev/null +++ b/tests/renderer/adapters/xlsxAdapter.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { xlsxAdapter } from '../../../src/renderer/src/adapters/xlsxAdapter' +import { textAdapter } from '../../../src/renderer/src/adapters/textAdapter' +import { resolveAdapter } from '../../../src/renderer/src/adapters' + +const sheetFile = (sheets) => ({ name: 'book.xlsx', kind: 'spreadsheet', sheets }) + +describe('xlsxAdapter', () => { + it('is resolved for a parsed spreadsheet file', () => { + expect(resolveAdapter(sheetFile([]))).toBe(xlsxAdapter) + }) + + it('does not claim text files, even ones named .xlsx without a parsed grid', () => { + // Only main sets kind:'spreadsheet'. A plain object named .xlsx with no + // kind is not a parsed spreadsheet and must fall through to text. + expect(resolveAdapter({ name: 'a.xlsx', content: 'hi' })).toBe(textAdapter) + expect(resolveAdapter({ name: 'a.txt', content: 'hi' })).toBe(textAdapter) + }) + + it('turns the loaded sheets into a spreadsheet comparable', () => { + const sheets = [{ name: 'S1', rows: [['a', 1]] }] + expect(xlsxAdapter.toComparable(sheetFile(sheets))).toEqual({ kind: 'spreadsheet', sheets }) + }) + + it('tolerates a missing sheets array', () => { + expect(xlsxAdapter.toComparable({ name: 'x.xlsx', kind: 'spreadsheet' })).toEqual({ + kind: 'spreadsheet', + sheets: [] + }) + }) +}) diff --git a/tests/renderer/composables/usePasteShortcut.test.js b/tests/renderer/composables/usePasteShortcut.test.js new file mode 100644 index 0000000..11fd3b3 --- /dev/null +++ b/tests/renderer/composables/usePasteShortcut.test.js @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { isEditableTarget, isPasteChord } from '../../../src/renderer/src/composables/usePasteShortcut' + +describe('isPasteChord', () => { + const chord = (over) => ({ key: 'v', ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...over }) + + it('accepts Ctrl+V and Cmd+V', () => { + expect(isPasteChord(chord({ ctrlKey: true }))).toBe(true) + expect(isPasteChord(chord({ metaKey: true }))).toBe(true) + expect(isPasteChord(chord({ ctrlKey: true, key: 'V' }))).toBe(true) + }) + + it('rejects V without a modifier, and modifier combos with alt/shift', () => { + expect(isPasteChord(chord({}))).toBe(false) + expect(isPasteChord(chord({ ctrlKey: true, shiftKey: true }))).toBe(false) + expect(isPasteChord(chord({ ctrlKey: true, altKey: true }))).toBe(false) + expect(isPasteChord(chord({ ctrlKey: true, key: 'c' }))).toBe(false) + }) +}) + +describe('isEditableTarget', () => { + it('is true for inputs, textareas, and contenteditable', () => { + expect(isEditableTarget({ tagName: 'INPUT' })).toBe(true) + expect(isEditableTarget({ tagName: 'TEXTAREA' })).toBe(true) + expect(isEditableTarget({ tagName: 'DIV', isContentEditable: true })).toBe(true) + }) + it('is false for non-editable elements and null', () => { + expect(isEditableTarget({ tagName: 'DIV' })).toBe(false) + expect(isEditableTarget({ tagName: 'BUTTON' })).toBe(false) + expect(isEditableTarget(null)).toBe(false) + }) +}) diff --git a/tests/renderer/composables/useSectionReorder.test.js b/tests/renderer/composables/useSectionReorder.test.js new file mode 100644 index 0000000..bbceb9d --- /dev/null +++ b/tests/renderer/composables/useSectionReorder.test.js @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useSectionReorder } from '../../../src/renderer/src/composables/useSectionReorder' +import { useSettingsStore, SECTIONS } from '../../../src/renderer/src/stores/settingsStore' + +// A drag event carrying a spyable dataTransfer. +const dragEvent = () => ({ dataTransfer: { effectAllowed: '', setData: vi.fn() } }) + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() +}) + +describe('useSectionReorder', () => { + it('a full drag from one header to another reorders the sections', () => { + const r = useSectionReorder() + const e = dragEvent() + r.onDragStart('snippets', e) + expect(e.dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'snippets') + r.onDrop('saved') // dropped before "saved" + expect(useSettingsStore().sectionOrder).toEqual(['snippets', 'saved', 'external']) + }) + + it('marks other sections as drop targets while a drag is in flight', () => { + const r = useSectionReorder() + expect(r.isDropTarget('saved')).toBe(false) // nothing dragging yet + r.onDragStart('saved', dragEvent()) + expect(r.isDropTarget('saved')).toBe(false) // not the one being dragged + expect(r.isDropTarget('external')).toBe(true) + r.onDragEnd() + expect(r.isDropTarget('external')).toBe(false) + }) + + it('does nothing while the order is locked', () => { + useSettingsStore().toggleSectionsLock() + const r = useSectionReorder() + r.onDragStart('snippets', dragEvent()) // refused: no drag begins + expect(r.isDropTarget('saved')).toBe(false) + r.onDrop('saved') + expect(useSettingsStore().sectionOrder).toEqual(SECTIONS) + }) +}) diff --git a/tests/renderer/composables/useSpreadsheetDiff.test.js b/tests/renderer/composables/useSpreadsheetDiff.test.js new file mode 100644 index 0000000..109fa42 --- /dev/null +++ b/tests/renderer/composables/useSpreadsheetDiff.test.js @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useDiffStore } from '../../../src/renderer/src/stores/diffStore' +import { useSpreadsheetDiff } from '../../../src/renderer/src/composables/useSpreadsheetDiff' + +const book = (sheets) => ({ name: 'book.xlsx', kind: 'spreadsheet', sheets }) + +beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + window.api = {} +}) + +describe('useSpreadsheetDiff', () => { + it('diffs the two loaded workbooks into per-sheet results', () => { + const store = useDiffStore() + store.left = book([{ name: 'S', rows: [['a', 1]] }]) + store.right = book([{ name: 'S', rows: [['a', 2]] }]) + const { sheets, totals, identical } = useSpreadsheetDiff() + expect(sheets.value).toHaveLength(1) + expect(totals.value).toEqual({ changed: 1, added: 0, removed: 0 }) + expect(identical.value).toBe(false) + }) + + it('reports identical when nothing differs', () => { + const store = useDiffStore() + const sheetsData = [{ name: 'S', rows: [['a', 1]] }] + store.left = book(sheetsData) + store.right = book(sheetsData) + expect(useSpreadsheetDiff().identical.value).toBe(true) + }) + + it('select changes the active sheet and ignores out-of-range indices', () => { + const store = useDiffStore() + const two = [ + { name: 'One', rows: [['a']] }, + { name: 'Two', rows: [['b']] } + ] + store.left = book(two) + store.right = book(two) + const { active, activeSheet, select } = useSpreadsheetDiff() + expect(activeSheet.value.name).toBe('One') + select(1) + expect(activeSheet.value.name).toBe('Two') + select(5) // out of range — ignored + expect(active.value).toBe(1) + }) + + it('clamps the active sheet when the sheet count shrinks', () => { + const store = useDiffStore() + store.left = book([ + { name: 'One', rows: [['a']] }, + { name: 'Two', rows: [['b']] } + ]) + store.right = store.left + const { active, activeSheet } = useSpreadsheetDiff() + active.value = 1 + // Now only one sheet on each side: the clamp keeps activeSheet valid. + store.left = book([{ name: 'One', rows: [['a']] }]) + store.right = book([{ name: 'One', rows: [['a']] }]) + expect(activeSheet.value.name).toBe('One') + }) +}) diff --git a/tests/renderer/stores/diffStore.test.js b/tests/renderer/stores/diffStore.test.js index 62b1831..4da92dd 100644 --- a/tests/renderer/stores/diffStore.test.js +++ b/tests/renderer/stores/diffStore.test.js @@ -37,6 +37,80 @@ describe('diffStore', () => { expect(store.notice).toContain('blob.bin') }) + it('rejects an unreadable spreadsheet with a notice and leaves the slot empty', () => { + const store = useDiffStore() + store.receive('left', { error: 'xlsx', name: 'book.xlsx', message: 'DOCTYPE not allowed' }) + expect(store.left).toBeNull() + expect(store.notice).toContain('book.xlsx') + expect(store.notice).toContain('DOCTYPE not allowed') + }) + + it('loads a parsed spreadsheet and routes it to the grid viewer', () => { + const store = useDiffStore() + const sheets = [{ name: 'S1', rows: [['Region', 100]] }] + store.receive('left', { path: '/tmp/l.xlsx', name: 'l.xlsx', kind: 'spreadsheet', sheets }) + store.receive('right', { path: '/tmp/r.xlsx', name: 'r.xlsx', kind: 'spreadsheet', sheets }) + expect(store.ready).toBe(true) + expect(store.comparableKind).toBe('spreadsheet') + expect(store.leftComparable).toEqual({ kind: 'spreadsheet', sheets }) + }) + + it('comparableKind is text for the empty and text-file states', () => { + const store = useDiffStore() + expect(store.comparableKind).toBe('text') + store.left = FILE('a.txt') + expect(store.comparableKind).toBe('text') + }) + + it('refuses a spreadsheet dropped into paste mode', () => { + const store = useDiffStore() + store.receivePasteFile('left', { name: 'book.xlsx', kind: 'spreadsheet', sheets: [] }) + expect(store.pasteLeftFile).toBeNull() + expect(store.notice).toContain('book.xlsx') + }) + + it('paste-to-compare: confirming reads the clipboard into the first empty side', async () => { + window.api = { readText: () => Promise.resolve('pasted body') } + const store = useDiffStore() + store.requestPasteFromClipboard() + expect(store.pastePrompt).toBe('enter') + await store.confirmPasteEnter() + expect(store.mode).toBe('paste') + expect(store.pasteLeft).toBe('pasted body') + expect(store.pastePrompt).toBeNull() + }) + + it('paste-to-compare: fills the right side when the left already has content', async () => { + window.api = { readText: () => Promise.resolve('second') } + const store = useDiffStore() + store.pasteLeft = 'first' + await store.confirmPasteEnter() + expect(store.pasteRight).toBe('second') + expect(store.pastePrompt).toBeNull() + }) + + it('paste-to-compare: both sides full escalates to the overwrite confirm', async () => { + window.api = { readText: () => Promise.resolve('third') } + const store = useDiffStore() + store.pasteLeft = 'first' + store.pasteRight = 'second' + await store.confirmPasteEnter() + expect(store.pastePrompt).toBe('overwrite') + expect(store.pasteLeft).toBe('first') // nothing clobbered yet + store.confirmPasteOverwrite() + expect(store.pasteLeft).toBe('third') // left replaced, right kept + expect(store.pasteRight).toBe('second') + expect(store.pastePrompt).toBeNull() + }) + + it('paste-to-compare: an empty clipboard notices and does not enter a prompt', async () => { + window.api = { readText: () => Promise.resolve(' ') } + const store = useDiffStore() + await store.confirmPasteEnter() + expect(store.pastePrompt).toBeNull() + expect(store.notice).toContain('clipboard is empty') + }) + it('swap exchanges the two sides', () => { const store = useDiffStore() store.left = FILE('a.txt') @@ -296,6 +370,53 @@ describe('diffStore', () => { expect(store.leftFormatHint).not.toBeNull() // new content, dismissal doesn't carry over }) + it('merges both sides into one banner with a Format-both action', () => { + const store = useDiffStore() + store.left = { path: '/tmp/a.json', name: 'a.json', content: '{"a":1}' } + store.right = { path: '/tmp/b.json', name: 'b.json', content: '{"b":2}' } + const banner = store.formatBanner + expect(banner.message).toBe('Both sides look like JSON — pretty-print?') + expect(banner.formatBoth).toBe(true) + expect(banner.invalid).toBe(false) + expect(banner.dismissSides).toEqual(['left', 'right']) + + store.formatBoth() + expect(store.left.content).toBe('{\n "a": 1\n}') + expect(store.right.content).toBe('{\n "b": 2\n}') + expect(store.formatBanner).toBeNull() // both pretty now, banner clears itself + }) + + it('names the single formattable side when the other is invalid', () => { + const store = useDiffStore() + store.left = { path: '/tmp/a.json', name: 'a.json', content: '{"a":1}' } + store.right = { path: '/tmp/b.json', name: 'b.json', content: '{"b": 2,}' } + const banner = store.formatBanner + expect(banner.formatBoth).toBe(false) + expect(banner.formatSide).toBe('left') + expect(banner.formatLabel).toBe('Format Left') + expect(banner.invalid).toBe(false) // still actionable — the left side can format + expect(banner.message).toContain("doesn't parse") + }) + + it('is a red, actionless banner when both sides are invalid, and dismiss silences both', () => { + const store = useDiffStore() + store.left = { path: '/tmp/a.json', name: 'a.json', content: '{"a": 1,}' } + store.right = { path: '/tmp/b.json', name: 'b.json', content: '{"b": 2,}' } + const banner = store.formatBanner + expect(banner.invalid).toBe(true) + expect(banner.formatBoth).toBe(false) + expect(banner.formatSide).toBeNull() + + store.dismissFormatHints(banner.dismissSides) + expect(store.formatBanner).toBeNull() + }) + + it('has no banner when neither side has a hint', () => { + const store = useDiffStore() + store.left = { path: '/tmp/a.txt', name: 'a.txt', content: 'plain' } + expect(store.formatBanner).toBeNull() + }) + it('defaults to Light and toggleTheme flips the ground, persisting + stamping', () => { const store = useDiffStore() expect(store.theme).toBe('light') // Light is the default @@ -319,6 +440,30 @@ describe('diffStore', () => { store.setTheme('bogus') expect(store.theme).toBe('light') }) + + it('daily rotation overrides the active theme but keeps the saved choice, reverting when off', async () => { + const { useSettingsStore } = await import('../../../src/renderer/src/stores/settingsStore') + const settings = useSettingsStore() + const store = useDiffStore() + store.setTheme('neon') // the user's saved pick + + settings.setRotateThemeDaily(true) + store.resolveActiveTheme() + const { themeForDay } = await import('../../../src/renderer/src/utils/themes') + expect(store.theme).toBe(themeForDay()) // active is the day's theme + expect(store.userTheme).toBe('neon') // saved choice untouched + + // Picking a theme while rotating saves it but doesn't override today's theme. + store.setTheme('solar') + expect(store.userTheme).toBe('solar') + expect(store.theme).toBe(themeForDay()) + + // Turning rotation off reverts to the saved choice. + settings.setRotateThemeDaily(false) + store.resolveActiveTheme() + expect(store.theme).toBe('solar') + }) + it('adds a trusted key before clearing the pending state, then opens the manager', async () => { const store = useDiffStore() const seen = [] diff --git a/tests/renderer/stores/errorStore.test.js b/tests/renderer/stores/errorStore.test.js new file mode 100644 index 0000000..0de0270 --- /dev/null +++ b/tests/renderer/stores/errorStore.test.js @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useErrorStore } from '../../../src/renderer/src/stores/errorStore' + +beforeEach(() => { + setActivePinia(createPinia()) + window.api = { logError: vi.fn() } +}) + +describe('errorStore', () => { + it('forwards an Error to the local log and raises the dialog', () => { + const s = useErrorStore() + s.capture(new Error('kaboom'), 'window.error') + expect(s.visible).toBe(true) + expect(s.lastError.message).toBe('kaboom') + expect(window.api.logError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'kaboom', context: 'window.error' }) + ) + }) + + it('normalises a PromiseRejectionEvent-like reason', () => { + const s = useErrorStore() + s.capture({ reason: new Error('rejected') }, 'unhandledrejection') + expect(s.lastError.message).toBe('rejected') + }) + + it('handles a plain string error', () => { + const s = useErrorStore() + s.capture('bare string', 'window.error') + expect(s.lastError.message).toBe('bare string') + }) + + it('throttles identical repeats: no second log, no re-raise', () => { + const s = useErrorStore() + s.capture(new Error('same'), 'ctx') + s.dismiss() + s.capture(new Error('same'), 'ctx') // within the window -> dropped + expect(window.api.logError).toHaveBeenCalledTimes(1) + expect(s.visible).toBe(false) + }) + + it('logs a genuinely different error even right after another', () => { + const s = useErrorStore() + s.capture(new Error('one'), 'ctx') + s.capture(new Error('two'), 'ctx') + expect(window.api.logError).toHaveBeenCalledTimes(2) + expect(s.lastError.message).toBe('two') + }) + + it('ignores benign framework noise (Monaco Canceled, ResizeObserver, opaque script error)', () => { + const s = useErrorStore() + s.capture(new Error('Canceled'), 'unhandledrejection') + const cancellation = Object.assign(new Error('operation cancelled'), { name: 'CancellationError' }) + s.capture({ reason: cancellation }, 'unhandledrejection') + s.capture('ResizeObserver loop completed with undelivered notifications', 'window.error') + s.capture('Script error.', 'window.error') + expect(s.visible).toBe(false) + expect(window.api.logError).not.toHaveBeenCalled() + }) + + it('never throws when the log bridge is unavailable', () => { + window.api = {} + const s = useErrorStore() + expect(() => s.capture(new Error('x'))).not.toThrow() + expect(s.visible).toBe(true) + }) +}) diff --git a/tests/renderer/stores/settingsStore.test.js b/tests/renderer/stores/settingsStore.test.js index 01ab28c..3c61f37 100644 --- a/tests/renderer/stores/settingsStore.test.js +++ b/tests/renderer/stores/settingsStore.test.js @@ -3,8 +3,7 @@ import { createPinia, setActivePinia } from 'pinia' import { useSettingsStore, SECTIONS, - DEFAULT_MAX_COMPARISON_FILE_MB, - MAX_COMPARISON_FILE_MB_CAP, + FILE_TYPE_LIMITS, MAX_SNIPPET_SIZE_KB_CAP } from '../../../src/renderer/src/stores/settingsStore' @@ -18,7 +17,9 @@ describe('settingsStore', () => { const s = useSettingsStore() expect(s.sectionOrder).toEqual(SECTIONS) expect(s.showShortcutBar).toBe(true) - expect(s.maxComparisonFileMb).toBe(DEFAULT_MAX_COMPARISON_FILE_MB) + expect(s.fileSizeLimitMb('text')).toBe(FILE_TYPE_LIMITS.text.default) + expect(s.fileSizeLimitMb('spreadsheet')).toBe(FILE_TYPE_LIMITS.spreadsheet.default) + expect(s.fileSizeLimitBytes('spreadsheet')).toBe(FILE_TYPE_LIMITS.spreadsheet.default * 1024 * 1024) expect(s.maxSnippetSizeKb).toBeGreaterThan(0) }) @@ -72,21 +73,88 @@ describe('settingsStore', () => { expect(useSettingsStore().showShortcutBar).toBe(false) }) - it('clamps the size guards to their safe ranges', () => { + it('clamps each file-type size guard to its own cap, independently', () => { + const s = useSettingsStore() + s.setFileSizeLimitMb('spreadsheet', 999999) + expect(s.fileSizeLimitMb('spreadsheet')).toBe(FILE_TYPE_LIMITS.spreadsheet.cap) + s.setFileSizeLimitMb('spreadsheet', 0) + expect(s.fileSizeLimitMb('spreadsheet')).toBe(1) + // The text limit is untouched by changes to the spreadsheet one. + expect(s.fileSizeLimitMb('text')).toBe(FILE_TYPE_LIMITS.text.default) + s.setFileSizeLimitMb('text', 999999) + expect(s.fileSizeLimitMb('text')).toBe(FILE_TYPE_LIMITS.text.cap) + s.setFileSizeLimitMb('bogus', 50) // unknown type ignored + expect(s.fileSizeLimitsMb.bogus).toBeUndefined() + }) + + it('clamps the snippet size guard to its safe range', () => { const s = useSettingsStore() - s.setMaxComparisonFileMb(999999) - expect(s.maxComparisonFileMb).toBe(MAX_COMPARISON_FILE_MB_CAP) - s.setMaxComparisonFileMb(0) - expect(s.maxComparisonFileMb).toBe(1) s.setMaxSnippetSizeKb('not a number') expect(s.maxSnippetSizeKb).toBeGreaterThan(0) s.setMaxSnippetSizeKb(10_000_000) expect(s.maxSnippetSizeKb).toBe(MAX_SNIPPET_SIZE_KB_CAP) }) + it('migrates the pre-per-type maxComparisonFileMb into the text limit', () => { + localStorage.setItem('diffbro.settings', JSON.stringify({ maxComparisonFileMb: 42 })) + const s = useSettingsStore() + expect(s.fileSizeLimitMb('text')).toBe(42) // legacy value -> text bucket + expect(s.fileSizeLimitMb('spreadsheet')).toBe(FILE_TYPE_LIMITS.spreadsheet.default) + }) + + it('persists per-type limits and a legacy text mirror across reload', () => { + useSettingsStore().setFileSizeLimitMb('spreadsheet', 60) + const raw = JSON.parse(localStorage.getItem('diffbro.settings')) + expect(raw.fileSizeLimitsMb.spreadsheet).toBe(60) + expect(raw.maxComparisonFileMb).toBe(FILE_TYPE_LIMITS.text.default) // legacy mirror + setActivePinia(createPinia()) + expect(useSettingsStore().fileSizeLimitMb('spreadsheet')).toBe(60) + }) + + it('daily theme rotation defaults off and persists when toggled', () => { + const s = useSettingsStore() + expect(s.rotateThemeDaily).toBe(false) + s.setRotateThemeDaily(true) + setActivePinia(createPinia()) + expect(useSettingsStore().rotateThemeDaily).toBe(true) + }) + it('toggles the shortcut bar and persists it', () => { useSettingsStore().setShowShortcutBar(false) setActivePinia(createPinia()) expect(useSettingsStore().showShortcutBar).toBe(false) }) + + it('drag-reorders a section to land just before its drop target', () => { + const s = useSettingsStore() + s.reorderSections('snippets', 'saved') // drop snippets before saved + expect(s.sectionOrder).toEqual(['snippets', 'saved', 'external']) + s.reorderSections('external', 'snippets') // external before snippets + expect(s.sectionOrder).toEqual(['external', 'snippets', 'saved']) + }) + + it('ignores a reorder onto itself or an unknown id', () => { + const s = useSettingsStore() + s.reorderSections('saved', 'saved') + s.reorderSections('bogus', 'saved') + s.reorderSections('saved', 'bogus') + expect(s.sectionOrder).toEqual(SECTIONS) + }) + + it('locks section order: move and reorder become no-ops until unlocked', () => { + const s = useSettingsStore() + expect(s.sectionsLocked).toBe(false) + s.toggleSectionsLock() + expect(s.sectionsLocked).toBe(true) + s.moveSection('snippets', -1) + s.reorderSections('snippets', 'saved') + expect(s.sectionOrder).toEqual(SECTIONS) // frozen + // survives a reload + setActivePinia(createPinia()) + const reloaded = useSettingsStore() + expect(reloaded.sectionsLocked).toBe(true) + reloaded.toggleSectionsLock() + reloaded.moveSection('snippets', -1) + expect(reloaded.sectionOrder).toEqual(['saved', 'snippets', 'external']) + }) }) diff --git a/tests/renderer/utils/alignRows.test.js b/tests/renderer/utils/alignRows.test.js new file mode 100644 index 0000000..c02fabc --- /dev/null +++ b/tests/renderer/utils/alignRows.test.js @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { alignRows, changedCells, cellsEqual } from '../../../src/renderer/src/utils/alignRows' + +const statuses = (entries) => entries.map((e) => e.status) + +describe('alignRows', () => { + it('marks identical rows as same', () => { + const rows = [ + ['a', 1], + ['b', 2] + ] + expect(statuses(alignRows(rows, rows))).toEqual(['same', 'same']) + }) + + it('detects a changed cell in a row with a stable key column', () => { + const left = [['id', 1, 2]] + const right = [['id', 1, 9]] + const [entry] = alignRows(left, right) + expect(entry.status).toBe('changed') + expect(entry.changed).toEqual([2]) + expect(entry.leftIndex).toBe(0) + expect(entry.rightIndex).toBe(0) + }) + + it('does NOT cascade rows below an inserted row', () => { + const left = [['A'], ['B'], ['C']] + const right = [['A'], ['X'], ['B'], ['C']] + const entries = alignRows(left, right) + // The inserted X is added; B and C stay "same" rather than shifting into + // a wall of false changes — the whole point of aligning instead of zipping. + expect(statuses(entries)).toEqual(['same', 'added', 'same', 'same']) + expect(entries[1].right).toEqual(['X']) + }) + + it('reports a deleted row as removed, not as changes down the sheet', () => { + const left = [['A'], ['B'], ['C']] + const right = [['A'], ['C']] + expect(statuses(alignRows(left, right))).toEqual(['same', 'removed', 'same']) + }) + + it('separates a genuine add/remove from a same-key change', () => { + // Mirrors the design mockup: North changes, West is removed, Central added. + const left = [ + ['Region', 'Q1', 'Q2'], + ['North', 100, 120], + ['South', 90, 95], + ['East', 70, 80], + ['West', 50, 60], + ['Total', 310, 355] + ] + const right = [ + ['Region', 'Q1', 'Q2'], + ['North', 100, 150], + ['South', 90, 95], + ['East', 70, 80], + ['Central', 40, 55], + ['Total', 310, 355] + ] + const entries = alignRows(left, right) + expect(statuses(entries)).toEqual([ + 'same', + 'changed', + 'same', + 'same', + 'removed', + 'added', + 'same' + ]) + expect(entries[1].changed).toEqual([2]) // North's Q2 cell only + expect(entries[4].left).toEqual(['West', 50, 60]) + expect(entries[5].right).toEqual(['Central', 40, 55]) + }) + + it('treats trailing empty cells and null as absent', () => { + expect(cellsEqual(null, '')).toBe(true) + expect(changedCells(['a', 1], ['a', 1, null, ''])).toEqual([]) + const [entry] = alignRows([['a', 1]], [['a', 1, null, '']]) + expect(entry.status).toBe('same') + }) + + it('falls back to positional alignment past the LCS size budget', () => { + const left = [['a', 1], ['b', 2]] + const right = [['a', 1], ['b', 9]] + // Force the fallback path; it still produces a same + a changed row. + const entries = alignRows(left, right, { maxProduct: 0 }) + expect(statuses(entries)).toEqual(['same', 'changed']) + expect(entries[1].changed).toEqual([1]) + }) +}) diff --git a/tests/renderer/utils/resizeRect.test.js b/tests/renderer/utils/resizeRect.test.js new file mode 100644 index 0000000..ef5c66e --- /dev/null +++ b/tests/renderer/utils/resizeRect.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { resizeRect, centeredRect } from '../../../src/renderer/src/utils/resizeRect' + +const MIN = { width: 100, height: 80 } +const BOUNDS = { minX: 0, minY: 0, maxX: 1000, maxY: 1000 } +const start = { left: 400, top: 300, width: 200, height: 150 } +const resize = (corner, dx, dy, bounds = BOUNDS) => + resizeRect({ rect: start, corner, dx, dy, min: MIN, bounds }) + +describe('resizeRect', () => { + it('grows from the SE corner, pinning the NW corner', () => { + expect(resize('se', 50, 40)).toEqual({ left: 400, top: 300, width: 250, height: 190 }) + }) + + it('grows from the NW corner, pinning the SE corner', () => { + // SE corner (600, 450) stays put; the box grows up and to the left. + expect(resize('nw', -50, -40)).toEqual({ left: 350, top: 260, width: 250, height: 190 }) + }) + + it('handles the mixed NE / SW corners independently per axis', () => { + expect(resize('ne', 30, -20)).toEqual({ left: 400, top: 280, width: 230, height: 170 }) + expect(resize('sw', -30, 20)).toEqual({ left: 370, top: 300, width: 230, height: 170 }) + }) + + it('never shrinks below the minimum size', () => { + const r = resize('se', -1000, -1000) + expect(r.width).toBe(MIN.width) + expect(r.height).toBe(MIN.height) + }) + + it('clamps a dragged edge to the viewport bounds', () => { + const tight = { minX: 0, minY: 0, maxX: 620, maxY: 1000 } + const r = resize('se', 9999, 0, tight) + expect(r.left + r.width).toBe(620) // east edge stops at the bound + }) + + it('centres a rect in the viewport', () => { + expect(centeredRect(200, 100, 1000, 600)).toEqual({ + left: 400, + top: 250, + width: 200, + height: 100 + }) + }) +}) diff --git a/tests/renderer/utils/spreadsheetDiff.test.js b/tests/renderer/utils/spreadsheetDiff.test.js new file mode 100644 index 0000000..bd80b19 --- /dev/null +++ b/tests/renderer/utils/spreadsheetDiff.test.js @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + diffWorkbooks, + columnName, + pageRows, + RENDER_ROW_CAP +} from '../../../src/renderer/src/utils/spreadsheetDiff' + +const sheet = (name, rows) => ({ name, rows }) + +describe('columnName', () => { + it('maps 0-based indices to spreadsheet column letters', () => { + expect(columnName(0)).toBe('A') + expect(columnName(25)).toBe('Z') + expect(columnName(26)).toBe('AA') + expect(columnName(27)).toBe('AB') + expect(columnName(701)).toBe('ZZ') + }) +}) + +describe('diffWorkbooks', () => { + it('pairs sheets by name and rolls up per-sheet stats', () => { + const left = [sheet('Budget', [['a', 1], ['b', 2], ['c', 3]])] + const right = [sheet('Budget', [['a', 1], ['b', 9], ['d', 4]])] + const [s] = diffWorkbooks(left, right) + expect(s.present).toBe('both') + expect(s.stats).toEqual({ changed: 1, added: 1, removed: 1 }) + expect(s.changes).toBe(3) + expect(s.columns).toBe(2) + }) + + it('flags a sheet present in only the left file', () => { + const [s] = diffWorkbooks([sheet('Only', [['x']])], []) + expect(s.present).toBe('left') + expect(s.stats).toEqual({ changed: 0, added: 0, removed: 1 }) + expect(s.rows[0].status).toBe('removed') + }) + + it('flags a sheet present in only the right file', () => { + const [s] = diffWorkbooks([], [sheet('New', [['x'], ['y']])]) + expect(s.present).toBe('right') + expect(s.stats).toEqual({ changed: 0, added: 2, removed: 0 }) + expect(s.rows.every((r) => r.status === 'added')).toBe(true) + }) + + it('keeps sheet order: left sheets first, then right-only sheets', () => { + const left = [sheet('A', [['1']]), sheet('B', [['2']])] + const right = [sheet('B', [['2']]), sheet('C', [['3']])] + expect(diffWorkbooks(left, right).map((s) => s.name)).toEqual(['A', 'B', 'C']) + }) +}) + +describe('pageRows', () => { + it('returns everything untouched below the cap', () => { + const rows = [1, 2, 3] + expect(pageRows(rows, 10)).toEqual({ rows, hidden: 0 }) + }) + + it('caps the rendered rows and reports how many are held back', () => { + const rows = Array.from({ length: 25 }, (_, i) => i) + const out = pageRows(rows, 10) + expect(out.rows).toHaveLength(10) + expect(out.hidden).toBe(15) + }) + + it('has a sane default cap', () => { + expect(RENDER_ROW_CAP).toBeGreaterThanOrEqual(1000) + expect(pageRows(Array.from({ length: RENDER_ROW_CAP + 1 }, () => 0)).hidden).toBe(1) + }) +}) diff --git a/tests/renderer/utils/themes.test.js b/tests/renderer/utils/themes.test.js index a85765f..6b2dca5 100644 --- a/tests/renderer/utils/themes.test.js +++ b/tests/renderer/utils/themes.test.js @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { THEMES, DEFAULT_THEME, + themeForDay, isValidTheme, isDarkTheme, normalizeTheme @@ -9,14 +10,23 @@ import { describe('themes registry', () => { it('offers the five named themes, Light first and the default', () => { - expect(THEMES.map((t) => t.id)).toEqual(['light', 'dark', 'solar', 'neon', 'contrast']) + expect(THEMES.map((t) => t.id)).toEqual([ + 'light', + 'dark', + 'solar', + 'neon', + 'contrast', + 'nord', + 'sepia', + 'nyan' + ]) expect(DEFAULT_THEME).toBe('light') expect(THEMES[0].id).toBe(DEFAULT_THEME) }) it('marks each theme dark- or light-ground (drives the editor/diagram theme)', () => { const dark = THEMES.filter((t) => t.dark).map((t) => t.id) - expect(dark).toEqual(['dark', 'neon']) + expect(dark).toEqual(['dark', 'neon', 'nord', 'nyan']) expect(isDarkTheme('neon')).toBe(true) expect(isDarkTheme('solar')).toBe(false) expect(isDarkTheme('nope')).toBe(false) @@ -39,3 +49,20 @@ describe('themes registry', () => { } }) }) + +describe('themeForDay', () => { + it('is stable within a day and returns a valid theme id', () => { + const d = new Date(2026, 6, 23) + const a = themeForDay(d) + const b = themeForDay(new Date(2026, 6, 23, 18, 30)) + expect(a).toBe(b) // same calendar day -> same theme + expect(isValidTheme(a)).toBe(true) + }) + + it('varies across days and covers the registry over time', () => { + const seen = new Set() + for (let i = 0; i < 60; i++) seen.add(themeForDay(new Date(2026, 0, 1 + i))) + expect(seen.size).toBeGreaterThan(1) // not stuck on one theme + for (const id of seen) expect(isValidTheme(id)).toBe(true) + }) +}) diff --git a/tests/stress/diff-stress.test.js b/tests/stress/diff-stress.test.js new file mode 100644 index 0000000..8a9770e --- /dev/null +++ b/tests/stress/diff-stress.test.js @@ -0,0 +1,111 @@ +// Stress benchmark for BOTH comparable kinds: how big can a file get before +// loading/diffing it hangs the app? Opt-in — it's slow and machine-dependent, +// so it's skipped unless STRESS=1, and never runs in `npm run check`: +// +// STRESS=1 npx vitest run tests/stress/diff-stress.test.js +// +// It lives under vitest (not a bare node script) because the parser leans on +// fflate, whose Uint8Array checks break under a raw ESM loader's realm — the +// Vite pipeline the rest of the suite uses handles it correctly. +// +// What it measures is the CPU half the app owns: parse (.xlsx) + row alignment +// for spreadsheets, and the unified-patch generator for text. The DOM/Monaco +// render is the separate *viewing* ceiling and is measured in the Docker env; +// both kinds already cap what they hand the view (spreadsheet: RENDER_ROW_CAP; +// text: the 10 MB file-size prompt in main + MAX_DIFF_LINES on the patch). +import { describe, it, expect } from 'vitest' +import { zipSync, strToU8 } from 'fflate' +import { readXlsx } from '../../src/main/xlsx/index' +import { alignRows } from '../../src/renderer/src/utils/alignRows' +import { toUnifiedDiff, MAX_DIFF_LINES } from '../../src/renderer/src/utils/unifiedDiff' + +const RUN = !!process.env.STRESS +const COLS = 12 +const SLUGGISH_MS = 1_000 +const HANG_MS = 3_000 + +const time = (fn) => { + const t = process.hrtime.bigint() + const out = fn() + return { ms: Number(process.hrtime.bigint() - t) / 1e6, out } +} +const verdict = (ms) => (ms < 100 ? 'smooth' : ms < SLUGGISH_MS ? 'ok' : ms < HANG_MS ? 'sluggish' : 'HANG') +const padL = (s, n) => String(s).padStart(n) + +function sheetXml(rowCount, mutate = null) { + let body = '' + for (let i = 1; i <= rowCount; i++) { + let row = `<row r="${i}">` + for (let c = 0; c < COLS; c++) { + const ref = String.fromCharCode(65 + c) + i + if (c === 0) row += `<c r="${ref}" t="inlineStr"><is><t>row-${i}</t></is></c>` + else row += `<c r="${ref}"><v>${(i * 31 + c * 7) % 1000 + (mutate?.(i) && c === 3 ? 500 : 0)}</v></c>` + } + body += row + '</row>' + } + return `<?xml version="1.0"?><worksheet><sheetData>${body}</sheetData></worksheet>` +} + +function buildXlsx(xml) { + // Every zip entry value must be a Uint8Array — a raw string sends fflate's + // flatten into infinite recursion over the string's characters. + return Buffer.from( + zipSync({ + 'xl/workbook.xml': strToU8( + '<workbook xmlns:r="r"><sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>' + ), + 'xl/_rels/workbook.xml.rels': strToU8( + '<Relationships><Relationship Id="rId1" ' + + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' + + 'Target="worksheets/sheet1.xml"/></Relationships>' + ), + 'xl/worksheets/sheet1.xml': strToU8(xml) + }) + ) +} + +describe.skipIf(!RUN)('spreadsheet stress — parse + align', () => { + it('reports the size/latency curve and stays usable to ~10k rows', { timeout: 120_000 }, () => { + const counts = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000] + console.log(`\nSpreadsheet — ${COLS} cols, ~5% rows changed`) + console.log('rows'.padEnd(9) + padL('MB', 8) + padL('parse', 8) + padL('align', 8) + padL('total', 8) + ' verdict') + let tenK = 0 + for (const rows of counts) { + const left = buildXlsx(sheetXml(rows)) + const right = buildXlsx(sheetXml(rows, (i) => i % 20 === 0)) + const p = time(() => readXlsx(left, { maxCells: 5_000_000 })) + const p2 = time(() => readXlsx(right, { maxCells: 5_000_000 })) + const a = time(() => alignRows(p.out.sheets[0].rows, p2.out.sheets[0].rows)) + const totalMs = p.ms + p2.ms + a.ms + if (rows === 10_000) tenK = totalMs + console.log( + String(rows).padEnd(9) + + padL((left.length / 1048576).toFixed(1), 8) + + padL((p.ms + p2.ms).toFixed(0), 8) + + padL(a.ms.toFixed(0), 8) + + padL(totalMs.toFixed(0), 8) + + ` ${verdict(totalMs)}` + ) + } + expect(tenK).toBeLessThan(HANG_MS) + }) +}) + +describe.skipIf(!RUN)('text stress — unified patch generator', () => { + it('reports latency up to its line cap and refuses beyond it', () => { + console.log(`\nText — unified patch (MAX_DIFF_LINES=${MAX_DIFF_LINES})`) + console.log('lines'.padEnd(9) + padL('ms', 8) + ' verdict') + for (const lines of [1_000, 2_000, MAX_DIFF_LINES]) { + const a = Array.from({ length: lines }, (_, i) => `line ${i}`).join('\n') + const b = a.replace(/line 5\d\b/g, 'CHANGED') + const r = time(() => toUnifiedDiff(a, b)) + console.log(String(lines).padEnd(9) + padL(r.ms.toFixed(1), 8) + ` ${verdict(r.ms)}`) + expect(r.out.patch).toBeDefined() + } + // Past the cap the generator refuses rather than grinding — the app's text + // hang guard, alongside the 10 MB file-size prompt on load. + const huge = Array.from({ length: MAX_DIFF_LINES + 1 }, (_, i) => `l${i}`).join('\n') + expect(toUnifiedDiff(huge, huge + '\nx').error).toBe('too-large') + console.log('') + }) +}) diff --git a/vitest.config.mjs b/vitest.config.mjs index 443563d..d3a0b1f 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -32,6 +32,8 @@ export default defineConfig({ 'src/main/textCrypt.js', 'src/main/kdf.js', 'src/main/configBackup.js', + 'src/main/logFormat.js', + 'src/main/xlsx/**', 'src/renderer/src/stores/**', 'src/renderer/src/utils/**', 'src/renderer/src/adapters/**'