Skip to content

Commit 7daaf60

Browse files
authored
Merge pull request #1 from node-networks-au/fix/encrypted-diff-link-and-line-counters
fix(diff): preserve E2E decryption key on label sync + add A/B line counters; uplift Docker
2 parents 18f18bb + 7b15b14 commit 7daaf60

13 files changed

Lines changed: 305 additions & 13 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
strategy:
1818
matrix:
1919
os: [ubuntu-latest]
20-
node: [20]
20+
node: [22]
2121

2222
steps:
2323
- name: Checkout 🛎

Dockerfile

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1-
FROM node:22 AS build-stage
1+
# Build stage runs on the current Node LTS (24). It's a throwaway stage —
2+
# only /app/dist is copied into the nginx image below — so we use the full
3+
# (non-slim) image to guarantee the toolchain for any native dep build.
4+
FROM node:24 AS build-stage
25

36
WORKDIR /app
47

58
COPY package*.json ./
69

7-
RUN npm ci
10+
RUN npm ci --no-audit --no-fund
811

912
COPY . .
1013

14+
# Nuxt 2 builds on webpack 4, which hashes with MD4 — removed from
15+
# OpenSSL 3's default provider (Node 17+). Re-enable the legacy provider
16+
# so `nuxt generate` doesn't fail with ERR_OSSL_EVP_UNSUPPORTED.
1117
ENV NODE_OPTIONS=--openssl-legacy-provider
1218
RUN npm run generate
1319

14-
FROM nginx:1.27.0-alpine-slim AS production-stage
20+
# Current nginx stable line (1.28.x); alpine-slim keeps the runtime tiny.
21+
FROM nginx:1.28.3-alpine-slim AS production-stage
1522

1623
COPY --from=build-stage /app/dist /usr/share/nginx/html
1724

components/diffActionBar.vue

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,30 @@
3636
</button>
3737
</div>
3838

39+
<!-- Center: per-side line counts + net delta (b - a). "A" is the
40+
original (left) pane, "B" is the modified (right) pane. -->
41+
<div class="noden-line-stats" aria-label="Line counts" aria-live="polite">
42+
<span class="noden-line-stat">
43+
<span class="noden-line-stat-key">A</span>
44+
<span class="noden-line-stat-val">{{ lineStats.a }}</span>
45+
</span>
46+
<span class="noden-line-stat">
47+
<span class="noden-line-stat-key">B</span>
48+
<span class="noden-line-stat-val">{{ lineStats.b }}</span>
49+
</span>
50+
<span
51+
class="noden-line-stat noden-line-stat-delta"
52+
:class="{
53+
'is-positive': lineStats.delta > 0,
54+
'is-negative': lineStats.delta < 0,
55+
}"
56+
:title="`Net change: ${formatDelta(lineStats.delta)} lines`"
57+
>
58+
<span class="noden-line-stat-key">Δ</span>
59+
<span class="noden-line-stat-val">{{ formatDelta(lineStats.delta) }}</span>
60+
</span>
61+
</div>
62+
3963
<!-- Right side: copy-link CTA. -->
4064
<CopyLink :click-handler="copyUrlToClipboard" :copied="copied" />
4165
</section>
@@ -55,6 +79,7 @@ import {
5579
} from '~/helpers/encrypt'
5680
import { DiffActionBarData } from '~/helpers/types'
5781
import { getRandomDiffId } from '~/helpers/utils'
82+
import { computeLineStats, formatDelta, LineStats } from '~/helpers/lineStats'
5883
export default Vue.extend({
5984
components: { CopyLink, Up, Down },
6085
props: {
@@ -89,6 +114,16 @@ export default Vue.extend({
89114
updateDiffDisposer: null,
90115
}
91116
},
117+
computed: {
118+
/* Per-side line counts + net delta, read reactively from the diff
119+
* payload in the store (set by diff.vue's unzipCommitData, so it
120+
* matches exactly what the editor renders). "a" is the original
121+
* (left) side, "b" is the modified (right) side. */
122+
lineStats(): LineStats {
123+
const data = (this.$store.state as any).data || {}
124+
return computeLineStats(String(data.lhs || ''), String(data.rhs || ''))
125+
},
126+
},
92127
watch: {
93128
/* Subscribe to Monaco's onDidUpdateDiff so the counter refreshes
94129
* whenever the diff is recomputed (initial load, model swap, etc).
@@ -121,6 +156,10 @@ export default Vue.extend({
121156
}
122157
},
123158
methods: {
159+
/* Exposed so the template can sign-prefix the delta (+15 / -15 / 0). */
160+
formatDelta(delta: number): string {
161+
return formatDelta(delta)
162+
},
124163
handleCtrlC(event: KeyboardEvent) {
125164
const { metaKey, ctrlKey, key } = event
126165
if (
@@ -292,6 +331,62 @@ export default Vue.extend({
292331
gap: 8px;
293332
}
294333
334+
/* Center cluster: per-side line counts (A / B) and the net delta (Δ).
335+
* Reads as quiet metadata — tabular figures so the numbers don't jitter
336+
* as they change, muted key letters, and a colour-coded delta. */
337+
.noden-line-stats {
338+
display: inline-flex;
339+
align-items: center;
340+
gap: 14px;
341+
font-size: 0.8rem;
342+
font-weight: 500;
343+
font-variant-numeric: tabular-nums;
344+
color: var(--noden-text-secondary, #64748b);
345+
user-select: none;
346+
}
347+
.noden-line-stat {
348+
display: inline-flex;
349+
align-items: baseline;
350+
gap: 4px;
351+
}
352+
.noden-line-stat-key {
353+
font-size: 0.7rem;
354+
font-weight: 600;
355+
letter-spacing: 0.04em;
356+
opacity: 0.7;
357+
}
358+
.noden-line-stat-val {
359+
color: var(--noden-text-primary, #1e3a5f);
360+
}
361+
/* Dark-theme overrides kept above the specificity-3 delta rules so the
362+
* cascade reads in ascending specificity (stylelint no-descending). */
363+
.dark .noden-line-stats {
364+
color: #9ca3af;
365+
}
366+
.dark .noden-line-stat-val {
367+
color: #e5e7eb;
368+
}
369+
.noden-line-stat-delta.is-positive .noden-line-stat-val {
370+
color: #16a34a;
371+
}
372+
.noden-line-stat-delta.is-negative .noden-line-stat-val {
373+
color: #dc2626;
374+
}
375+
.dark .noden-line-stat-delta.is-positive .noden-line-stat-val {
376+
color: #4ade80;
377+
}
378+
.dark .noden-line-stat-delta.is-negative .noden-line-stat-val {
379+
color: #f87171;
380+
}
381+
382+
/* On narrow viewports the action bar gets crowded; drop the line stats
383+
* rather than let them wrap the bar onto a second row. */
384+
@media (max-width: 640px) {
385+
.noden-line-stats {
386+
display: none;
387+
}
388+
}
389+
295390
/* Counter pill between Previous / Next. Reads as a soft label, not
296391
* an interactive control — no background hover, just sits between
297392
* the buttons showing "<idx>/<total> changes". Fixed min-width to

helpers/labelSyncUrl.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { buildLabelSyncUrl } from './labelSyncUrl'
2+
3+
describe('buildLabelSyncUrl', () => {
4+
it('rewrites the hash with the gzipped payload on the plain (non-id) path', () => {
5+
const url = buildLabelSyncUrl(
6+
{ pathname: '/diff', search: '' },
7+
'H4sIPAYLOAD'
8+
)
9+
expect(url).toBe('/diff#H4sIPAYLOAD')
10+
})
11+
12+
it('tolerates a payload that already carries its leading #', () => {
13+
const url = buildLabelSyncUrl(
14+
{ pathname: '/diff', search: '' },
15+
'#H4sIPAYLOAD'
16+
)
17+
expect(url).toBe('/diff#H4sIPAYLOAD')
18+
})
19+
20+
it('returns null on the E2E (?id=) path so the decryption key in the hash is never clobbered', () => {
21+
const url = buildLabelSyncUrl(
22+
{ pathname: '/diff', search: '?id=diff-123' },
23+
'H4sIPAYLOAD'
24+
)
25+
expect(url).toBeNull()
26+
})
27+
28+
it('also skips when id= is one of several query params', () => {
29+
const url = buildLabelSyncUrl(
30+
{ pathname: '/diff/', search: '?foo=1&id=diff-123' },
31+
'H4sIPAYLOAD'
32+
)
33+
expect(url).toBeNull()
34+
})
35+
})

helpers/labelSyncUrl.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export interface LocationParts {
2+
pathname: string
3+
search: string
4+
}
5+
6+
/**
7+
* Decide the URL to write back when the user renames a pane label.
8+
*
9+
* On the plain (locally-encoded) diff path the URL hash carries the
10+
* gzipped diff payload, so we rewrite it with the freshly-encoded
11+
* payload (re-including the new labels).
12+
*
13+
* On the end-to-end-encrypted path (`?id=` present) the hash instead
14+
* carries the AES decryption KEY for the server-stored blob. Rewriting
15+
* it with the payload would destroy the key, so refreshing — or copying —
16+
* the link can no longer decrypt the diff ("We couldn't decrypt your
17+
* diff."). On that path we return `null` to signal "leave the URL alone".
18+
*/
19+
export function buildLabelSyncUrl(
20+
loc: LocationParts,
21+
payloadHash: string
22+
): string | null {
23+
if (loc.search.includes('id=')) return null
24+
const hash = payloadHash.startsWith('#') ? payloadHash : `#${payloadHash}`
25+
return loc.pathname + hash
26+
}

helpers/lineStats.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { countLines, computeLineStats, formatDelta } from './lineStats'
2+
3+
describe('countLines', () => {
4+
it('counts lines the way Monaco gutter does (newlines + 1)', () => {
5+
expect(countLines('a')).toBe(1)
6+
expect(countLines('a\nb')).toBe(2)
7+
expect(countLines('a\nb\nc')).toBe(3)
8+
})
9+
10+
it('treats a trailing newline as a final (empty) line, matching the gutter', () => {
11+
expect(countLines('a\n')).toBe(2)
12+
})
13+
14+
it('reports an empty string as a single line (matches Monaco model)', () => {
15+
expect(countLines('')).toBe(1)
16+
})
17+
})
18+
19+
describe('computeLineStats', () => {
20+
it('reports per-side counts and the b-minus-a delta', () => {
21+
expect(computeLineStats('a\nb\nc', 'a\nb\nc\nd\ne')).toEqual({
22+
a: 3,
23+
b: 5,
24+
delta: 2,
25+
})
26+
})
27+
28+
it('reports a negative delta when the right side is shorter', () => {
29+
expect(computeLineStats('a\nb\nc\nd', 'a\nb')).toEqual({
30+
a: 4,
31+
b: 2,
32+
delta: -2,
33+
})
34+
})
35+
36+
it('reports a zero delta for equal-length sides', () => {
37+
expect(computeLineStats('a\nb', 'x\ny')).toEqual({ a: 2, b: 2, delta: 0 })
38+
})
39+
})
40+
41+
describe('formatDelta', () => {
42+
it('prefixes a positive delta with +', () => {
43+
expect(formatDelta(15)).toBe('+15')
44+
})
45+
46+
it('keeps the native minus sign for a negative delta', () => {
47+
expect(formatDelta(-15)).toBe('-15')
48+
})
49+
50+
it('renders zero without a sign', () => {
51+
expect(formatDelta(0)).toBe('0')
52+
})
53+
})

helpers/lineStats.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export interface LineStats {
2+
/** Line count of the left (original / "a") side. */
3+
a: number
4+
/** Line count of the right (modified / "b") side. */
5+
b: number
6+
/** Net line change, b - a (signed). */
7+
delta: number
8+
}
9+
10+
/**
11+
* Count display lines the way Monaco's gutter does: the number of
12+
* `\n`-separated segments, i.e. (newline count + 1). An empty string is
13+
* one line and a trailing newline adds a final empty line — both match
14+
* `ITextModel.getLineCount()` exactly, so these numbers line up with the
15+
* line numbers the user sees in the diff gutter.
16+
*/
17+
export function countLines(text: string): number {
18+
return text.split('\n').length
19+
}
20+
21+
/** Per-side line counts plus the b-minus-a delta for a diff. */
22+
export function computeLineStats(lhs: string, rhs: string): LineStats {
23+
const a = countLines(lhs)
24+
const b = countLines(rhs)
25+
return { a, b, delta: b - a }
26+
}
27+
28+
/** Render a delta with an explicit sign: "+15", "-15", or "0". */
29+
export function formatDelta(delta: number): string {
30+
return delta > 0 ? `+${delta}` : String(delta)
31+
}

jest.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
module.exports = {
2+
globals: {
3+
'ts-jest': {
4+
// Tests use jest globals (it/expect) which the app's tsconfig
5+
// intentionally excludes from its `types` allow-list. Point
6+
// ts-jest at a test-only tsconfig that re-adds the jest types so
7+
// type-checking the specs doesn't fail; the app build is untouched.
8+
tsconfig: 'tsconfig.jest.json',
9+
},
10+
},
211
moduleNameMapper: {
312
'^@/(.*)$': '<rootDir>/$1',
413
'^~/(.*)$': '<rootDir>/$1',

package-lock.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"test": "jest --passWithNoTests"
1616
},
1717
"engines": {
18-
"node": "22.x"
18+
"node": ">=22"
1919
},
2020
"lint-staged": {
2121
"*.{js,vue,ts}": "eslint --fix",
@@ -48,6 +48,7 @@
4848
"@nuxtjs/stylelint-module": "^4.0.0",
4949
"@nuxtjs/tailwindcss": "^4.2.0",
5050
"@types/express": "^5.0.3",
51+
"@types/jest": "^27.5.2",
5152
"@types/pg": "^8.15.5",
5253
"@vue/test-utils": "^1.2.1",
5354
"babel-core": "7.0.0-bridge.0",

0 commit comments

Comments
 (0)