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/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/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/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)
+}
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..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",
@@ -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/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
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"
]
}