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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
node: [20]
node: [22]

steps:
- name: Checkout 🛎
Expand Down
13 changes: 10 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
95 changes: 95 additions & 0 deletions components/diffActionBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@
</button>
</div>

<!-- Center: per-side line counts + net delta (b - a). "A" is the
original (left) pane, "B" is the modified (right) pane. -->
<div class="noden-line-stats" aria-label="Line counts" aria-live="polite">
<span class="noden-line-stat">
<span class="noden-line-stat-key">A</span>
<span class="noden-line-stat-val">{{ lineStats.a }}</span>
</span>
<span class="noden-line-stat">
<span class="noden-line-stat-key">B</span>
<span class="noden-line-stat-val">{{ lineStats.b }}</span>
</span>
<span
class="noden-line-stat noden-line-stat-delta"
:class="{
'is-positive': lineStats.delta > 0,
'is-negative': lineStats.delta < 0,
}"
:title="`Net change: ${formatDelta(lineStats.delta)} lines`"
>
<span class="noden-line-stat-key">Δ</span>
<span class="noden-line-stat-val">{{ formatDelta(lineStats.delta) }}</span>
</span>
</div>

<!-- Right side: copy-link CTA. -->
<CopyLink :click-handler="copyUrlToClipboard" :copied="copied" />
</section>
Expand All @@ -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: {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 "<idx>/<total> changes". Fixed min-width to
Expand Down
35 changes: 35 additions & 0 deletions helpers/labelSyncUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
26 changes: 26 additions & 0 deletions helpers/labelSyncUrl.ts
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions helpers/lineStats.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
31 changes: 31 additions & 0 deletions helpers/lineStats.ts
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 9 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -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: {
'^@/(.*)$': '<rootDir>/$1',
'^~/(.*)$': '<rootDir>/$1',
Expand Down
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"test": "jest --passWithNoTests"
},
"engines": {
"node": "22.x"
"node": ">=22"
},
"lint-staged": {
"*.{js,vue,ts}": "eslint --fix",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading