From d303041ece8cb79e62bf97b6a228716441000ac7 Mon Sep 17 00:00:00 2001 From: Michael Hobl Date: Thu, 4 Jun 2026 02:03:36 +1000 Subject: [PATCH 1/4] chore(test): add jest type config so specs type-check without polluting app build ts-jest now uses tsconfig.jest.json (jest types) for specs; *.test.ts excluded from the app tsconfig so the nuxt build doesn't type-check them. Adds @types/jest. --- jest.config.js | 9 +++++++++ package-lock.json | 22 ++++++++++++++++++++++ package.json | 1 + tsconfig.jest.json | 6 ++++++ tsconfig.json | 4 +++- 5 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tsconfig.jest.json diff --git a/jest.config.js b/jest.config.js index 3fb0c76..b2ba221 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,13 @@ module.exports = { + globals: { + 'ts-jest': { + // Tests use jest globals (it/expect) which the app's tsconfig + // intentionally excludes from its `types` allow-list. Point + // ts-jest at a test-only tsconfig that re-adds the jest types so + // type-checking the specs doesn't fail; the app build is untouched. + tsconfig: 'tsconfig.jest.json', + }, + }, moduleNameMapper: { '^@/(.*)$': '/$1', '^~/(.*)$': '/$1', diff --git a/package-lock.json b/package-lock.json index 02775c9..dfed9fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@nuxtjs/stylelint-module": "^4.0.0", "@nuxtjs/tailwindcss": "^4.2.0", "@types/express": "^5.0.3", + "@types/jest": "^27.5.2", "@types/pg": "^8.15.5", "@vue/test-utils": "^1.2.1", "babel-core": "7.0.0-bridge.0", @@ -6375,6 +6376,17 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "27.5.2", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz", + "integrity": "sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -37293,6 +37305,16 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "27.5.2", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz", + "integrity": "sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==", + "dev": true, + "requires": { + "jest-matcher-utils": "^27.0.0", + "pretty-format": "^27.0.0" + } + }, "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", diff --git a/package.json b/package.json index 67d0c72..da7a403 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@nuxtjs/stylelint-module": "^4.0.0", "@nuxtjs/tailwindcss": "^4.2.0", "@types/express": "^5.0.3", + "@types/jest": "^27.5.2", "@types/pg": "^8.15.5", "@vue/test-utils": "^1.2.1", "babel-core": "7.0.0-bridge.0", diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 0000000..1116857 --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "node"] + } +} diff --git a/tsconfig.json b/tsconfig.json index 1cfee1a..2b3773b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,8 @@ "exclude": [ "node_modules", ".nuxt", - "dist" + "dist", + "**/*.test.ts", + "**/*.spec.ts" ] } From a038f53dd6c3f190dbcee90dbc3c997111a822b9 Mon Sep 17 00:00:00 2001 From: Michael Hobl Date: Thu, 4 Jun 2026 02:03:37 +1000 Subject: [PATCH 2/4] fix(diff): stop label-sync clobbering the decryption key in E2E share links On the ?id= path the URL hash holds the AES key for the server-stored blob, not the diff payload. syncLabelsToUrl() ran when unzipCommitData set the pane labels after decryption and replaceState'd the hash with the gzipped payload, so refreshing/re-copying the link failed with 'We couldn't decrypt your diff.' buildLabelSyncUrl() now returns null on the ?id= path, leaving the key intact. --- helpers/labelSyncUrl.test.ts | 35 +++++++++++++++++++++++++++++++++++ helpers/labelSyncUrl.ts | 26 ++++++++++++++++++++++++++ pages/diff.vue | 19 ++++++++++++------- 3 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 helpers/labelSyncUrl.test.ts create mode 100644 helpers/labelSyncUrl.ts diff --git a/helpers/labelSyncUrl.test.ts b/helpers/labelSyncUrl.test.ts new file mode 100644 index 0000000..06a67c2 --- /dev/null +++ b/helpers/labelSyncUrl.test.ts @@ -0,0 +1,35 @@ +import { buildLabelSyncUrl } from './labelSyncUrl' + +describe('buildLabelSyncUrl', () => { + it('rewrites the hash with the gzipped payload on the plain (non-id) path', () => { + const url = buildLabelSyncUrl( + { pathname: '/diff', search: '' }, + 'H4sIPAYLOAD' + ) + expect(url).toBe('/diff#H4sIPAYLOAD') + }) + + it('tolerates a payload that already carries its leading #', () => { + const url = buildLabelSyncUrl( + { pathname: '/diff', search: '' }, + '#H4sIPAYLOAD' + ) + expect(url).toBe('/diff#H4sIPAYLOAD') + }) + + it('returns null on the E2E (?id=) path so the decryption key in the hash is never clobbered', () => { + const url = buildLabelSyncUrl( + { pathname: '/diff', search: '?id=diff-123' }, + 'H4sIPAYLOAD' + ) + expect(url).toBeNull() + }) + + it('also skips when id= is one of several query params', () => { + const url = buildLabelSyncUrl( + { pathname: '/diff/', search: '?foo=1&id=diff-123' }, + 'H4sIPAYLOAD' + ) + expect(url).toBeNull() + }) +}) diff --git a/helpers/labelSyncUrl.ts b/helpers/labelSyncUrl.ts new file mode 100644 index 0000000..3dbca06 --- /dev/null +++ b/helpers/labelSyncUrl.ts @@ -0,0 +1,26 @@ +export interface LocationParts { + pathname: string + search: string +} + +/** + * Decide the URL to write back when the user renames a pane label. + * + * On the plain (locally-encoded) diff path the URL hash carries the + * gzipped diff payload, so we rewrite it with the freshly-encoded + * payload (re-including the new labels). + * + * On the end-to-end-encrypted path (`?id=` present) the hash instead + * carries the AES decryption KEY for the server-stored blob. Rewriting + * it with the payload would destroy the key, so refreshing — or copying — + * the link can no longer decrypt the diff ("We couldn't decrypt your + * diff."). On that path we return `null` to signal "leave the URL alone". + */ +export function buildLabelSyncUrl( + loc: LocationParts, + payloadHash: string +): string | null { + if (loc.search.includes('id=')) return null + const hash = payloadHash.startsWith('#') ? payloadHash : `#${payloadHash}` + return loc.pathname + hash +} diff --git a/pages/diff.vue b/pages/diff.vue index c6cab97..f0c6f9e 100644 --- a/pages/diff.vue +++ b/pages/diff.vue @@ -105,6 +105,7 @@ import Footer from '~/components/footer.vue' import Navbar from '~/components/navbar.vue' import Pencil from '~/components/icons/pencil.vue' import { getDecryptedText, getDepryctionKey } from '~/helpers/decrypt' +import { buildLabelSyncUrl } from '~/helpers/labelSyncUrl' import { v2DiffData } from '~/helpers/types' import { E2E_DATA_DECRYPTING_INFO, @@ -194,10 +195,10 @@ export default Vue.extend({ /* Re-encode the current lhs/rhs/lhsLabel/rhsLabel payload into * the URL hash. Mirrors the gzip+base64 encoding the entry page * does on Compare; uses replaceState so we don't push a new - * history entry every keystroke. Skips when the page is being - * driven by a server-stored short link (the ?id= query path) — - * in that case the cached e2eLink would be stale and we let it - * regenerate next copy. */ + * history entry every keystroke. Skips entirely when the page is + * driven by a server-stored short link (the ?id= query path), + * because the hash there is the decryption key — see + * buildLabelSyncUrl. */ syncLabelsToUrl() { try { const lhs = String(this.lhs || '').trim() @@ -210,9 +211,13 @@ export default Vue.extend({ }) const gzip = Buffer.from(pako.gzip(payload)).toString('base64') const hash = `#${doUrlSafeBase64(gzip)}` - const url = window.location.search.includes('id=') - ? window.location.pathname + window.location.search + hash - : window.location.pathname + hash + const url = buildLabelSyncUrl(window.location, hash) + // On the E2E (?id=) path buildLabelSyncUrl returns null: the URL + // hash there holds the AES decryption key, NOT the diff payload. + // Overwriting it (as this method used to) destroyed the key, so + // refreshing or re-copying the shared link could no longer decrypt + // the diff. Leave the URL — and the key — untouched in that case. + if (url === null) return window.history.replaceState(null, '', url) // Reset the action-bar's cached E2E link so the next copy // generates a new server-side payload that reflects the From 49ef9ad5e9bcd444855296b60571582acfe04298 Mon Sep 17 00:00:00 2001 From: Michael Hobl Date: Thu, 4 Jun 2026 02:03:37 +1000 Subject: [PATCH 3/4] feat(diff): show per-side (A/B) line counts and net delta in the action bar Adds an A/B line-count display with a colour-coded net delta (b - a) to the diff action bar, computed reactively from the store payload (matches the Monaco gutter). Hidden under 640px. --- components/diffActionBar.vue | 95 ++++++++++++++++++++++++++++++++++++ helpers/lineStats.test.ts | 53 ++++++++++++++++++++ helpers/lineStats.ts | 31 ++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 helpers/lineStats.test.ts create mode 100644 helpers/lineStats.ts diff --git a/components/diffActionBar.vue b/components/diffActionBar.vue index 180507a..3c5fc13 100644 --- a/components/diffActionBar.vue +++ b/components/diffActionBar.vue @@ -36,6 +36,30 @@ + +
+ + A + {{ lineStats.a }} + + + B + {{ lineStats.b }} + + + Δ + {{ formatDelta(lineStats.delta) }} + +
+ @@ -55,6 +79,7 @@ import { } from '~/helpers/encrypt' import { DiffActionBarData } from '~/helpers/types' import { getRandomDiffId } from '~/helpers/utils' +import { computeLineStats, formatDelta, LineStats } from '~/helpers/lineStats' export default Vue.extend({ components: { CopyLink, Up, Down }, props: { @@ -89,6 +114,16 @@ export default Vue.extend({ updateDiffDisposer: null, } }, + computed: { + /* Per-side line counts + net delta, read reactively from the diff + * payload in the store (set by diff.vue's unzipCommitData, so it + * matches exactly what the editor renders). "a" is the original + * (left) side, "b" is the modified (right) side. */ + lineStats(): LineStats { + const data = (this.$store.state as any).data || {} + return computeLineStats(String(data.lhs || ''), String(data.rhs || '')) + }, + }, watch: { /* Subscribe to Monaco's onDidUpdateDiff so the counter refreshes * whenever the diff is recomputed (initial load, model swap, etc). @@ -121,6 +156,10 @@ export default Vue.extend({ } }, methods: { + /* Exposed so the template can sign-prefix the delta (+15 / -15 / 0). */ + formatDelta(delta: number): string { + return formatDelta(delta) + }, handleCtrlC(event: KeyboardEvent) { const { metaKey, ctrlKey, key } = event if ( @@ -292,6 +331,62 @@ export default Vue.extend({ gap: 8px; } +/* Center cluster: per-side line counts (A / B) and the net delta (Δ). + * Reads as quiet metadata — tabular figures so the numbers don't jitter + * as they change, muted key letters, and a colour-coded delta. */ +.noden-line-stats { + display: inline-flex; + align-items: center; + gap: 14px; + font-size: 0.8rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--noden-text-secondary, #64748b); + user-select: none; +} +.noden-line-stat { + display: inline-flex; + align-items: baseline; + gap: 4px; +} +.noden-line-stat-key { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.04em; + opacity: 0.7; +} +.noden-line-stat-val { + color: var(--noden-text-primary, #1e3a5f); +} +/* Dark-theme overrides kept above the specificity-3 delta rules so the + * cascade reads in ascending specificity (stylelint no-descending). */ +.dark .noden-line-stats { + color: #9ca3af; +} +.dark .noden-line-stat-val { + color: #e5e7eb; +} +.noden-line-stat-delta.is-positive .noden-line-stat-val { + color: #16a34a; +} +.noden-line-stat-delta.is-negative .noden-line-stat-val { + color: #dc2626; +} +.dark .noden-line-stat-delta.is-positive .noden-line-stat-val { + color: #4ade80; +} +.dark .noden-line-stat-delta.is-negative .noden-line-stat-val { + color: #f87171; +} + +/* On narrow viewports the action bar gets crowded; drop the line stats + * rather than let them wrap the bar onto a second row. */ +@media (max-width: 640px) { + .noden-line-stats { + display: none; + } +} + /* Counter pill between Previous / Next. Reads as a soft label, not * an interactive control — no background hover, just sits between * the buttons showing "/ changes". Fixed min-width to diff --git a/helpers/lineStats.test.ts b/helpers/lineStats.test.ts new file mode 100644 index 0000000..5a8f6cd --- /dev/null +++ b/helpers/lineStats.test.ts @@ -0,0 +1,53 @@ +import { countLines, computeLineStats, formatDelta } from './lineStats' + +describe('countLines', () => { + it('counts lines the way Monaco gutter does (newlines + 1)', () => { + expect(countLines('a')).toBe(1) + expect(countLines('a\nb')).toBe(2) + expect(countLines('a\nb\nc')).toBe(3) + }) + + it('treats a trailing newline as a final (empty) line, matching the gutter', () => { + expect(countLines('a\n')).toBe(2) + }) + + it('reports an empty string as a single line (matches Monaco model)', () => { + expect(countLines('')).toBe(1) + }) +}) + +describe('computeLineStats', () => { + it('reports per-side counts and the b-minus-a delta', () => { + expect(computeLineStats('a\nb\nc', 'a\nb\nc\nd\ne')).toEqual({ + a: 3, + b: 5, + delta: 2, + }) + }) + + it('reports a negative delta when the right side is shorter', () => { + expect(computeLineStats('a\nb\nc\nd', 'a\nb')).toEqual({ + a: 4, + b: 2, + delta: -2, + }) + }) + + it('reports a zero delta for equal-length sides', () => { + expect(computeLineStats('a\nb', 'x\ny')).toEqual({ a: 2, b: 2, delta: 0 }) + }) +}) + +describe('formatDelta', () => { + it('prefixes a positive delta with +', () => { + expect(formatDelta(15)).toBe('+15') + }) + + it('keeps the native minus sign for a negative delta', () => { + expect(formatDelta(-15)).toBe('-15') + }) + + it('renders zero without a sign', () => { + expect(formatDelta(0)).toBe('0') + }) +}) diff --git a/helpers/lineStats.ts b/helpers/lineStats.ts new file mode 100644 index 0000000..1ec9f10 --- /dev/null +++ b/helpers/lineStats.ts @@ -0,0 +1,31 @@ +export interface LineStats { + /** Line count of the left (original / "a") side. */ + a: number + /** Line count of the right (modified / "b") side. */ + b: number + /** Net line change, b - a (signed). */ + delta: number +} + +/** + * Count display lines the way Monaco's gutter does: the number of + * `\n`-separated segments, i.e. (newline count + 1). An empty string is + * one line and a trailing newline adds a final empty line — both match + * `ITextModel.getLineCount()` exactly, so these numbers line up with the + * line numbers the user sees in the diff gutter. + */ +export function countLines(text: string): number { + return text.split('\n').length +} + +/** Per-side line counts plus the b-minus-a delta for a diff. */ +export function computeLineStats(lhs: string, rhs: string): LineStats { + const a = countLines(lhs) + const b = countLines(rhs) + return { a, b, delta: b - a } +} + +/** Render a delta with an explicit sign: "+15", "-15", or "0". */ +export function formatDelta(delta: number): string { + return delta > 0 ? `+${delta}` : String(delta) +} From 7b15b140449c6cf30630ec02d4ccab6054b6e345 Mon Sep 17 00:00:00 2001 From: Michael Hobl Date: Thu, 4 Jun 2026 02:19:19 +1000 Subject: [PATCH 4/4] build(docker): uplift to Node 24 LTS build image and nginx stable 1.28 Build stage node:22 -> node:24 (current LTS); production nginx 1.27.0 (old mainline) -> 1.28.3-alpine-slim (current stable, pinned). Loosen engines 22.x -> >=22 so the Node 24 Docker build and newer local runtimes don't emit EBADENGINE. Bump CI matrix node 20 (nears EOL) -> 22 (the supported floor). Kept --openssl-legacy-provider (webpack 4 / MD4 on OpenSSL 3) and documented why. --- .github/workflows/ci.yml | 2 +- Dockerfile | 13 ++++++++++--- package.json | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bd23e2..f34492c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node: [20] + node: [22] steps: - name: Checkout 🛎 diff --git a/Dockerfile b/Dockerfile index 115a259..00cade0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,24 @@ -FROM node:22 AS build-stage +# Build stage runs on the current Node LTS (24). It's a throwaway stage — +# only /app/dist is copied into the nginx image below — so we use the full +# (non-slim) image to guarantee the toolchain for any native dep build. +FROM node:24 AS build-stage WORKDIR /app COPY package*.json ./ -RUN npm ci +RUN npm ci --no-audit --no-fund COPY . . +# Nuxt 2 builds on webpack 4, which hashes with MD4 — removed from +# OpenSSL 3's default provider (Node 17+). Re-enable the legacy provider +# so `nuxt generate` doesn't fail with ERR_OSSL_EVP_UNSUPPORTED. ENV NODE_OPTIONS=--openssl-legacy-provider RUN npm run generate -FROM nginx:1.27.0-alpine-slim AS production-stage +# Current nginx stable line (1.28.x); alpine-slim keeps the runtime tiny. +FROM nginx:1.28.3-alpine-slim AS production-stage COPY --from=build-stage /app/dist /usr/share/nginx/html diff --git a/package.json b/package.json index da7a403..9695977 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test": "jest --passWithNoTests" }, "engines": { - "node": "22.x" + "node": ">=22" }, "lint-staged": { "*.{js,vue,ts}": "eslint --fix",