From 33057f3c99123c075ea34e6cc869cb602cb7b272 Mon Sep 17 00:00:00 2001 From: Daniel Rivas Perez Date: Thu, 23 Apr 2026 10:30:50 +0000 Subject: [PATCH 1/5] Fetch .gitattributes for files in the diff via git check-attr Adds a batched `git check-attr --stdin` call covering the four Linguist-style attributes we care about (linguist-generated, linguist-vendored, linguist-documentation, binary) and surfaces the results under DiffResponse.attrs. Graceful empty result when git is unavailable (pure-jj repo with no .git/). --- package-lock.json | 78 ------------------------- server/attrs.test.ts | 118 +++++++++++++++++++++++++++++++++++++ server/attrs.ts | 135 +++++++++++++++++++++++++++++++++++++++++++ server/main.ts | 7 ++- shared/types.ts | 14 +++++ 5 files changed, 273 insertions(+), 79 deletions(-) create mode 100644 server/attrs.test.ts create mode 100644 server/attrs.ts diff --git a/package-lock.json b/package-lock.json index 8f443d0..296c050 100644 --- a/package-lock.json +++ b/package-lock.json @@ -697,9 +697,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -717,9 +714,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -737,9 +731,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +748,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -777,9 +765,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -797,9 +782,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -817,9 +799,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -837,9 +816,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1044,9 +1020,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1064,9 +1037,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1084,9 +1054,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1104,9 +1071,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1124,9 +1088,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1144,9 +1105,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1164,9 +1122,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1184,9 +1139,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1384,9 +1336,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1404,9 +1353,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1424,9 +1370,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1444,9 +1387,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1464,9 +1404,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1484,9 +1421,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2281,9 +2215,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2305,9 +2236,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2329,9 +2257,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2353,9 +2278,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/server/attrs.test.ts b/server/attrs.test.ts new file mode 100644 index 0000000..a00d88a --- /dev/null +++ b/server/attrs.test.ts @@ -0,0 +1,118 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { describe, expect, it } from 'vitest' +import { parseCheckAttrOutput } from './attrs.ts' + +describe('parseCheckAttrOutput', () => { + it('returns empty object for empty input', () => { + expect(parseCheckAttrOutput('')).toEqual({}) + }) + + it('parses a single generated file', () => { + const out = [ + 'pnpm-lock.yaml: linguist-generated: set', + 'pnpm-lock.yaml: linguist-vendored: unspecified', + 'pnpm-lock.yaml: linguist-documentation: unspecified', + 'pnpm-lock.yaml: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)).toEqual({ + 'pnpm-lock.yaml': { + generated: true, + vendored: false, + documentation: false, + binary: false, + }, + }) + }) + + it('drops files where nothing was set', () => { + const out = [ + 'src/foo.ts: linguist-generated: unspecified', + 'src/foo.ts: linguist-vendored: unspecified', + 'src/foo.ts: linguist-documentation: unspecified', + 'src/foo.ts: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)).toEqual({}) + }) + + it('treats `unset` as false', () => { + const out = [ + 'README.md: linguist-generated: unset', + 'README.md: linguist-vendored: unspecified', + 'README.md: linguist-documentation: set', + 'README.md: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)).toEqual({ + 'README.md': { + generated: false, + vendored: false, + documentation: true, + binary: false, + }, + }) + }) + + it('parses multiple files', () => { + const out = [ + 'pnpm-lock.yaml: linguist-generated: set', + 'pnpm-lock.yaml: linguist-vendored: unspecified', + 'pnpm-lock.yaml: linguist-documentation: unspecified', + 'pnpm-lock.yaml: binary: unspecified', + 'third-party/lib.js: linguist-generated: unspecified', + 'third-party/lib.js: linguist-vendored: set', + 'third-party/lib.js: linguist-documentation: unspecified', + 'third-party/lib.js: binary: unspecified', + 'logo.png: linguist-generated: unspecified', + 'logo.png: linguist-vendored: unspecified', + 'logo.png: linguist-documentation: unspecified', + 'logo.png: binary: set', + '', + ].join('\n') + const result = parseCheckAttrOutput(out) + expect(result['pnpm-lock.yaml']?.generated).toBe(true) + expect(result['third-party/lib.js']?.vendored).toBe(true) + expect(result['logo.png']?.binary).toBe(true) + expect(Object.keys(result).length).toBe(3) + }) + + it('handles paths containing colons', () => { + // Unusual but legal — git doesn't quote these, so the parser must split + // from the right. + const out = [ + 'weird: file: linguist-generated: set', + 'weird: file: linguist-vendored: unspecified', + 'weird: file: linguist-documentation: unspecified', + 'weird: file: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)['weird: file']?.generated).toBe(true) + }) + + it('ignores unrelated attribute lines', () => { + const out = [ + 'foo.ts: unrelated-attr: set', + 'foo.ts: linguist-generated: set', + 'foo.ts: linguist-vendored: unspecified', + 'foo.ts: linguist-documentation: unspecified', + 'foo.ts: binary: unspecified', + '', + ].join('\n') + expect(parseCheckAttrOutput(out)).toEqual({ + 'foo.ts': { + generated: true, + vendored: false, + documentation: false, + binary: false, + }, + }) + }) +}) diff --git a/server/attrs.ts b/server/attrs.ts new file mode 100644 index 0000000..972ff38 --- /dev/null +++ b/server/attrs.ts @@ -0,0 +1,135 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Per-file attributes that affect how skepsis displays a file in the diff. + * Sourced from `.gitattributes` via `git check-attr`, mirroring GitHub + * Linguist's behavior for `linguist-generated`, `linguist-vendored`, + * `linguist-documentation`, and `binary`. + * + * Generated/vendored/binary files are auto-collapsed by the frontend; + * documentation files get a badge but stay expanded. + */ + +import { spawn } from 'node:child_process' +import type { FileAttrs } from '../shared/types.ts' + +const TRACKED_ATTRS = [ + 'linguist-generated', + 'linguist-vendored', + 'linguist-documentation', + 'binary', +] as const + +type TrackedAttr = (typeof TRACKED_ATTRS)[number] + +function emptyAttrs(): FileAttrs { + return { + generated: false, + vendored: false, + documentation: false, + binary: false, + } +} + +/** + * Fetch `.gitattributes` attributes for the given file paths. Returns a + * partial map: files with no tracked attributes set are omitted. + * + * Resolves to `{}` if `git check-attr` is unavailable (e.g., pure-jj repo + * with no `.git/`) — skepsis treats that as "no files have attributes." + */ +export async function getGitAttrs( + files: string[], + cwd: string, +): Promise> { + if (files.length === 0) return {} + + let stdout: string + try { + stdout = await runCheckAttr(files, cwd) + } catch { + return {} + } + return parseCheckAttrOutput(stdout) +} + +function runCheckAttr(files: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('git', ['check-attr', '--stdin', ...TRACKED_ATTRS], { + cwd, + }) + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + proc.stdout.on('data', (d: Buffer) => stdoutChunks.push(d)) + proc.stderr.on('data', (d: Buffer) => stderrChunks.push(d)) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(stdoutChunks).toString()) + } else { + reject( + new Error( + `git check-attr exited ${code}: ${Buffer.concat(stderrChunks).toString()}`, + ), + ) + } + }) + proc.stdin.end(files.join('\n') + '\n') + }) +} + +const ATTR_TO_FIELD: Record = { + 'linguist-generated': 'generated', + 'linguist-vendored': 'vendored', + 'linguist-documentation': 'documentation', + binary: 'binary', +} + +/** + * Parse `git check-attr --stdin` output. Format per line: + * : : + * where value is `set`, `unset`, `unspecified`, or a literal string. + * Only `set` counts as true for our boolean attributes. + */ +export function parseCheckAttrOutput(stdout: string): Record { + const result: Record = {} + + for (const line of stdout.split('\n')) { + if (!line) continue + + // Parse "path: attr: value" from the right, since paths may contain colons. + const lastColon = line.lastIndexOf(': ') + if (lastColon === -1) continue + const value = line.slice(lastColon + 2) + const pathAndAttr = line.slice(0, lastColon) + + const midColon = pathAndAttr.lastIndexOf(': ') + if (midColon === -1) continue + const path = pathAndAttr.slice(0, midColon) + const attr = pathAndAttr.slice(midColon + 2) + + if (!isTrackedAttr(attr)) continue + + const attrs = (result[path] ??= emptyAttrs()) + attrs[ATTR_TO_FIELD[attr]] = value === 'set' + } + + // Drop entries where nothing was set — keeps the payload small. + for (const [path, attrs] of Object.entries(result)) { + if (!attrs.generated && !attrs.vendored && !attrs.documentation && !attrs.binary) { + delete result[path] + } + } + + return result +} + +function isTrackedAttr(s: string): s is TrackedAttr { + return (TRACKED_ATTRS as readonly string[]).includes(s) +} diff --git a/server/main.ts b/server/main.ts index a47a547..0b0ae27 100644 --- a/server/main.ts +++ b/server/main.ts @@ -14,6 +14,7 @@ import { join, relative } from 'node:path' import { readFile } from 'node:fs/promises' import type { DiffArgs } from '../shared/types.ts' import { getDiff, validateDiffArgs } from './diff.ts' +import { getGitAttrs } from './attrs.ts' import { loadViewed, markViewed, unmarkViewed } from './viewed.ts' import { insertComment, removeComment } from './comment.ts' import { @@ -36,7 +37,10 @@ export async function startServer(opts: { app.get('/api/diff', async (c) => { const { patch, fileHashes } = await getDiff(diffSource) - const viewed = await loadViewed(cwd, fileHashes) + const [viewed, attrs] = await Promise.all([ + loadViewed(cwd, fileHashes), + getGitAttrs(Object.keys(fileHashes), cwd), + ]) const fileSuffix = diffSource.files.length > 0 ? ` -- ${diffSource.files.join(' ')}` : '' return c.json({ @@ -49,6 +53,7 @@ export async function startServer(opts: { commentsEnabled: diffSource.commentsEnabled, fileHashes, viewed, + attrs, } satisfies DiffResponse) }) diff --git a/shared/types.ts b/shared/types.ts index 690150f..5eedec5 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -49,6 +49,18 @@ export type DiffArgs = ({ vcs: 'jj'; args: string[] } | { vcs: 'git'; args: stri export type ViewedMap = Record export type FileHashes = Record +/** + * Per-file attributes (from `.gitattributes` and Linguist built-in rules). + * Generated/vendored/binary cause auto-collapse in the UI; documentation + * only produces a badge. + */ +export interface FileAttrs { + generated: boolean + vendored: boolean + documentation: boolean + binary: boolean +} + export interface DiffResponse { patch: string revset: string @@ -56,6 +68,8 @@ export interface DiffResponse { commentsEnabled: boolean fileHashes: FileHashes viewed: ViewedMap + /** Only includes files with at least one attribute set. */ + attrs: Record error?: string } From 360fb74f5779386ad5265ea115107d9f29805124 Mon Sep 17 00:00:00 2001 From: Daniel Rivas Perez Date: Thu, 23 Apr 2026 10:34:02 +0000 Subject: [PATCH 2/5] Classify files by Linguist's built-in path rules Ports the name-based rules from generated.rb plus the full regex lists from vendor.yml and documentation.yml (pinned to linguist @ 537297cdae3ab05f8d5dd1c03627a5bd73707b19) into pure functions that run over file paths. Adds a small merge helper: .gitattributes decisions from M1 win over path rules on a per-attribute basis, so an explicit `linguist-generated=unset` still lets documentation detection run. --- server/attrs.test.ts | 69 ++++-- server/attrs.ts | 95 +++++--- server/linguist/pathRules.test.ts | 216 ++++++++++++++++++ server/linguist/pathRules.ts | 358 ++++++++++++++++++++++++++++++ server/main.ts | 4 +- 5 files changed, 699 insertions(+), 43 deletions(-) create mode 100644 server/linguist/pathRules.test.ts create mode 100644 server/linguist/pathRules.ts diff --git a/server/attrs.test.ts b/server/attrs.test.ts index a00d88a..b1fd256 100644 --- a/server/attrs.test.ts +++ b/server/attrs.test.ts @@ -7,14 +7,14 @@ */ import { describe, expect, it } from 'vitest' -import { parseCheckAttrOutput } from './attrs.ts' +import { mergeAttrs, parseCheckAttrOutput } from './attrs.ts' describe('parseCheckAttrOutput', () => { it('returns empty object for empty input', () => { expect(parseCheckAttrOutput('')).toEqual({}) }) - it('parses a single generated file', () => { + it('parses a generated file as an explicit set', () => { const out = [ 'pnpm-lock.yaml: linguist-generated: set', 'pnpm-lock.yaml: linguist-vendored: unspecified', @@ -23,16 +23,11 @@ describe('parseCheckAttrOutput', () => { '', ].join('\n') expect(parseCheckAttrOutput(out)).toEqual({ - 'pnpm-lock.yaml': { - generated: true, - vendored: false, - documentation: false, - binary: false, - }, + 'pnpm-lock.yaml': { generated: true }, }) }) - it('drops files where nothing was set', () => { + it('drops files where nothing was set or unset', () => { const out = [ 'src/foo.ts: linguist-generated: unspecified', 'src/foo.ts: linguist-vendored: unspecified', @@ -43,7 +38,7 @@ describe('parseCheckAttrOutput', () => { expect(parseCheckAttrOutput(out)).toEqual({}) }) - it('treats `unset` as false', () => { + it('records `unset` as an explicit false', () => { const out = [ 'README.md: linguist-generated: unset', 'README.md: linguist-vendored: unspecified', @@ -52,12 +47,7 @@ describe('parseCheckAttrOutput', () => { '', ].join('\n') expect(parseCheckAttrOutput(out)).toEqual({ - 'README.md': { - generated: false, - vendored: false, - documentation: true, - binary: false, - }, + 'README.md': { generated: false, documentation: true }, }) }) @@ -107,6 +97,22 @@ describe('parseCheckAttrOutput', () => { '', ].join('\n') expect(parseCheckAttrOutput(out)).toEqual({ + 'foo.ts': { generated: true }, + }) + }) +}) + +describe('mergeAttrs', () => { + it('returns {} for no layers', () => { + expect(mergeAttrs([])).toEqual({}) + }) + + it('returns {} when nothing ends up set', () => { + expect(mergeAttrs([{ 'foo.ts': { generated: false } }])).toEqual({}) + }) + + it('materializes a single layer to full FileAttrs', () => { + expect(mergeAttrs([{ 'foo.ts': { generated: true } }])).toEqual({ 'foo.ts': { generated: true, vendored: false, @@ -115,4 +121,35 @@ describe('parseCheckAttrOutput', () => { }, }) }) + + it('lets a higher-priority layer override a lower one (per attribute)', () => { + // check-attr says linguist-generated: unset, even though path rules say + // it's generated. Expect the explicit unset to win. + const checkAttr = { 'weird.js': { generated: false } } + const pathRules = { 'weird.js': { generated: true } } + expect(mergeAttrs([checkAttr, pathRules])).toEqual({}) + }) + + it('falls through to the next layer when the first is silent on an attr', () => { + // check-attr sets linguist-documentation, says nothing about generated. + // path rules say the file is generated. Expect both to apply. + const checkAttr = { 'foo.md': { documentation: true } } + const pathRules = { 'foo.md': { generated: true } } + expect(mergeAttrs([checkAttr, pathRules])).toEqual({ + 'foo.md': { + generated: true, + vendored: false, + documentation: true, + binary: false, + }, + }) + }) + + it('unions paths across layers', () => { + const a = { 'a.ts': { generated: true } } + const b = { 'b.ts': { vendored: true } } + const merged = mergeAttrs([a, b]) + expect(merged['a.ts']?.generated).toBe(true) + expect(merged['b.ts']?.vendored).toBe(true) + }) }) diff --git a/server/attrs.ts b/server/attrs.ts index 972ff38..da125a5 100644 --- a/server/attrs.ts +++ b/server/attrs.ts @@ -8,9 +8,11 @@ /** * Per-file attributes that affect how skepsis displays a file in the diff. - * Sourced from `.gitattributes` via `git check-attr`, mirroring GitHub - * Linguist's behavior for `linguist-generated`, `linguist-vendored`, - * `linguist-documentation`, and `binary`. + * Mirrors GitHub Linguist: `linguist-generated`, `linguist-vendored`, + * `linguist-documentation`, and `binary`. Sources (highest priority first): + * + * 1. `.gitattributes` via `git check-attr` — explicit `set`/`unset`. + * 2. Linguist's built-in path rules (`linguist/pathRules.ts`). * * Generated/vendored/binary files are auto-collapsed by the frontend; * documentation files get a badge but stay expanded. @@ -18,6 +20,13 @@ import { spawn } from 'node:child_process' import type { FileAttrs } from '../shared/types.ts' +import { classifyByPaths } from './linguist/pathRules.ts' + +/** + * A `Partial` tracks which attributes a layer has decided on. + * A missing key means "no opinion" — the next layer gets a turn. + */ +export type PartialAttrMap = Record> const TRACKED_ATTRS = [ 'linguist-generated', @@ -28,26 +37,30 @@ const TRACKED_ATTRS = [ type TrackedAttr = (typeof TRACKED_ATTRS)[number] -function emptyAttrs(): FileAttrs { - return { - generated: false, - vendored: false, - documentation: false, - binary: false, - } +/** + * Resolve the full attribute map for a set of files by combining + * `.gitattributes` (highest priority) with Linguist's built-in path rules. + * Files without any set attribute are omitted from the result. + */ +export async function resolveFileAttrs( + files: string[], + cwd: string, +): Promise> { + if (files.length === 0) return {} + const [gitAttrs, pathAttrs] = [await getGitAttrs(files, cwd), classifyByPaths(files)] + return mergeAttrs([gitAttrs, pathAttrs]) } /** - * Fetch `.gitattributes` attributes for the given file paths. Returns a - * partial map: files with no tracked attributes set are omitted. + * Fetch `.gitattributes` attributes for the given file paths. Presence of + * a key in the inner `Partial` means check-attr returned + * `set` or `unset` for that attribute; `unspecified` is represented as a + * missing key so lower-priority layers can contribute. * * Resolves to `{}` if `git check-attr` is unavailable (e.g., pure-jj repo - * with no `.git/`) — skepsis treats that as "no files have attributes." + * with no `.git/`). */ -export async function getGitAttrs( - files: string[], - cwd: string, -): Promise> { +export async function getGitAttrs(files: string[], cwd: string): Promise { if (files.length === 0) return {} let stdout: string @@ -95,10 +108,10 @@ const ATTR_TO_FIELD: Record = { * Parse `git check-attr --stdin` output. Format per line: * : : * where value is `set`, `unset`, `unspecified`, or a literal string. - * Only `set` counts as true for our boolean attributes. + * `unspecified` is dropped so lower-priority sources can contribute. */ -export function parseCheckAttrOutput(stdout: string): Record { - const result: Record = {} +export function parseCheckAttrOutput(stdout: string): PartialAttrMap { + const result: PartialAttrMap = {} for (const line of stdout.split('\n')) { if (!line) continue @@ -115,15 +128,47 @@ export function parseCheckAttrOutput(stdout: string): Record const attr = pathAndAttr.slice(midColon + 2) if (!isTrackedAttr(attr)) continue + if (value !== 'set' && value !== 'unset') continue - const attrs = (result[path] ??= emptyAttrs()) + const attrs = (result[path] ??= {}) attrs[ATTR_TO_FIELD[attr]] = value === 'set' } - // Drop entries where nothing was set — keeps the payload small. - for (const [path, attrs] of Object.entries(result)) { - if (!attrs.generated && !attrs.vendored && !attrs.documentation && !attrs.binary) { - delete result[path] + return result +} + +/** + * Merge attribute layers in priority order (first wins per attribute). + * Materializes to full `FileAttrs` objects and drops entries where + * nothing ended up set. + */ +export function mergeAttrs(layers: PartialAttrMap[]): Record { + const paths = new Set() + for (const layer of layers) { + for (const path of Object.keys(layer)) paths.add(path) + } + + const result: Record = {} + const fields: (keyof FileAttrs)[] = ['generated', 'vendored', 'documentation', 'binary'] + + for (const path of paths) { + const out: FileAttrs = { + generated: false, + vendored: false, + documentation: false, + binary: false, + } + for (const field of fields) { + for (const layer of layers) { + const v = layer[path]?.[field] + if (v !== undefined) { + out[field] = v + break + } + } + } + if (out.generated || out.vendored || out.documentation || out.binary) { + result[path] = out } } diff --git a/server/linguist/pathRules.test.ts b/server/linguist/pathRules.test.ts new file mode 100644 index 0000000..61f2a45 --- /dev/null +++ b/server/linguist/pathRules.test.ts @@ -0,0 +1,216 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { describe, expect, it } from 'vitest' +import { classifyByPath, classifyByPaths } from './pathRules.ts' + +describe('classifyByPath — generated', () => { + it.each([ + // xcode_file? (extension-based) + ['MainMenu.nib'], + ['App.xcworkspace/contents.xcworkspacedata'], + ['UserInterfaceState.xcuserstate'], + // intellij_file? + ['.idea/workspace.xml'], + ['sub/.idea/misc.xml'], + // cocoapods? + ['Pods/Alamofire/Source/Alamofire.swift'], + ['app/Pods/FBSDK/README.md'], + // carthage_build? + ['Carthage/Build/iOS/Alamofire.framework/Info.plist'], + // node_modules? + ['node_modules/react/index.js'], + // go_lock? + ['Gopkg.lock'], + ['glide.lock'], + // package_resolved? + ['Package.resolved'], + // lock files + ['poetry.lock'], + ['pdm.lock'], + ['uv.lock'], + ['pixi.lock'], + ['deno.lock'], + ['esy.lock'], + ['my.esy.lock'], + // npm_shrinkwrap_or_package_lock? + ['npm-shrinkwrap.json'], + ['package-lock.json'], + // pnpm_lock? + ['pnpm-lock.yaml'], + // bun_lock? + ['bun.lock'], + ['bun.lockb'], + ['sub/bun.lock'], + // generated_yarn_plugnplay? + ['.pnp.js'], + ['.pnp.cjs'], + ['sub/.pnp.loader.mjs'], + // godeps? + ['Godeps/foo.go'], + // composer_lock? + ['composer.lock'], + // cargo + ['Cargo.lock'], + ['sub/Cargo.lock'], + ['Cargo.toml.orig'], + // flake_lock? + ['flake.lock'], + ['sub/flake.lock'], + // bazel_lock? + ['MODULE.bazel.lock'], + // pipenv_lock? + ['Pipfile.lock'], + // terraform_lock? + ['.terraform.lock.hcl'], + ['sub/.terraform.lock.hcl'], + // generated_graphql_relay? + ['src/__generated__/Query.graphql.ts'], + // generated_net_designer_file? (case insensitive) + ['Form1.Designer.cs'], + ['form1.designer.vb'], + // generated_net_specflow_feature_file? + ['Features/Login.feature.cs'], + // generated_pascal_tlb? + ['UnitName_TLB.pas'], + // gradle_wrapper? + ['gradlew'], + ['sub/gradlew.bat'], + // maven_wrapper? + ['mvnw'], + ['mvnw.cmd'], + // htmlcov? + ['htmlcov/index.html'], + ])('%s is generated', (path) => { + expect(classifyByPath(path).generated).toBe(true) + }) + + it('respects the go_vendor? heuristic', () => { + // go_vendor? matches import paths under vendor/. + expect(classifyByPath('vendor/github.com/pkg/errors/errors.go').generated).toBe(true) + // But not plain `vendor/` entries that don't look like domains. + expect(classifyByPath('vendor/foo').generated).toBeUndefined() + }) + + it('flags generated_by_zephir? only with the right extensions', () => { + expect(classifyByPath('src/foo.zep.c').generated).toBe(true) + expect(classifyByPath('src/foo.zep.h').generated).toBe(true) + expect(classifyByPath('src/foo.zep.php').generated).toBe(true) + expect(classifyByPath('src/foo.zep').generated).toBeUndefined() + }) +}) + +describe('classifyByPath — vendored', () => { + it.each([ + ['cache/something.dat'], + ['Dependencies/foo.c'], + ['dist/bundle.js'], + ['deps/openssl/openssl.c'], + ['configure'], + ['node_modules/pkg/file.js'], + ['.yarn/releases/yarn.cjs'], + ['bower_components/jquery/jquery.js'], + ['third-party/lib.js'], + ['3rd-party/lib.js'], + ['vendor/foo.js'], + ['vendors/lib.js'], + ['extern/lib.js'], + ['externals/lib.js'], + ['app.min.js'], + ['app.min.css'], + ['style-min.js'], + ['bootstrap.css'], + ['bootstrap.min.css'], + ['font-awesome.css'], + ['jquery.js'], + ['jquery-3.6.0.js'], + ['d3.js'], + ['d3.v5.min.js'], + ['react.js'], + ['react-dom.js'], + ['Vagrantfile'], + ['Jenkinsfile'], + ['.DS_Store'], + ['.github/workflows/ci.yml'], + ['.vscode/settings.json'], + ['.gitignore'], + ['.gitattributes'], + ['gradlew'], + ['types/node.d.ts'], + ['tests/fixtures/input.txt'], + ['specs/fixtures/case.json'], + ])('%s is vendored', (path) => { + expect(classifyByPath(path).vendored).toBe(true) + }) +}) + +describe('classifyByPath — documentation', () => { + it.each([ + ['docs/README.md'], + ['Docs/index.html'], + ['doc/manual.pdf'], + ['Doc/overview.md'], + ['Documentation/api.md'], + ['pkg/Documentation/api.md'], + ['man/cmd.1'], + ['examples/basic.py'], + ['demo/sample.html'], + ['demos/sample.html'], + ['samples/sample.py'], + ['inst/doc/notes.md'], + ['CITATION'], + ['CITATION.cff'], + ['CITATIONS.md'], + ['CHANGELOG'], + ['CHANGELOG.md'], + ['CONTRIBUTING.md'], + ['COPYING'], + ['INSTALL.md'], + ['LICENSE'], + ['LICENSE.md'], + ['LICENCE'], + ['README'], + ['README.md'], + ['readme.txt'], + ])('%s is documentation', (path) => { + expect(classifyByPath(path).documentation).toBe(true) + }) +}) + +describe('classifyByPath — no match', () => { + it.each([ + ['src/App.tsx'], + ['server/main.ts'], + ['cli.ts'], + ['index.html'], + ['package.json'], + ])('%s returns no attributes', (path) => { + expect(classifyByPath(path)).toEqual({}) + }) +}) + +describe('classifyByPaths', () => { + it('returns only files that matched a rule', () => { + const result = classifyByPaths([ + 'src/App.tsx', + 'pnpm-lock.yaml', + 'README.md', + 'node_modules/lib.js', + ]) + expect(Object.keys(result).toSorted()).toEqual([ + 'README.md', + 'node_modules/lib.js', + 'pnpm-lock.yaml', + ]) + expect(result['pnpm-lock.yaml']?.generated).toBe(true) + expect(result['README.md']?.documentation).toBe(true) + // node_modules matches both generated and vendored in Linguist. + expect(result['node_modules/lib.js']?.generated).toBe(true) + expect(result['node_modules/lib.js']?.vendored).toBe(true) + }) +}) diff --git a/server/linguist/pathRules.ts b/server/linguist/pathRules.ts new file mode 100644 index 0000000..359ec2e --- /dev/null +++ b/server/linguist/pathRules.ts @@ -0,0 +1,358 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Linguist's path-based rules for generated, vendored, and documentation + * files — the ones that can be decided from the filename alone, without + * reading the file's content. + * + * Ported from: + * github-linguist/linguist @ 537297cdae3ab05f8d5dd1c03627a5bd73707b19 + * - lib/linguist/generated.rb (name-based checks) + * - lib/linguist/vendor.yml + * - lib/linguist/documentation.yml + * + * Content-based Linguist rules (source maps, protobuf, etc.) live in + * `contentRules.ts`. + */ + +import type { FileAttrs } from '../../shared/types.ts' + +// --- Generated (path-based subset of generated.rb) --- + +const GENERATED_EXTENSIONS = new Set([ + // xcode_file? + '.nib', + '.xcworkspacedata', + '.xcuserstate', +]) + +const GENERATED_PATH_PATTERNS: string[] = [ + // intellij_file? + '(?:^|/)\\.idea/', + // cocoapods? + '(^Pods|/Pods)/', + // carthage_build? + '(^|/)Carthage/Build/', + // node_modules? + 'node_modules/', + // go_vendor? + 'vendor/((?!-)[-0-9A-Za-z]+(? new RegExp(p)), + ...GENERATED_PATH_PATTERNS_CI.map((p) => new RegExp(p, 'i')), +] + +function hasGeneratedExtension(path: string): boolean { + const lastSlash = path.lastIndexOf('/') + const base = lastSlash === -1 ? path : path.slice(lastSlash + 1) + const dot = base.lastIndexOf('.') + if (dot === -1) return false + return GENERATED_EXTENSIONS.has(base.slice(dot)) +} + +// --- Vendor (vendor.yml, 168 patterns) --- + +const VENDOR_PATTERNS: string[] = [ + '(^|/)cache/', + '^[Dd]ependencies/', + '(^|/)dist/', + '^deps/', + '(^|/)configure$', + '(^|/)config\\.guess$', + '(^|/)config\\.sub$', + '(^|/)aclocal\\.m4', + '(^|/)libtool\\.m4', + '(^|/)ltoptions\\.m4', + '(^|/)ltsugar\\.m4', + '(^|/)ltversion\\.m4', + '(^|/)lt~obsolete\\.m4', + '(^|/)dotnet-install\\.(ps1|sh)$', + '(^|/)cpplint\\.py', + '(^|/)node_modules/', + '(^|/)\\.yarn/releases/', + '(^|/)\\.yarn/plugins/', + '(^|/)\\.yarn/sdks/', + '(^|/)\\.yarn/versions/', + '(^|/)\\.yarn/unplugged/', + '(^|/)_esy$', + '(^|/)bower_components/', + '^rebar$', + '(^|/)erlang\\.mk', + '(^|/)Godeps/_workspace/', + '(^|/)testdata/', + '(^|/)\\.indent\\.pro', + '(\\.|-)min\\.(js|css)$', + '([^\\s]*)import\\.(css|less|scss|styl)$', + '(^|/)bootstrap([^/.]*)(\\..*)?\\.(js|css|less|scss|styl)$', + '(^|/)custom\\.bootstrap([^\\s]*)(js|css|less|scss|styl)$', + '(^|/)font-?awesome\\.(css|less|scss|styl)$', + '(^|/)font-?awesome/.*\\.(css|less|scss|styl)$', + '(^|/)foundation\\.(css|less|scss|styl)$', + '(^|/)normalize\\.(css|less|scss|styl)$', + '(^|/)skeleton\\.(css|less|scss|styl)$', + '(^|/)[Bb]ourbon/.*\\.(css|less|scss|styl)$', + '(^|/)animate\\.(css|less|scss|styl)$', + '(^|/)materialize\\.(css|less|scss|styl|js)$', + '(^|/)select2/.*\\.(css|scss|js)$', + '(^|/)bulma\\.(css|sass|scss)$', + '(3rd|[Tt]hird)[-_]?[Pp]arty/', + '(^|/)vendors?/', + '(^|/)[Ee]xtern(als?)?/', + '(^|/)[Vv]+endor/', + '^debian/', + '(^|/)run\\.n$', + '(^|/)bootstrap-datepicker/', + '(^|/)jquery([^.]*)\\.js$', + '(^|/)jquery\\-\\d\\.\\d+(\\.\\d+)?\\.js$', + '(^|/)jquery\\-ui(\\-\\d\\.\\d+(\\.\\d+)?)?(\\.\\w+)?\\.(js|css)$', + '(^|/)jquery\\.(ui|effects)\\.([^.]*)\\.(js|css)$', + '(^|/)jquery\\.fn\\.gantt\\.js', + '(^|/)jquery\\.fancybox\\.(js|css)', + '(^|/)fuelux\\.js', + '(^|/)jquery\\.fileupload(-\\w+)?\\.js$', + '(^|/)jquery\\.dataTables\\.js', + '(^|/)bootbox\\.js', + '(^|/)pdf\\.worker\\.js', + '(^|/)slick\\.\\w+.js$', + '(^|/)Leaflet\\.Coordinates-\\d+\\.\\d+\\.\\d+\\.src\\.js$', + '(^|/)leaflet\\.draw-src\\.js', + '(^|/)leaflet\\.draw\\.css', + '(^|/)Control\\.FullScreen\\.css', + '(^|/)Control\\.FullScreen\\.js', + '(^|/)leaflet\\.spin\\.js', + '(^|/)wicket-leaflet\\.js', + '(^|/)\\.sublime-project', + '(^|/)\\.sublime-workspace', + '(^|/)\\.vscode/', + '(^|/)prototype(.*)\\.js$', + '(^|/)effects\\.js$', + '(^|/)controls\\.js$', + '(^|/)dragdrop\\.js$', + '(.*?)\\.d\\.ts$', + '(^|/)mootools([^.]*)\\d+\\.\\d+.\\d+([^.]*)\\.js$', + '(^|/)dojo\\.js$', + '(^|/)MochiKit\\.js$', + '(^|/)yahoo-([^.]*)\\.js$', + '(^|/)yui([^.]*)\\.js$', + '(^|/)ckeditor\\.js$', + '(^|/)tiny_mce([^.]*)\\.js$', + '(^|/)tiny_mce/(langs|plugins|themes|utils)', + '(^|/)ace-builds/', + '(^|/)fontello(.*?)\\.css$', + '(^|/)MathJax/', + '(^|/)Chart\\.js$', + '(^|/)[Cc]ode[Mm]irror/(\\d+\\.\\d+/)?(lib|mode|theme|addon|keymap|demo)', + '(^|/)shBrush([^.]*)\\.js$', + '(^|/)shCore\\.js$', + '(^|/)shLegacy\\.js$', + '(^|/)angular([^.]*)\\.js$', + '(^|/)d3(\\.v\\d+)?([^.]*)\\.js$', + '(^|/)react(-[^.]*)?\\.js$', + '(^|/)flow-typed/.*\\.js$', + '(^|/)modernizr\\-\\d\\.\\d+(\\.\\d+)?\\.js$', + '(^|/)modernizr\\.custom\\.\\d+\\.js$', + '(^|/)knockout-(\\d+\\.){3}(debug\\.)?js$', + '(^|/)docs?/_?(build|themes?|templates?|static)/', + '(^|/)admin_media/', + '(^|/)env/', + '(^|/)fabfile\\.py$', + '(^|/)waf$', + '(^|/)\\.osx$', + '\\.xctemplate/', + '\\.imageset/', + '(^|/)Carthage/', + '(^|/)Sparkle/', + '(^|/)Crashlytics\\.framework/', + '(^|/)Fabric\\.framework/', + '(^|/)BuddyBuildSDK\\.framework/', + '(^|/)Realm\\.framework', + '(^|/)RealmSwift\\.framework', + '(^|/)\\.gitattributes$', + '(^|/)\\.gitignore$', + '(^|/)\\.gitmodules$', + '(^|/)gradlew$', + '(^|/)gradlew\\.bat$', + '(^|/)gradle/wrapper/', + '(^|/)mvnw$', + '(^|/)mvnw\\.cmd$', + '(^|/)\\.mvn/wrapper/', + '-vsdoc\\.js$', + '\\.intellisense\\.js$', + '(^|/)jquery([^.]*)\\.validate(\\.unobtrusive)?\\.js$', + '(^|/)jquery([^.]*)\\.unobtrusive\\-ajax\\.js$', + '(^|/)[Mm]icrosoft([Mm]vc)?([Aa]jax|[Vv]alidation)(\\.debug)?\\.js$', + '(^|/)[Pp]ackages/.+\\.\\d+/', + '(^|/)extjs/.*?\\.js$', + '(^|/)extjs/.*?\\.xml$', + '(^|/)extjs/.*?\\.txt$', + '(^|/)extjs/.*?\\.html$', + '(^|/)extjs/.*?\\.properties$', + '(^|/)extjs/\\.sencha/', + '(^|/)extjs/docs/', + '(^|/)extjs/builds/', + '(^|/)extjs/cmd/', + '(^|/)extjs/examples/', + '(^|/)extjs/locale/', + '(^|/)extjs/packages/', + '(^|/)extjs/plugins/', + '(^|/)extjs/resources/', + '(^|/)extjs/src/', + '(^|/)extjs/welcome/', + '(^|/)html5shiv\\.js$', + '(^|/)[Tt]ests?/fixtures/', + '(^|/)[Ss]pecs?/fixtures/', + '(^|/)cordova([^.]*)\\.js$', + '(^|/)cordova\\-\\d\\.\\d(\\.\\d)?\\.js$', + '(^|/)foundation(\\..*)?\\.js$', + '(^|/)Vagrantfile$', + '(^|/)\\.[Dd][Ss]_[Ss]tore$', + '(^|/)inst/extdata/', + '(^|/)octicons\\.css', + '(^|/)sprockets-octicons\\.scss', + '(^|/)activator$', + '(^|/)activator\\.bat$', + '(^|/)proguard\\.pro$', + '(^|/)proguard-rules\\.pro$', + '(^|/)puphpet/', + '(^|/)\\.google_apis/', + '(^|/)Jenkinsfile$', + '(^|/)\\.gitpod\\.Dockerfile$', + '(^|/)\\.github/', + '(^|/)\\.obsidian/', + '(^|/)\\.teamcity/', + '(^|/)xvba_modules/', +] + +const VENDOR_REGEXES: RegExp[] = VENDOR_PATTERNS.map((p) => new RegExp(p)) + +// --- Documentation (documentation.yml) --- + +const DOCUMENTATION_PATTERNS: string[] = [ + '^[Dd]ocs?/', + '(^|/)[Dd]ocumentation/', + '(^|/)[Gg]roovydoc/', + '(^|/)[Jj]avadoc/', + '^[Mm]an/', + '^[Ee]xamples/', + '^[Dd]emos?/', + '(^|/)inst/doc/', + '(^|/)CITATION(\\.cff|(S)?(\\.(bib|md))?)$', + '(^|/)CHANGE(S|LOG)?(\\.|$)', + '(^|/)CONTRIBUTING(\\.|$)', + '(^|/)COPYING(\\.|$)', + '(^|/)INSTALL(\\.|$)', + '(^|/)LICEN[CS]E(\\.|$)', + '(^|/)[Ll]icen[cs]e(\\.|$)', + '(^|/)README(\\.|$)', + '(^|/)[Rr]eadme(\\.|$)', + '^[Ss]amples?/', +] + +const DOCUMENTATION_REGEXES: RegExp[] = DOCUMENTATION_PATTERNS.map((p) => new RegExp(p)) + +// --- Public API --- + +/** + * Classify a file path using Linguist's built-in path rules. Returns only + * the attributes that the rules explicitly set; a file that doesn't match + * any rule returns `{}`. + * + * The caller is responsible for merging this with other attribute sources + * (e.g., `.gitattributes`). + */ +export function classifyByPath(path: string): Partial { + const result: Partial = {} + if (hasGeneratedExtension(path) || GENERATED_REGEXES.some((r) => r.test(path))) { + result.generated = true + } + if (VENDOR_REGEXES.some((r) => r.test(path))) { + result.vendored = true + } + if (DOCUMENTATION_REGEXES.some((r) => r.test(path))) { + result.documentation = true + } + return result +} + +/** Apply `classifyByPath` to many files, dropping entries with no matches. */ +export function classifyByPaths(paths: string[]): Record> { + const result: Record> = {} + for (const path of paths) { + const attrs = classifyByPath(path) + if (Object.keys(attrs).length > 0) result[path] = attrs + } + return result +} diff --git a/server/main.ts b/server/main.ts index 0b0ae27..6aacd8e 100644 --- a/server/main.ts +++ b/server/main.ts @@ -14,7 +14,7 @@ import { join, relative } from 'node:path' import { readFile } from 'node:fs/promises' import type { DiffArgs } from '../shared/types.ts' import { getDiff, validateDiffArgs } from './diff.ts' -import { getGitAttrs } from './attrs.ts' +import { resolveFileAttrs } from './attrs.ts' import { loadViewed, markViewed, unmarkViewed } from './viewed.ts' import { insertComment, removeComment } from './comment.ts' import { @@ -39,7 +39,7 @@ export async function startServer(opts: { const { patch, fileHashes } = await getDiff(diffSource) const [viewed, attrs] = await Promise.all([ loadViewed(cwd, fileHashes), - getGitAttrs(Object.keys(fileHashes), cwd), + resolveFileAttrs(Object.keys(fileHashes), cwd), ]) const fileSuffix = diffSource.files.length > 0 ? ` -- ${diffSource.files.join(' ')}` : '' From 2bfa4ea110dc9376b718f415df1f685b2b71e376 Mon Sep 17 00:00:00 2001 From: Daniel Rivas Perez Date: Thu, 23 Apr 2026 10:40:28 +0000 Subject: [PATCH 3/5] Port Linguist's content-based generated-file rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a batched git cat-file --batch reader that pulls the blob content for each file in the diff, then runs the content-based subset of generated.rb (source maps, protobuf variants, Haxe, Cython, gRPC, Sorbet RBI, ~30 more) to flag files that aren't caught by path rules. Content lookups only run for files where `generated` isn't already decided — check-attr wins, path rules win, content fills in. Also adds the sqlx-query path rule that was missing from M2. --- server/attrs.ts | 45 ++- server/linguist/contentReader.test.ts | 63 ++++ server/linguist/contentReader.ts | 113 ++++++ server/linguist/contentRules.test.ts | 303 ++++++++++++++++ server/linguist/contentRules.ts | 475 ++++++++++++++++++++++++++ server/linguist/pathRules.ts | 2 + server/main.ts | 2 +- 7 files changed, 996 insertions(+), 7 deletions(-) create mode 100644 server/linguist/contentReader.test.ts create mode 100644 server/linguist/contentReader.ts create mode 100644 server/linguist/contentRules.test.ts create mode 100644 server/linguist/contentRules.ts diff --git a/server/attrs.ts b/server/attrs.ts index da125a5..97cb22a 100644 --- a/server/attrs.ts +++ b/server/attrs.ts @@ -19,8 +19,10 @@ */ import { spawn } from 'node:child_process' -import type { FileAttrs } from '../shared/types.ts' +import type { FileAttrs, FileHashes } from '../shared/types.ts' import { classifyByPaths } from './linguist/pathRules.ts' +import { classifyByContent } from './linguist/contentRules.ts' +import { readBlobSummaries } from './linguist/contentReader.ts' /** * A `Partial` tracks which attributes a layer has decided on. @@ -38,17 +40,48 @@ const TRACKED_ATTRS = [ type TrackedAttr = (typeof TRACKED_ATTRS)[number] /** - * Resolve the full attribute map for a set of files by combining - * `.gitattributes` (highest priority) with Linguist's built-in path rules. + * Resolve the full attribute map for a diff by combining (highest priority + * first): + * + * 1. `.gitattributes` via `git check-attr`. + * 2. Linguist's built-in path rules. + * 3. Linguist's built-in content rules (reads blob contents via + * `git cat-file`; only runs where `generated` isn't already decided). + * * Files without any set attribute are omitted from the result. */ export async function resolveFileAttrs( - files: string[], + fileHashes: FileHashes, cwd: string, ): Promise> { + const files = Object.keys(fileHashes) if (files.length === 0) return {} - const [gitAttrs, pathAttrs] = [await getGitAttrs(files, cwd), classifyByPaths(files)] - return mergeAttrs([gitAttrs, pathAttrs]) + + const [gitAttrs, pathAttrs] = await Promise.all([ + getGitAttrs(files, cwd), + Promise.resolve(classifyByPaths(files)), + ]) + + // Only read content for files whose `generated` status isn't already + // decided. check-attr `unset` wins — we respect the user's override and + // skip content checks. + const needsContent: FileHashes = {} + for (const [path, hash] of Object.entries(fileHashes)) { + const git = gitAttrs[path]?.generated + const byPath = pathAttrs[path]?.generated + if (git === undefined && byPath !== true) needsContent[path] = hash + } + + const contentAttrs: PartialAttrMap = {} + if (Object.keys(needsContent).length > 0) { + const summaries = await readBlobSummaries(needsContent, cwd) + for (const [path, summary] of Object.entries(summaries)) { + const attrs = classifyByContent(path, summary) + if (Object.keys(attrs).length > 0) contentAttrs[path] = attrs + } + } + + return mergeAttrs([gitAttrs, pathAttrs, contentAttrs]) } /** diff --git a/server/linguist/contentReader.test.ts b/server/linguist/contentReader.test.ts new file mode 100644 index 0000000..0373854 --- /dev/null +++ b/server/linguist/contentReader.test.ts @@ -0,0 +1,63 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { describe, expect, it } from 'vitest' +import { parseCatFileBatch } from './contentReader.ts' + +function concat(...parts: (string | Buffer)[]): Buffer { + return Buffer.concat( + parts.map((p) => (typeof p === 'string' ? Buffer.from(p, 'utf-8') : p)), + ) +} + +describe('parseCatFileBatch', () => { + it('returns empty map for empty input', () => { + expect(parseCatFileBatch(Buffer.alloc(0))).toEqual(new Map()) + }) + + it('parses a single blob', () => { + const content = 'hello world\n' + const size = Buffer.byteLength(content, 'utf-8') + const buf = concat(`aabbccdd blob ${size}\n`, content, '\n') + const result = parseCatFileBatch(buf) + expect(result.get('aabbccdd')).toBe(content) + expect(result.size).toBe(1) + }) + + it('skips missing objects', () => { + const content = 'hi\n' + const size = Buffer.byteLength(content, 'utf-8') + const buf = concat('deadbeef missing\n', `cafebabe blob ${size}\n`, content, '\n') + const result = parseCatFileBatch(buf) + expect(result.size).toBe(1) + expect(result.get('cafebabe')).toBe(content) + }) + + it('parses multiple blobs in a row', () => { + const a = 'one\n' + const b = 'two lines\nhere\n' + const buf = concat( + `aaaaaaaa blob ${Buffer.byteLength(a, 'utf-8')}\n`, + a, + '\n', + `bbbbbbbb blob ${Buffer.byteLength(b, 'utf-8')}\n`, + b, + '\n', + ) + const result = parseCatFileBatch(buf) + expect(result.get('aaaaaaaa')).toBe(a) + expect(result.get('bbbbbbbb')).toBe(b) + }) + + it('handles content that embeds newlines', () => { + const content = 'line1\nline2\nline3' + const size = Buffer.byteLength(content, 'utf-8') + const buf = concat(`abc123 blob ${size}\n`, content, '\n') + expect(parseCatFileBatch(buf).get('abc123')).toBe(content) + }) +}) diff --git a/server/linguist/contentReader.ts b/server/linguist/contentReader.ts new file mode 100644 index 0000000..f53a965 --- /dev/null +++ b/server/linguist/contentReader.ts @@ -0,0 +1,113 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Read blob contents from the git object store in one batched subprocess, + * then summarize each blob as line arrays for the content-based Linguist + * rules. + * + * We use the blob object IDs that skepsis already extracts from the diff + * (the "newObjectId" side). Deleted files (all-zero hash) are skipped, + * and blobs that git reports as missing are silently omitted — the + * caller treats them as "no content signal." + */ + +import { spawn } from 'node:child_process' + +export interface BlobSummary { + /** All lines of the blob (split on \n — trailing empty string preserved). */ + lines: string[] +} + +export async function readBlobSummaries( + blobIdsByPath: Record, + cwd: string, +): Promise> { + const uniqueIds = new Set() + for (const id of Object.values(blobIdsByPath)) { + if (id && !/^0+$/.test(id)) uniqueIds.add(id) + } + if (uniqueIds.size === 0) return {} + + let blobs: Map + try { + blobs = await readBlobs([...uniqueIds], cwd) + } catch { + return {} + } + + const result: Record = {} + for (const [path, id] of Object.entries(blobIdsByPath)) { + const content = blobs.get(id) + if (content === undefined) continue + result[path] = { lines: content.split('\n') } + } + return result +} + +function readBlobs(blobIds: string[], cwd: string): Promise> { + return new Promise((resolve, reject) => { + const proc = spawn('git', ['cat-file', '--batch', '--buffer'], { cwd }) + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + proc.stdout.on('data', (d: Buffer) => stdoutChunks.push(d)) + proc.stderr.on('data', (d: Buffer) => stderrChunks.push(d)) + proc.on('error', reject) + proc.on('close', (code) => { + if (code !== 0) { + reject( + new Error( + `git cat-file exited ${code}: ${Buffer.concat(stderrChunks).toString()}`, + ), + ) + return + } + try { + resolve(parseCatFileBatch(Buffer.concat(stdoutChunks))) + } catch (e) { + reject(e) + } + }) + proc.stdin.end(blobIds.join('\n') + '\n') + }) +} + +/** + * Parse `git cat-file --batch` output. For each requested object id, git + * emits either: + * missing\n + * or: + * \n + * \n + * + * Exported for tests. + */ +export function parseCatFileBatch(buf: Buffer): Map { + const result = new Map() + const LF = 0x0a + let i = 0 + while (i < buf.length) { + const nl = buf.indexOf(LF, i) + if (nl === -1) break + const header = buf.slice(i, nl).toString('utf-8') + i = nl + 1 + const parts = header.split(' ') + const sha = parts[0] + if (!sha) break + if (parts[1] === 'missing') continue + const size = Number(parts[2]) + if (!Number.isFinite(size) || size < 0) break + if (i + size > buf.length) break + const content = buf.slice(i, i + size).toString('utf-8') + result.set(sha, content) + i += size + // Skip the trailing LF after the content, if present. + if (buf[i] === LF) i += 1 + } + return result +} diff --git a/server/linguist/contentRules.test.ts b/server/linguist/contentRules.test.ts new file mode 100644 index 0000000..410ef2d --- /dev/null +++ b/server/linguist/contentRules.test.ts @@ -0,0 +1,303 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { describe, expect, it } from 'vitest' +import { classifyByContent } from './contentRules.ts' +import type { BlobSummary } from './contentReader.ts' + +function summary(content: string): BlobSummary { + return { lines: content.split('\n') } +} + +function expectGenerated(path: string, content: string): void { + expect(classifyByContent(path, summary(content))).toEqual({ + generated: true, + }) +} + +function expectNotGenerated(path: string, content: string): void { + expect(classifyByContent(path, summary(content))).toEqual({}) +} + +describe('classifyByContent — minified', () => { + it('flags .js when avg line length > 110', () => { + const content = 'x'.repeat(500) + '\n' + 'y'.repeat(500) + expectGenerated('app.js', content) + }) + + it("doesn't flag short .js files", () => { + expectNotGenerated('src/app.js', 'const a = 1\nconst b = 2\n') + }) + + it("doesn't flag .ts even when long", () => { + // Minification detection is only for .js/.css per Linguist. + const content = 'x'.repeat(500) + '\n' + 'y'.repeat(500) + expectNotGenerated('app.ts', content) + }) +}) + +describe('classifyByContent — source map references and files', () => { + it('flags .js with trailing sourceMappingURL comment', () => { + expectGenerated('bundle.js', 'const x = 1\n//# sourceMappingURL=bundle.js.map\n') + }) + + it('flags .map files by name', () => { + expectGenerated('bundle.js.map', '{"version":3,"sources":["foo.js"]}') + expectGenerated('styles.css.map', '{"version":3}') + }) + + it('flags .map files by revision 2 header', () => { + expectGenerated('foo.map', '{"version":3,"sources":[]}') + }) + + it("doesn't flag random .map files without the header", () => { + expectNotGenerated('world.map', 'just some map data\n') + }) +}) + +describe('classifyByContent — compiled CoffeeScript', () => { + it('flags .js with "// Generated by" comment on the first line', () => { + expectGenerated('app.js', '// Generated by CoffeeScript 1.12.7\nconst x = 1') + }) + + it('flags via structural pattern + score threshold', () => { + const lines = [ + '(function() {', + ' var _fn, _i, _len, _ref, _results;', + ' var __bind = function() {};', + '}).call(this);', + '', + ] + expectGenerated('app.js', lines.join('\n')) + }) + + it("doesn't flag structurally-similar non-Coffee code", () => { + const lines = ['(function() {', ' const x = 1', '}).call(this);', ''] + expectNotGenerated('app.js', lines.join('\n')) + }) +}) + +describe('classifyByContent — generated .NET docfile', () => { + it('flags .xml with //', () => { + const content = [ + '', + '', + ' Foo', + ' ', + '', + '', + ].join('\n') + expectGenerated('Foo.xml', content) + }) +}) + +describe('classifyByContent — PEG.js parser', () => { + it('flags .js with PEG.js banner in first 5 lines', () => { + expectGenerated( + 'parser.js', + '/*\n * Generated by PEG.js 0.10.0.\n */\nmodule.exports = ...\n', + ) + }) +}) + +describe('classifyByContent — PostScript', () => { + it('flags .ps with ImageMagick Creator line', () => { + expectGenerated( + 'graph.ps', + '%!PS-Adobe-3.0\n%%Creator: ImageMagick 7.1.0\n%%Title: foo\n', + ) + }) + + it('flags .ps with eexec marker', () => { + expectGenerated('font.ps', 'currentfile eexec\nAABBCC\n') + }) +}) + +describe('classifyByContent — protobuf variants', () => { + it('flags .proto files from go-to-protobuf', () => { + expectGenerated( + 'foo.proto', + '// This file was autogenerated by go-to-protobuf. DO NOT EDIT.\nsyntax = "proto3";\n', + ) + }) + + it('flags protoc-generated C++/Java/Python', () => { + const banner = '// Generated by the protocol buffer compiler. DO NOT EDIT!\n' + expectGenerated('foo.pb.cc', banner + 'code;\n') + expectGenerated('Foo.java', banner + 'class Foo {}\n') + expectGenerated('foo_pb2.py', banner + 'print(1)\n') + }) + + it('flags protoc-generated JS at line 5', () => { + const content = [ + '// source: foo.proto', + '/**', + ' * @fileoverview', + ' * @enhanceable', + ' * @suppress {missingRequire}', + '// GENERATED CODE -- DO NOT EDIT!', + 'var foo = ...;', + ].join('\n') + expectGenerated('foo_pb.js', content) + }) + + it('flags ts-proto .ts on line 0', () => { + expectGenerated( + 'foo.ts', + '// Code generated by protoc-gen-ts_proto. DO NOT EDIT.\n// versions:\n// protoc-gen-ts_proto v1\n// source: hello.proto\n\nexport interface Foo {}\n', + ) + }) +}) + +describe('classifyByContent — Apache Thrift', () => { + it('flags files with Thrift banner in first 6 lines', () => { + const banner = '/**\n * Autogenerated by Thrift Compiler (0.15.0)\n *\n */\n' + expectGenerated('foo.java', banner + 'class Foo {}\n') + }) +}) + +describe('classifyByContent — JNI header', () => { + it('flags JNI-generated headers', () => { + const content = + '/* DO NOT EDIT THIS FILE - it is machine generated */\n#include \n/* Header for class com_Example */\n' + expectGenerated('com_Example.h', content) + }) +}) + +describe('classifyByContent — VCR cassette', () => { + it('flags .yml with VCR tail', () => { + const content = ['http_interactions: []', 'recorded_with: VCR 6.0.0', ''].join('\n') + expectGenerated('cassette.yml', content) + }) +}) + +describe('classifyByContent — Cython', () => { + it('flags .c generated by Cython', () => { + expectGenerated( + 'foo.c', + '/* Generated by Cython 0.29.21 */\n#define PY_SSIZE_T_CLEAN\n', + ) + }) +}) + +describe('classifyByContent — Unity3D / Racc / JFlex / GrammarKit / roxygen2', () => { + it('Unity3D .meta', () => { + expectGenerated('foo.meta', 'fileFormatVersion: 2\nguid: abc\n') + }) + it('Racc .rb', () => { + expectGenerated( + 'parser.rb', + '# frozen_string_literal: true\nrequire "racc"\n# This file is automatically generated by Racc 1.6.0\nmodule Parser\nend\n', + ) + }) + it('JFlex .java', () => { + expectGenerated( + 'Lexer.java', + '/* The following code was generated by JFlex 1.8.2 */\npublic class Lexer {}\n', + ) + }) + it('GrammarKit .java', () => { + expectGenerated( + 'Parser.java', + '// This is a generated file. Not intended for manual editing.\npublic class Parser {}\n', + ) + }) + it('roxygen2 .Rd', () => { + expectGenerated( + 'foo.Rd', + '% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/foo.R\n', + ) + }) +}) + +describe('classifyByContent — Go', () => { + it('flags .go with "Code generated ..." comment', () => { + expectGenerated( + 'zz_generated.go', + '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage foo\n', + ) + }) +}) + +describe('classifyByContent — gRPC C++', () => { + it('flags generated gRPC C++ headers', () => { + expectGenerated( + 'foo.grpc.pb.h', + '// Generated by the gRPC C++ plugin.\n// If you make any local changes they will be lost\n', + ) + }) +}) + +describe('classifyByContent — Dart', () => { + it('flags Dart with "GENERATED CODE - DO NOT MODIFY"', () => { + expectGenerated( + 'foo.g.dart', + 'part of foo;\n// GENERATED CODE - DO NOT MODIFY\n\npart foo {}\n', + ) + }) +}) + +describe('classifyByContent — Haxe output', () => { + it('flags Haxe output JS', () => { + expectGenerated('foo.js', '// Generated by Haxe 4.2.5\n(function() {})\n') + }) +}) + +describe('classifyByContent — generated HTML', () => { + it('flags pkgdown-generated HTML', () => { + expectGenerated( + 'index.html', + '\n\n\n', + ) + }) + it('flags Doxygen-generated HTML', () => { + const content = + '\n\n\n\n\n' + expectGenerated('class.html', content) + }) + it('flags groff-generated HTML via ', () => { + const content = + '\n\n\n\n' + expectGenerated('man.html', content) + }) +}) + +describe('classifyByContent — Sorbet RBI from Tapioca', () => { + it('flags typed:-prefixed RBI with Tapioca banner', () => { + const content = [ + '# typed: true', + '', + '# DO NOT EDIT MANUALLY', + '# This is an autogenerated file from Tapioca.', + '', + 'module Foo; end', + ].join('\n') + expectGenerated('foo.rbi', content) + }) + it("doesn't flag plain .rbi without banner", () => { + const content = '# typed: true\n\nmodule Foo; end\n' + expectNotGenerated('foo.rbi', content) + }) +}) + +describe('classifyByContent — MySQL view definition', () => { + it('flags .frm with TYPE=VIEW', () => { + expectGenerated('my_view.frm', 'TYPE=VIEW\nquery=SELECT 1\n') + }) +}) + +describe('classifyByContent — no match', () => { + it.each([ + ['src/App.tsx', 'import React from "react"\nexport const App = () => null\n'], + ['cli.ts', 'import { main } from "./main.ts"\nmain()\n'], + ['server/main.ts', 'export function startServer() {}\n'], + ['README.md', '# Title\n\nSome content.\n'], + ])('%s returns no attributes', (path, content) => { + expectNotGenerated(path, content) + }) +}) diff --git a/server/linguist/contentRules.ts b/server/linguist/contentRules.ts new file mode 100644 index 0000000..a0a6b8e --- /dev/null +++ b/server/linguist/contentRules.ts @@ -0,0 +1,475 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/** + * Linguist's content-based "generated" rules — the ones that need to + * inspect file contents (first/last lines, line counts, etc.) rather + * than filenames. + * + * Ported from: + * github-linguist/linguist @ 537297cdae3ab05f8d5dd1c03627a5bd73707b19 + * - lib/linguist/generated.rb + * + * Every rule only sets `generated` — Linguist's vendored and + * documentation checks are path-based and live in `pathRules.ts`. + */ + +import type { FileAttrs } from '../../shared/types.ts' +import type { BlobSummary } from './contentReader.ts' + +export function classifyByContent(path: string, summary: BlobSummary): Partial { + return isGenerated(path, summary) ? { generated: true } : {} +} + +function isGenerated(path: string, s: BlobSummary): boolean { + return ( + minified(path, s) || + hasSourceMapRef(path, s) || + isSourceMap(path, s) || + compiledCoffeescript(path, s) || + generatedNetDocfile(path, s) || + generatedParser(path, s) || + generatedPostscript(path, s) || + generatedGo(path, s) || + generatedProtocolBufferFromGo(path, s) || + generatedProtocolBuffer(path, s) || + generatedJavascriptProtocolBuffer(path, s) || + generatedTypescriptProtocolBuffer(path, s) || + generatedApacheThrift(path, s) || + generatedJniHeader(path, s) || + vcrCassette(path, s) || + generatedAntlr(path, s) || + compiledCythonFile(path, s) || + generatedModule(path, s) || + generatedUnity3dMeta(path, s) || + generatedRacc(path, s) || + generatedJflex(path, s) || + generatedGrammarkit(path, s) || + generatedRoxygen2(path, s) || + generatedJison(path, s) || + generatedGrpcCpp(path, s) || + generatedDart(path, s) || + generatedPerlPpportHeader(path, s) || + generatedGamemakerstudio(path, s) || + generatedGimp(path, s) || + generatedVisualstudio6(path, s) || + generatedHaxe(path, s) || + generatedHtml(path, s) || + generatedJooq(path, s) || + generatedSorbetRbi(path, s) || + generatedMysqlViewDefinitionFormat(path, s) + ) +} + +// --- Path helpers --- + +function extname(path: string): string { + const slash = path.lastIndexOf('/') + const base = slash === -1 ? path : path.slice(slash + 1) + const dot = base.lastIndexOf('.') + return dot === -1 ? '' : base.slice(dot) +} + +function basename(path: string): string { + const slash = path.lastIndexOf('/') + return slash === -1 ? path : path.slice(slash + 1) +} + +// --- Rule implementations (preserving Ruby source order where practical) --- + +const MAYBE_MINIFIED = new Set(['.js', '.css']) + +function maybeMinified(path: string): boolean { + return MAYBE_MINIFIED.has(extname(path).toLowerCase()) +} + +// minified_files? +function minified(path: string, s: BlobSummary): boolean { + if (!maybeMinified(path)) return false + if (s.lines.length === 0) return false + const total = s.lines.reduce((acc, l) => acc + l.length, 0) + return total / s.lines.length > 110 +} + +// has_source_map? +function hasSourceMapRef(path: string, s: BlobSummary): boolean { + if (!maybeMinified(path)) return false + const tail = s.lines.slice(-2) + // eslint-disable-next-line unicorn/prefer-string-starts-ends-with + return tail.some((l) => /^\/[*/][#@] source(?:Mapping)?URL|sourceURL=/.test(l)) +} + +// source_map? +function isSourceMap(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.map') return false + if (/(\.css|\.js)\.map$/i.test(path)) return true + const first = s.lines[0] ?? '' + // eslint-disable-next-line unicorn/prefer-string-starts-ends-with + return /^{"version":\d+,/.test(first) || first.startsWith('/** Begin line maps. **/{') +} + +// compiled_coffeescript? +function compiledCoffeescript(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.js') return false + const first = s.lines[0] ?? '' + if (first.startsWith('// Generated by ')) return true + if (s.lines.length < 3) return false + if (first !== '(function() {') return false + if (s.lines[s.lines.length - 2] !== '}).call(this);') return false + if (s.lines[s.lines.length - 1] !== '') return false + + let score = 0 + for (const line of s.lines) { + if (!/var /.test(line)) continue + score += countMatches(line, /_fn|_i|_len|_ref|_results/g) + score += 3 * countMatches(line, /__bind|__extends|__hasProp|__indexOf|__slice/g) + } + return score >= 3 +} + +function countMatches(s: string, re: RegExp): number { + return (s.match(re) ?? []).length +} + +// generated_net_docfile? +function generatedNetDocfile(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.xml') return false + if (s.lines.length <= 3) return false + return ( + (s.lines[1]?.includes('') ?? false) && + (s.lines[2]?.includes('') ?? false) && + (s.lines[s.lines.length - 2]?.includes('') ?? false) + ) +} + +// generated_parser? (PEG.js) +function generatedParser(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.js') return false + const head = s.lines.slice(0, 5).join('') + return /^(?:[^/]|\/[^*])*\/\*(?:[^*]|\*[^/])*Generated by PEG\.js/.test(head) +} + +// generated_postscript? +function generatedPostscript(path: string, s: BlobSummary): boolean { + if (!['.ps', '.eps', '.pfa'].includes(extname(path))) return false + + // Full content check for the two hex-stream markers. + const joined = s.lines.join('\n') + if (/^\s*(?:currentfile eexec\s+|\/sfnts\s+\[\s<)/m.test(joined)) return true + + const head = s.lines.slice(0, 10) + const creator = head.find((l) => l.startsWith('%%Creator: ')) + if (!creator) return false + + if ( + /[0-9]|draw|mpage|ImageMagick|inkscape|MATLAB/.test(creator) || + /PCBNEW|pnmtops|\(Unknown\)|Serif Affinity|Filterimage -tops/.test(creator) + ) { + return true + } + + // EAGLE check + if (creator.includes('EAGLE')) { + return s.lines.slice(0, 5).some((l) => l.startsWith('%%Title: EAGLE Drawing ')) + } + return false +} + +// generated_go? +function generatedGo(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.go') return false + if (s.lines.length <= 1) return false + return s.lines.slice(0, 40).some((l) => /^\/\/ Code generated .*/.test(l)) +} + +// generated_protocol_buffer_from_go? +function generatedProtocolBufferFromGo(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.proto') return false + if (s.lines.length <= 1) return false + return s.lines + .slice(0, 20) + .some((l) => l.includes('This file was autogenerated by go-to-protobuf')) +} + +const PROTOBUF_EXTS = new Set(['.py', '.java', '.h', '.cc', '.cpp', '.m', '.rb', '.php']) + +// generated_protocol_buffer? +function generatedProtocolBuffer(path: string, s: BlobSummary): boolean { + if (!PROTOBUF_EXTS.has(extname(path))) return false + if (s.lines.length <= 1) return false + return s.lines + .slice(0, 3) + .some((l) => l.includes('Generated by the protocol buffer compiler. DO NOT EDIT!')) +} + +// generated_javascript_protocol_buffer? +function generatedJavascriptProtocolBuffer(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.js') return false + if (s.lines.length <= 6) return false + return s.lines[5]?.includes('GENERATED CODE -- DO NOT EDIT!') ?? false +} + +// generated_typescript_protocol_buffer? +function generatedTypescriptProtocolBuffer(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.ts') return false + if (s.lines.length <= 4) return false + return ( + s.lines[0]?.includes('Code generated by protoc-gen-ts_proto. DO NOT EDIT.') ?? false + ) +} + +const APACHE_THRIFT_EXTS = new Set([ + '.rb', + '.py', + '.go', + '.js', + '.m', + '.java', + '.h', + '.cc', + '.cpp', + '.php', +]) + +// generated_apache_thrift? +function generatedApacheThrift(path: string, s: BlobSummary): boolean { + if (!APACHE_THRIFT_EXTS.has(extname(path))) return false + return s.lines.slice(0, 6).some((l) => l.includes('Autogenerated by Thrift Compiler')) +} + +// generated_jni_header? +function generatedJniHeader(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.h') return false + if (s.lines.length <= 2) return false + return ( + (s.lines[0]?.includes('/* DO NOT EDIT THIS FILE - it is machine generated */') ?? + false) && + (s.lines[1]?.includes('#include ') ?? false) + ) +} + +// vcr_cassette? +function vcrCassette(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.yml') return false + if (s.lines.length <= 2) return false + return s.lines[s.lines.length - 2]?.includes('recorded_with: VCR') ?? false +} + +// generated_antlr? +function generatedAntlr(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.g') return false + if (s.lines.length <= 2) return false + return s.lines[1]?.includes('generated by Xtest') ?? false +} + +// compiled_cython_file? +function compiledCythonFile(path: string, s: BlobSummary): boolean { + if (!['.c', '.cpp'].includes(extname(path))) return false + if (s.lines.length <= 1) return false + return s.lines[0]?.includes('Generated by Cython') ?? false +} + +// generated_module? +function generatedModule(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.mod') return false + if (s.lines.length <= 1) return false + const first = s.lines[0] ?? '' + return first.includes('PCBNEW-LibModule-V') || first.includes("GFORTRAN module version '") +} + +// generated_unity3d_meta? +function generatedUnity3dMeta(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.meta') return false + if (s.lines.length <= 1) return false + return s.lines[0]?.includes('fileFormatVersion: ') ?? false +} + +// generated_racc? +function generatedRacc(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.rb') return false + if (s.lines.length <= 2) return false + return s.lines[2]?.startsWith('# This file is automatically generated by Racc') ?? false +} + +// generated_jflex? +function generatedJflex(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.java') return false + if (s.lines.length <= 1) return false + return s.lines[0]?.startsWith('/* The following code was generated by JFlex ') ?? false +} + +// generated_grammarkit? +function generatedGrammarkit(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.java') return false + if (s.lines.length <= 1) return false + return ( + s.lines[0]?.startsWith( + '// This is a generated file. Not intended for manual editing.', + ) ?? false + ) +} + +// generated_roxygen2? +function generatedRoxygen2(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.Rd') return false + if (s.lines.length <= 1) return false + return s.lines[0]?.includes('% Generated by roxygen2: do not edit by hand') ?? false +} + +// generated_jison? +function generatedJison(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.js') return false + if (s.lines.length <= 1) return false + const first = s.lines[0] ?? '' + return ( + first.startsWith('/* parser generated by jison ') || + first.startsWith('/* generated by jison-lex ') + ) +} + +const GRPC_CPP_EXTS = new Set(['.cpp', '.hpp', '.h', '.cc']) + +// generated_grpc_cpp? +function generatedGrpcCpp(path: string, s: BlobSummary): boolean { + if (!GRPC_CPP_EXTS.has(extname(path))) return false + if (s.lines.length <= 1) return false + return s.lines[0]?.startsWith('// Generated by the gRPC') ?? false +} + +// generated_dart? +function generatedDart(path: string, s: BlobSummary): boolean { + if (extname(path) !== '.dart') return false + if (s.lines.length <= 1) return false + return s.lines.slice(0, 3).some((l) => /generated code\W{2,3}do not modify/i.test(l)) +} + +// generated_perl_ppport_header? +function generatedPerlPpportHeader(path: string, s: BlobSummary): boolean { + if (!basename(path).endsWith('ppport.h')) return false + if (s.lines.length <= 10) return false + return s.lines[8]?.includes('Automatically created by Devel::PPPort') ?? false +} + +// generated_gamemakerstudio? +function generatedGamemakerstudio(path: string, s: BlobSummary): boolean { + if (!['.yy', '.yyp'].includes(extname(path))) return false + if (s.lines.length <= 3) return false + const joined = s.lines.slice(0, 3).join('') + return /^\s*[{[]/.test(joined) || /^\d\.\d\.\d.+\|\{/.test(s.lines[0] ?? '') +} + +// generated_gimp? +function generatedGimp(path: string, s: BlobSummary): boolean { + if (!['.c', '.h'].includes(extname(path))) return false + if (s.lines.length === 0) return false + const first = s.lines[0] ?? '' + return ( + /^\/\* GIMP [a-zA-Z0-9\- ]+ C-Source image dump \(.+?\.c\) \*\//.test(first) || + /^\/\* GIMP header image file format \([a-zA-Z0-9\- ]+\): .+?\.h \*\//.test(first) + ) +} + +// generated_visualstudio6? +function generatedVisualstudio6(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.dsp') return false + return s.lines + .slice(0, 3) + .some((l) => l.includes('# Microsoft Developer Studio Generated Build File')) +} + +const HAXE_EXTS = new Set(['.js', '.py', '.lua', '.cpp', '.h', '.java', '.cs', '.php']) + +// generated_haxe? +function generatedHaxe(path: string, s: BlobSummary): boolean { + if (!HAXE_EXTS.has(extname(path))) return false + return s.lines.slice(0, 3).some((l) => l.includes('Generated by Haxe')) +} + +// generated_html? +function generatedHtml(path: string, s: BlobSummary): boolean { + if (!['.html', '.htm', '.xhtml'].includes(extname(path).toLowerCase())) { + return false + } + if (s.lines.length <= 1) return false + + const head = s.lines.slice(0, 31) + + // pkgdown marker (first 2 lines) + if ( + head + .slice(0, 2) + .some((l) => //.test(l)) + ) { + return true + } + // Mandoc marker (line 2) + if ( + s.lines.length > 2 && + s.lines[2]?.startsWith('/i.test(l))) { + return true + } + + // style + const joined = head.join(' ') + const metaMatches = [...joined.matchAll(/]+)>/gi)] + if (metaMatches.length === 0) return false + return metaMatches.some((m) => { + const attrs = extractHtmlMeta(m[1] ?? '') + if ((attrs['name'] ?? '').toLowerCase() !== 'generator') return false + const cv = [attrs['content'], attrs['value']].filter((v): v is string => !!v) + return cv.some((v) => + /^\s*(?:org\s+mode|j?latex2html|groff|makeinfo|texi2html|ronn)\b/i.test(v), + ) + }) +} + +function extractHtmlMeta(attrStr: string): Record { + const result: Record = {} + const cleaned = attrStr.replace(/\/\s*$/, '').trim() + const re = /(?<=^|\s)(name|content|value)\s*=\s*("[^"]+"|'[^']+'|[^\s"']+)/gi + for (const m of cleaned.matchAll(re)) { + const key = m[1]!.toLowerCase() + let val = m[2]! + val = val.replace(/^["']|["']$/g, '') + result[key] = val + } + return result +} + +// generated_jooq? +function generatedJooq(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.java') return false + return s.lines.slice(0, 2).some((l) => l.includes('This file is generated by jOOQ.')) +} + +// generated_sorbet_rbi? (Tapioca) +function generatedSorbetRbi(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.rbi') return false + if (s.lines.length < 5) return false + if (!(s.lines[0] ?? '').startsWith('# typed:')) return false + // Ruby's implementation checks for Tapioca's standard header in the next + // few lines. See the source; we accept any of the three Tapioca variants. + return s.lines + .slice(1, 5) + .some((l) => + /DO NOT EDIT MANUALLY|This is an autogenerated file|DO NOT EDIT: This file is automatically generated/i.test( + l, + ), + ) +} + +// generated_mysql_view_definition_format? +function generatedMysqlViewDefinitionFormat(path: string, s: BlobSummary): boolean { + if (extname(path).toLowerCase() !== '.frm') return false + return s.lines[0]?.includes('TYPE=VIEW') ?? false +} diff --git a/server/linguist/pathRules.ts b/server/linguist/pathRules.ts index 359ec2e..35e1c52 100644 --- a/server/linguist/pathRules.ts +++ b/server/linguist/pathRules.ts @@ -95,6 +95,8 @@ const GENERATED_PATH_PATTERNS: string[] = [ // maven_wrapper? (case-insensitive — handled below) // htmlcov? '(?:^|/)htmlcov/', + // generated_sqlx_query? + '(?:^|/)\\.sqlx/query-[a-f\\d]{64}\\.json$', ] const GENERATED_PATH_PATTERNS_CI: string[] = [ diff --git a/server/main.ts b/server/main.ts index 6aacd8e..8dea0a0 100644 --- a/server/main.ts +++ b/server/main.ts @@ -39,7 +39,7 @@ export async function startServer(opts: { const { patch, fileHashes } = await getDiff(diffSource) const [viewed, attrs] = await Promise.all([ loadViewed(cwd, fileHashes), - resolveFileAttrs(Object.keys(fileHashes), cwd), + resolveFileAttrs(fileHashes, cwd), ]) const fileSuffix = diffSource.files.length > 0 ? ` -- ${diffSource.files.join(' ')}` : '' From 5882f09ef2864a98ee7370c2435852583131d35b Mon Sep 17 00:00:00 2001 From: Daniel Rivas Perez Date: Thu, 23 Apr 2026 11:19:04 +0000 Subject: [PATCH 4/5] Auto-collapse generated, vendored, and binary files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeds the initial collapsed state for any file flagged by the new attrs field (matching how viewed files already collapse), and drops those files from the ProgressBar denominator so the count reflects only the files the reviewer is expected to look at. Documentation files are not auto-collapsed — they're usually worth reading, so only generated/vendored/binary collapse. --- .../{index-DP_ri0XS.js => index-Dxy9xqb9.js} | 2 +- dist/index.html | 2 +- src/App.tsx | 44 ++++++++++++++----- 3 files changed, 36 insertions(+), 12 deletions(-) rename dist/assets/{index-DP_ri0XS.js => index-Dxy9xqb9.js} (98%) diff --git a/dist/assets/index-DP_ri0XS.js b/dist/assets/index-Dxy9xqb9.js similarity index 98% rename from dist/assets/index-DP_ri0XS.js rename to dist/assets/index-Dxy9xqb9.js index 443f370..92eb0b8 100644 --- a/dist/assets/index-DP_ri0XS.js +++ b/dist/assets/index-Dxy9xqb9.js @@ -1891,4 +1891,4 @@ XID_Start XIDS`.split(/\s/).map(e=>[Fl(e),e])),Su=new Map([[`s`,J(383)],[J(383), color: var(--diffs-fg) !important; } [data-review-comment] [data-gutter-utility-slot] { display: none !important; } - `,e.appendChild(t)}function qm(e,t){let n=e.querySelector(`diffs-container`)?.shadowRoot;if(!n)return;let r=n.querySelector(`pre`);if(!r)return;Km(n);for(let e of n.querySelectorAll(`[data-review-comment]`))e.removeAttribute(`data-review-comment`);if(t.length===0)return;let i=e=>t.some(t=>e>=t.start&&e<=t.end);for(let e of Array.from(r.children)){if(!(e instanceof HTMLElement)||!e.hasAttribute(`data-code`)||e.hasAttribute(`data-deletions`))continue;let t=e.querySelector(`[data-gutter]`),n=e.querySelector(`[data-content]`);if(!(!t||!n))for(let e=0;e{o.current?.focus()},[]);let s=()=>{let t=i.trim();!t||n||e(t)};return(0,j.jsxs)(`div`,{className:`comment-form`,onClick:e=>e.stopPropagation(),children:[(0,j.jsx)(`textarea`,{ref:o,value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.metaKey||e.ctrlKey)&&(e.preventDefault(),s()),e.key===`Escape`&&t()},placeholder:`Leave a review comment... (Cmd+Enter to submit)`,rows:3,disabled:n}),(0,j.jsxs)(`div`,{className:`comment-form-actions`,children:[r&&(0,j.jsx)(`div`,{className:`comment-form-error`,children:r}),(0,j.jsx)(Jm,{onClick:t,disabled:n,children:`Cancel`}),(0,j.jsx)(Jm,{variant:`primary`,disabled:!i.trim()||n,onClick:s,children:`Comment`})]})]})}var Xm=20,Zm=!1,Qm=(0,A.memo)(function({fileDiff:e,diffStyle:t,lineAnnotations:n,renderAnnotation:r,onGutterUtilityClick:i,commentsEnabled:a}){return(0,j.jsx)(`div`,{ref:(0,A.useCallback)(n=>{Zm||!n||requestAnimationFrame(()=>{let r=t===`split`?e.splitLineCount:e.unifiedLineCount;r>0&&n.offsetHeight>0&&(Xm=n.offsetHeight/r,Zm=!0)})},[e,t]),children:(0,j.jsx)(wm,{fileDiff:e,options:{theme:`github-dark-default`,diffStyle:t,diffIndicators:`classic`,hunkSeparators:`line-info-basic`,overflow:`wrap`,disableFileHeader:!0,enableGutterUtility:a,onGutterUtilityClick:i},lineAnnotations:n,renderAnnotation:r})})},(e,t)=>e.fileDiff===t.fileDiff&&e.diffStyle===t.diffStyle&&e.lineAnnotations===t.lineAnnotations);function $m({fileDiff:e,isViewed:t,isStale:n,diffStyle:r,collapsed:i,composing:a,focused:o,onToggleCollapse:s,onToggleViewed:c,onStartComment:l,onSubmitComment:u,onResolveComment:d,onCancelComment:f,commentsEnabled:p,submitting:m,submitError:h}){let{additions:g,deletions:_}=zm(e),v=e.name,y=(0,A.useMemo)(()=>{if(!p)return[];let t=Rm(e,v);return a&&t.push({side:`additions`,lineNumber:a.line,metadata:{type:`composing`,file:v}}),t},[e,v,a,p]),b=(0,A.useCallback)(e=>{let t=e.metadata;return t.type===`review`?(0,j.jsx)(`div`,{className:`review-annotation`,children:(0,j.jsx)(Jm,{onClick:()=>d(t.startLine),children:`Resolve comment`})}):t.type===`composing`?(0,j.jsx)(Ym,{onSubmit:t=>u(e.lineNumber,t),onCancel:f,submitting:m,error:h}):null},[d,u,f,m,h]),x=(0,A.useCallback)(e=>{!p||e.side!==`additions`||y.some(t=>t.metadata.type===`review`&&e.start>=t.metadata.startLine&&e.start<=t.metadata.endLine)||l(e.start)},[l,p,y]),S=(0,A.useRef)(null),[C,w]=(0,A.useState)(!1);(0,A.useEffect)(()=>{let e=S.current;if(!e)return;let t=new IntersectionObserver(e=>{e[0]?.isIntersecting&&(w(!0),t.disconnect())},{rootMargin:`2000px`});return t.observe(e),()=>t.disconnect()},[]);let ee=C&&!i;(0,A.useEffect)(()=>{if(!ee)return;let e=S.current;if(!e)return;let t=y.flatMap(e=>e.metadata.type===`review`?[{start:e.metadata.startLine,end:e.metadata.endLine}]:[]),n=0,r=null,i=0,a=()=>qm(e,t),o=()=>{let t=(e.querySelector(`diffs-container`)?.shadowRoot)?.querySelector(`pre`);if(!t){i++<60&&(n=requestAnimationFrame(o));return}a(),r=new MutationObserver(a),r.observe(t,{childList:!0,subtree:!0})};return n=requestAnimationFrame(o),()=>{cancelAnimationFrame(n),r?.disconnect()}},[y,ee,r,e]);let T=(r===`split`?e.splitLineCount:e.unifiedLineCount)*Xm;return(0,j.jsxs)(`div`,{ref:S,className:`file-card`+(o?` focused`:``),children:[(0,j.jsxs)(`div`,{className:`file-header`,onClick:s,children:[(0,j.jsx)(`span`,{className:`collapse-chevron`+(i?` collapsed`:``),children:`▶`}),(0,j.jsx)(`span`,{className:`file-header-name`,children:v}),(0,j.jsxs)(`span`,{className:`file-header-stats`,children:[g>0&&(0,j.jsxs)(`span`,{className:`stat-add`,children:[`+`,g]}),_>0&&(0,j.jsxs)(`span`,{className:`stat-del`,children:[`-`,_]})]}),(0,j.jsxs)(`button`,{type:`button`,className:`viewed-button`+(n?` stale`:``)+(t?` checked`:``),onClick:e=>{e.stopPropagation(),c()},children:[(0,j.jsx)(`input`,{type:`checkbox`,className:`viewed-checkbox`+(n?` stale`:``),checked:t,readOnly:!0,tabIndex:-1}),n?`Changed`:`Viewed`]})]}),ee?(0,j.jsx)(Qm,{fileDiff:e,diffStyle:r,lineAnnotations:y,renderAnnotation:b,onGutterUtilityClick:x,commentsEnabled:p}):!i&&(0,j.jsx)(`div`,{style:{height:T}})]})}function eh({fileHashes:e,viewed:t}){let n=Object.keys(e).length,r=Object.entries(e).filter(([e,n])=>t[e]===n).length;return n===0?null:(0,j.jsxs)(`div`,{className:`progress-bar`,children:[(0,j.jsxs)(`span`,{children:[r,`/`,n,` files viewed`]}),(0,j.jsx)(`div`,{className:`progress-track`,children:(0,j.jsx)(`div`,{className:`progress-fill`,style:{width:`${r/n*100}%`}})})]})}function th({onClose:e,children:t}){let n=(0,A.useRef)(null);return(0,A.useEffect)(()=>n.current?.showModal(),[]),(0,j.jsx)(`dialog`,{ref:n,className:`help-modal`,onClose:e,onClick:e=>{e.target===e.currentTarget&&n.current?.close()},children:t})}var nh=[[`j / k`,`Next / previous line`],[`n / p`,`Next / previous file`],[`v`,`Toggle viewed`],[`e / E`,`Toggle collapse file / all files`],[`s`,`Toggle split mode (responsive / unified)`],[`c`,`Comment on line`],[`Esc`,`Close / cancel`],[`?`,`Toggle this help`]];function rh({onClose:e}){return(0,j.jsxs)(th,{onClose:e,children:[(0,j.jsx)(`h3`,{children:`Keyboard Shortcuts`}),(0,j.jsx)(`table`,{children:(0,j.jsx)(`tbody`,{children:nh.map(([e,t])=>(0,j.jsxs)(`tr`,{children:[(0,j.jsx)(`td`,{children:e.split(` / `).map((e,t)=>(0,j.jsxs)(`span`,{children:[t>0&&` / `,(0,j.jsx)(`kbd`,{children:e})]},e))}),(0,j.jsx)(`td`,{children:t})]},e))})})]})}function ih({onClose:e,vcs:t}){return(0,j.jsxs)(th,{onClose:e,children:[(0,j.jsxs)(`p`,{children:[`Comments work by inserting lines into the code. This can only work if the`,` `,t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`revset includes `,(0,j.jsx)(`code`,{children:`@`})]}):`commit range includes the working copy`,`. Otherwise, the inserted lines will land in`,` `,t===`jj`?(0,j.jsx)(`code`,{children:`@`}):`the working copy`,` but not in the diff being viewed.`]}),t===`jj`?(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with the`,` `,(0,j.jsx)(`code`,{children:`-f`}),` flag to get a diff that starts at some revision and ends at`,` `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with `,(0,j.jsx)(`code`,{children:`-f`}),` `,`only (no `,(0,j.jsx)(`code`,{children:`-t`}),`), so the diff goes from a commit to the working tree.`]})]})}function ah({onLearnMore:e,vcs:t}){return(0,j.jsxs)(`div`,{className:`comments-disabled-banner`,children:[t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`Comments are disabled because the revset does not include `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):`Comments are disabled because the commit range does not end at the working copy.`,` `,(0,j.jsx)(`a`,{href:`#`,onClick:t=>{t.preventDefault(),e()},children:`Learn more`})]})}var oh=[`responsive`,`unified`];function sh(){let e=Im(),[t,n]=(0,A.useState)(`responsive`),r=t===`responsive`&&e?`split`:`unified`,{toast:i,showToast:a}=Lm(),[o,s]=(0,A.useState)({}),[c,l]=(0,A.useState)(null),u=(0,A.useRef)(!1),{data:d,error:f,isLoading:p}=ct({queryKey:[`diff`],queryFn:()=>Pm(`/api/diff`)});(0,A.useEffect)(()=>{d?.revset&&(document.title=`skepsis | ${d.revset}`)},[d?.revset]),(0,A.useEffect)(()=>{if(u.current||!d?.fileHashes)return;u.current=!0;let e={};for(let[t,n]of Object.entries(d.fileHashes))d.viewed[t]===n&&(e[t]=!0);s(e)},[d]);let m=qe(),h=lt({mutationFn:({file:e,hash:t,mark:n})=>Pm(`/api/viewed`,{method:n?`POST`:`DELETE`,body:{file:e,hash:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),g=lt({mutationFn:({file:e,afterLine:t,text:n})=>Pm(`/api/comment`,{method:`POST`,body:{file:e,afterLine:t,text:n}}),onSuccess:()=>l(null),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),_=lt({mutationFn:({file:e,line:t})=>Pm(`/api/comment`,{method:`DELETE`,body:{file:e,line:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),v=d?.patch,y=(0,A.useMemo)(()=>v?qp(v).flatMap(e=>e.files):[],[v]),b=(0,A.useMemo)(()=>{let e={...d?.viewed};if(h.isPending&&h.variables){let{file:t,hash:n,mark:r}=h.variables;r?e[t]=n:delete e[t]}return e},[d,h.isPending,h.variables]),[x,S]=(0,A.useState)(0),[C,w]=(0,A.useState)(0),[ee,T]=(0,A.useState)(!1),[te,ne]=(0,A.useState)(!1),re=(0,A.useRef)(null),ie=(0,A.useRef)(0),ae=(0,A.useRef)({focusedFileIdx:0,cursorIdx:0,showHelp:!1,composing:null,files:[],collapsed:{},fileHashes:{},viewed:{},markMutate:h.mutate});ae.current={focusedFileIdx:x,cursorIdx:C,showHelp:ee,composing:c,files:y,collapsed:o,fileHashes:d?.fileHashes??{},viewed:b,markMutate:h.mutate},(0,A.useLayoutEffect)(()=>{Wm();let e=re.current;re.current=null,e===`file`&&document.querySelectorAll(`.file-card`)[x]?.scrollIntoView({block:`start`});let t=y[x];if(!t||(o[t.name]??!1))return;let n=Bm(x),r=n[Math.min(C,Math.max(n.length-1,0))];if(!r)return;let i=r.gutterEl.getRootNode();i instanceof ShadowRoot&&Gm(i),r.gutterEl.setAttribute(`data-selected-line`,`single`),r.contentEl.setAttribute(`data-selected-line`,`single`),e===`line`&&r.contentEl.scrollIntoView({block:`nearest`})},[x,C,v,c,o,y]),(0,A.useEffect)(()=>{function e(e){if(e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLInputElement||e.ctrlKey||e.metaKey||e.altKey)return;let t=ae.current;switch(e.key){case`?`:e.preventDefault(),T(e=>!e);break;case`Escape`:t.showHelp?(e.preventDefault(),T(!1)):t.composing&&(e.preventDefault(),l(null));break;case`n`:case`p`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=e.key===`n`?1:-1,r=Math.max(0,Math.min(t.focusedFileIdx+n,t.files.length-1));r!==t.focusedFileIdx&&(ie.current=Date.now(),S(r),w(0),re.current=`file`);break}case`j`:case`k`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx]?.name;if(!n||(t.collapsed[n]??!1))break;let r=Bm(t.focusedFileIdx);if(r.length===0)break;let i=t.cursorIdx,a=r[Math.min(i,r.length-1)];if(a&&!Um(a.contentEl,t.focusedFileIdx)){let e=Hm(t.focusedFileIdx);e>=0&&(i=e)}let o=Math.max(0,Math.min(i+(e.key===`j`?1:-1),r.length-1));o!==t.cursorIdx&&(ie.current=Date.now(),w(o),re.current=`line`);break}case`v`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;let r=t.fileHashes[n.name];if(!r)break;let i=t.viewed[n.name]!==r;s(e=>({...e,[n.name]:i})),t.markMutate({file:n.name,hash:r,mark:i});break}case`e`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;ie.current=Date.now(),s(e=>({...e,[n.name]:!(e[n.name]??!1)}));break}case`E`:{if(t.showHelp||t.composing)break;e.preventDefault(),ie.current=Date.now();let n=t.files.some(e=>!(t.collapsed[e.name]??!1));s(Object.fromEntries(t.files.map(e=>[e.name,n])));break}case`s`:if(t.showHelp||t.composing)break;e.preventDefault(),n(e=>{let t=oh[(oh.indexOf(e)+1)%oh.length];return a((0,j.jsxs)(j.Fragment,{children:[`Split mode: `,(0,j.jsx)(`code`,{children:t})]})),t});break;case`c`:{if(!d?.commentsEnabled||t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n||(t.collapsed[n.name]??!1))break;let r=Bm(t.focusedFileIdx)[t.cursorIdx];if(!r||r.lineType===`change-deletion`)break;l({file:n.name,line:r.lineNumber});break}}}return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[a,d?.commentsEnabled]);let oe=(0,A.useRef)(new Set);if((0,A.useEffect)(()=>{oe.current.clear();let e=new IntersectionObserver(e=>{for(let t of e){let e=Number(t.target.dataset.fileIdx);t.isIntersecting?oe.current.add(e):oe.current.delete(e)}if(Date.now()-ie.current<150||oe.current.size===0)return;let t=Math.min(...oe.current),n=ae.current;if(t!==n.focusedFileIdx){S(t);let e=n.files[t];if(e&&!(n.collapsed[e.name]??!1)){let e=Hm(t);e>=0&&w(e)}}},{threshold:0});return document.querySelectorAll(`.file-card`).forEach((t,n)=>{t.dataset.fileIdx=String(n),e.observe(t)}),()=>e.disconnect()},[y]),p||!d)return(0,j.jsx)(`div`,{className:`empty-diff`,children:`Loading...`});if(f)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:String(f)});if(d.error)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:d.error});if(!v)return(0,j.jsxs)(`div`,{className:`empty-diff`,children:[(0,j.jsx)(`code`,{children:d.revset}),` is empty`]});let{fileHashes:se}=d;return(0,j.jsxs)(j.Fragment,{children:[!d.commentsEnabled&&(0,j.jsx)(ah,{vcs:d.vcs,onLearnMore:()=>ne(!0)}),(0,j.jsxs)(`div`,{className:`diff-container`,children:[(0,j.jsx)(eh,{fileHashes:se,viewed:b}),ee&&(0,j.jsx)(rh,{onClose:()=>T(!1)}),te&&(0,j.jsx)(ih,{vcs:d.vcs,onClose:()=>ne(!1)}),i&&(0,j.jsx)(`div`,{className:`toast`,children:i.content},i.key),y.map((e,t)=>{let n=e.name,i=se[n],a=b[n],u=i!=null&&a===i,f=a!=null&&i!=null&&a!==i,p=o[n]??!1;return(0,j.jsx)($m,{fileDiff:e,isViewed:u,isStale:f,diffStyle:r,collapsed:p,composing:c?.file===n?{line:c.line}:null,focused:t===x,onToggleCollapse:()=>s(e=>({...e,[n]:!p})),onToggleViewed:()=>{if(i){let e=!u;s(t=>({...t,[n]:e})),h.mutate({file:n,hash:i,mark:e})}},onStartComment:e=>{g.reset(),l({file:n,line:e})},onSubmitComment:(e,t)=>g.mutate({file:n,afterLine:e,text:t}),onResolveComment:e=>_.mutate({file:n,line:e}),onCancelComment:()=>l(null),commentsEnabled:d.commentsEnabled,submitting:g.isPending&&g.variables?.file===n,submitError:g.variables?.file===n&&g.error?g.error.message:null},n??t)})]})]})}function ch(){return(0,j.jsx)(Je,{client:Nm,children:(0,j.jsx)(sh,{})})}(0,u.createRoot)(document.getElementById(`root`)).render((0,j.jsx)(ch,{})); \ No newline at end of file + `,e.appendChild(t)}function qm(e,t){let n=e.querySelector(`diffs-container`)?.shadowRoot;if(!n)return;let r=n.querySelector(`pre`);if(!r)return;Km(n);for(let e of n.querySelectorAll(`[data-review-comment]`))e.removeAttribute(`data-review-comment`);if(t.length===0)return;let i=e=>t.some(t=>e>=t.start&&e<=t.end);for(let e of Array.from(r.children)){if(!(e instanceof HTMLElement)||!e.hasAttribute(`data-code`)||e.hasAttribute(`data-deletions`))continue;let t=e.querySelector(`[data-gutter]`),n=e.querySelector(`[data-content]`);if(!(!t||!n))for(let e=0;e{o.current?.focus()},[]);let s=()=>{let t=i.trim();!t||n||e(t)};return(0,j.jsxs)(`div`,{className:`comment-form`,onClick:e=>e.stopPropagation(),children:[(0,j.jsx)(`textarea`,{ref:o,value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.metaKey||e.ctrlKey)&&(e.preventDefault(),s()),e.key===`Escape`&&t()},placeholder:`Leave a review comment... (Cmd+Enter to submit)`,rows:3,disabled:n}),(0,j.jsxs)(`div`,{className:`comment-form-actions`,children:[r&&(0,j.jsx)(`div`,{className:`comment-form-error`,children:r}),(0,j.jsx)(Jm,{onClick:t,disabled:n,children:`Cancel`}),(0,j.jsx)(Jm,{variant:`primary`,disabled:!i.trim()||n,onClick:s,children:`Comment`})]})]})}var Xm=20,Zm=!1,Qm=(0,A.memo)(function({fileDiff:e,diffStyle:t,lineAnnotations:n,renderAnnotation:r,onGutterUtilityClick:i,commentsEnabled:a}){return(0,j.jsx)(`div`,{ref:(0,A.useCallback)(n=>{Zm||!n||requestAnimationFrame(()=>{let r=t===`split`?e.splitLineCount:e.unifiedLineCount;r>0&&n.offsetHeight>0&&(Xm=n.offsetHeight/r,Zm=!0)})},[e,t]),children:(0,j.jsx)(wm,{fileDiff:e,options:{theme:`github-dark-default`,diffStyle:t,diffIndicators:`classic`,hunkSeparators:`line-info-basic`,overflow:`wrap`,disableFileHeader:!0,enableGutterUtility:a,onGutterUtilityClick:i},lineAnnotations:n,renderAnnotation:r})})},(e,t)=>e.fileDiff===t.fileDiff&&e.diffStyle===t.diffStyle&&e.lineAnnotations===t.lineAnnotations);function $m({fileDiff:e,isViewed:t,isStale:n,diffStyle:r,collapsed:i,composing:a,focused:o,onToggleCollapse:s,onToggleViewed:c,onStartComment:l,onSubmitComment:u,onResolveComment:d,onCancelComment:f,commentsEnabled:p,submitting:m,submitError:h}){let{additions:g,deletions:_}=zm(e),v=e.name,y=(0,A.useMemo)(()=>{if(!p)return[];let t=Rm(e,v);return a&&t.push({side:`additions`,lineNumber:a.line,metadata:{type:`composing`,file:v}}),t},[e,v,a,p]),b=(0,A.useCallback)(e=>{let t=e.metadata;return t.type===`review`?(0,j.jsx)(`div`,{className:`review-annotation`,children:(0,j.jsx)(Jm,{onClick:()=>d(t.startLine),children:`Resolve comment`})}):t.type===`composing`?(0,j.jsx)(Ym,{onSubmit:t=>u(e.lineNumber,t),onCancel:f,submitting:m,error:h}):null},[d,u,f,m,h]),x=(0,A.useCallback)(e=>{!p||e.side!==`additions`||y.some(t=>t.metadata.type===`review`&&e.start>=t.metadata.startLine&&e.start<=t.metadata.endLine)||l(e.start)},[l,p,y]),S=(0,A.useRef)(null),[C,w]=(0,A.useState)(!1);(0,A.useEffect)(()=>{let e=S.current;if(!e)return;let t=new IntersectionObserver(e=>{e[0]?.isIntersecting&&(w(!0),t.disconnect())},{rootMargin:`2000px`});return t.observe(e),()=>t.disconnect()},[]);let ee=C&&!i;(0,A.useEffect)(()=>{if(!ee)return;let e=S.current;if(!e)return;let t=y.flatMap(e=>e.metadata.type===`review`?[{start:e.metadata.startLine,end:e.metadata.endLine}]:[]),n=0,r=null,i=0,a=()=>qm(e,t),o=()=>{let t=(e.querySelector(`diffs-container`)?.shadowRoot)?.querySelector(`pre`);if(!t){i++<60&&(n=requestAnimationFrame(o));return}a(),r=new MutationObserver(a),r.observe(t,{childList:!0,subtree:!0})};return n=requestAnimationFrame(o),()=>{cancelAnimationFrame(n),r?.disconnect()}},[y,ee,r,e]);let T=(r===`split`?e.splitLineCount:e.unifiedLineCount)*Xm;return(0,j.jsxs)(`div`,{ref:S,className:`file-card`+(o?` focused`:``),children:[(0,j.jsxs)(`div`,{className:`file-header`,onClick:s,children:[(0,j.jsx)(`span`,{className:`collapse-chevron`+(i?` collapsed`:``),children:`▶`}),(0,j.jsx)(`span`,{className:`file-header-name`,children:v}),(0,j.jsxs)(`span`,{className:`file-header-stats`,children:[g>0&&(0,j.jsxs)(`span`,{className:`stat-add`,children:[`+`,g]}),_>0&&(0,j.jsxs)(`span`,{className:`stat-del`,children:[`-`,_]})]}),(0,j.jsxs)(`button`,{type:`button`,className:`viewed-button`+(n?` stale`:``)+(t?` checked`:``),onClick:e=>{e.stopPropagation(),c()},children:[(0,j.jsx)(`input`,{type:`checkbox`,className:`viewed-checkbox`+(n?` stale`:``),checked:t,readOnly:!0,tabIndex:-1}),n?`Changed`:`Viewed`]})]}),ee?(0,j.jsx)(Qm,{fileDiff:e,diffStyle:r,lineAnnotations:y,renderAnnotation:b,onGutterUtilityClick:x,commentsEnabled:p}):!i&&(0,j.jsx)(`div`,{style:{height:T}})]})}function eh(e){return e?e.generated||e.vendored||e.binary:!1}function th({fileHashes:e,viewed:t,attrs:n}){let r=Object.entries(e).filter(([e])=>!eh(n[e])),i=r.length,a=r.filter(([e,n])=>t[e]===n).length;return i===0?null:(0,j.jsxs)(`div`,{className:`progress-bar`,children:[(0,j.jsxs)(`span`,{children:[a,`/`,i,` files viewed`]}),(0,j.jsx)(`div`,{className:`progress-track`,children:(0,j.jsx)(`div`,{className:`progress-fill`,style:{width:`${a/i*100}%`}})})]})}function nh({onClose:e,children:t}){let n=(0,A.useRef)(null);return(0,A.useEffect)(()=>n.current?.showModal(),[]),(0,j.jsx)(`dialog`,{ref:n,className:`help-modal`,onClose:e,onClick:e=>{e.target===e.currentTarget&&n.current?.close()},children:t})}var rh=[[`j / k`,`Next / previous line`],[`n / p`,`Next / previous file`],[`v`,`Toggle viewed`],[`e / E`,`Toggle collapse file / all files`],[`s`,`Toggle split mode (responsive / unified)`],[`c`,`Comment on line`],[`Esc`,`Close / cancel`],[`?`,`Toggle this help`]];function ih({onClose:e}){return(0,j.jsxs)(nh,{onClose:e,children:[(0,j.jsx)(`h3`,{children:`Keyboard Shortcuts`}),(0,j.jsx)(`table`,{children:(0,j.jsx)(`tbody`,{children:rh.map(([e,t])=>(0,j.jsxs)(`tr`,{children:[(0,j.jsx)(`td`,{children:e.split(` / `).map((e,t)=>(0,j.jsxs)(`span`,{children:[t>0&&` / `,(0,j.jsx)(`kbd`,{children:e})]},e))}),(0,j.jsx)(`td`,{children:t})]},e))})})]})}function ah({onClose:e,vcs:t}){return(0,j.jsxs)(nh,{onClose:e,children:[(0,j.jsxs)(`p`,{children:[`Comments work by inserting lines into the code. This can only work if the`,` `,t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`revset includes `,(0,j.jsx)(`code`,{children:`@`})]}):`commit range includes the working copy`,`. Otherwise, the inserted lines will land in`,` `,t===`jj`?(0,j.jsx)(`code`,{children:`@`}):`the working copy`,` but not in the diff being viewed.`]}),t===`jj`?(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with the`,` `,(0,j.jsx)(`code`,{children:`-f`}),` flag to get a diff that starts at some revision and ends at`,` `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with `,(0,j.jsx)(`code`,{children:`-f`}),` `,`only (no `,(0,j.jsx)(`code`,{children:`-t`}),`), so the diff goes from a commit to the working tree.`]})]})}function oh({onLearnMore:e,vcs:t}){return(0,j.jsxs)(`div`,{className:`comments-disabled-banner`,children:[t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`Comments are disabled because the revset does not include `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):`Comments are disabled because the commit range does not end at the working copy.`,` `,(0,j.jsx)(`a`,{href:`#`,onClick:t=>{t.preventDefault(),e()},children:`Learn more`})]})}var sh=[`responsive`,`unified`];function ch(){let e=Im(),[t,n]=(0,A.useState)(`responsive`),r=t===`responsive`&&e?`split`:`unified`,{toast:i,showToast:a}=Lm(),[o,s]=(0,A.useState)({}),[c,l]=(0,A.useState)(null),u=(0,A.useRef)(!1),{data:d,error:f,isLoading:p}=ct({queryKey:[`diff`],queryFn:()=>Pm(`/api/diff`)});(0,A.useEffect)(()=>{d?.revset&&(document.title=`skepsis | ${d.revset}`)},[d?.revset]),(0,A.useEffect)(()=>{if(u.current||!d?.fileHashes)return;u.current=!0;let e={};for(let[t,n]of Object.entries(d.fileHashes))(d.viewed[t]===n||eh(d.attrs[t]))&&(e[t]=!0);s(e)},[d]);let m=qe(),h=lt({mutationFn:({file:e,hash:t,mark:n})=>Pm(`/api/viewed`,{method:n?`POST`:`DELETE`,body:{file:e,hash:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),g=lt({mutationFn:({file:e,afterLine:t,text:n})=>Pm(`/api/comment`,{method:`POST`,body:{file:e,afterLine:t,text:n}}),onSuccess:()=>l(null),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),_=lt({mutationFn:({file:e,line:t})=>Pm(`/api/comment`,{method:`DELETE`,body:{file:e,line:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),v=d?.patch,y=(0,A.useMemo)(()=>v?qp(v).flatMap(e=>e.files):[],[v]),b=(0,A.useMemo)(()=>{let e={...d?.viewed};if(h.isPending&&h.variables){let{file:t,hash:n,mark:r}=h.variables;r?e[t]=n:delete e[t]}return e},[d,h.isPending,h.variables]),[x,S]=(0,A.useState)(0),[C,w]=(0,A.useState)(0),[ee,T]=(0,A.useState)(!1),[te,ne]=(0,A.useState)(!1),re=(0,A.useRef)(null),ie=(0,A.useRef)(0),ae=(0,A.useRef)({focusedFileIdx:0,cursorIdx:0,showHelp:!1,composing:null,files:[],collapsed:{},fileHashes:{},viewed:{},markMutate:h.mutate});ae.current={focusedFileIdx:x,cursorIdx:C,showHelp:ee,composing:c,files:y,collapsed:o,fileHashes:d?.fileHashes??{},viewed:b,markMutate:h.mutate},(0,A.useLayoutEffect)(()=>{Wm();let e=re.current;re.current=null,e===`file`&&document.querySelectorAll(`.file-card`)[x]?.scrollIntoView({block:`start`});let t=y[x];if(!t||(o[t.name]??!1))return;let n=Bm(x),r=n[Math.min(C,Math.max(n.length-1,0))];if(!r)return;let i=r.gutterEl.getRootNode();i instanceof ShadowRoot&&Gm(i),r.gutterEl.setAttribute(`data-selected-line`,`single`),r.contentEl.setAttribute(`data-selected-line`,`single`),e===`line`&&r.contentEl.scrollIntoView({block:`nearest`})},[x,C,v,c,o,y]),(0,A.useEffect)(()=>{function e(e){if(e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLInputElement||e.ctrlKey||e.metaKey||e.altKey)return;let t=ae.current;switch(e.key){case`?`:e.preventDefault(),T(e=>!e);break;case`Escape`:t.showHelp?(e.preventDefault(),T(!1)):t.composing&&(e.preventDefault(),l(null));break;case`n`:case`p`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=e.key===`n`?1:-1,r=Math.max(0,Math.min(t.focusedFileIdx+n,t.files.length-1));r!==t.focusedFileIdx&&(ie.current=Date.now(),S(r),w(0),re.current=`file`);break}case`j`:case`k`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx]?.name;if(!n||(t.collapsed[n]??!1))break;let r=Bm(t.focusedFileIdx);if(r.length===0)break;let i=t.cursorIdx,a=r[Math.min(i,r.length-1)];if(a&&!Um(a.contentEl,t.focusedFileIdx)){let e=Hm(t.focusedFileIdx);e>=0&&(i=e)}let o=Math.max(0,Math.min(i+(e.key===`j`?1:-1),r.length-1));o!==t.cursorIdx&&(ie.current=Date.now(),w(o),re.current=`line`);break}case`v`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;let r=t.fileHashes[n.name];if(!r)break;let i=t.viewed[n.name]!==r;s(e=>({...e,[n.name]:i})),t.markMutate({file:n.name,hash:r,mark:i});break}case`e`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;ie.current=Date.now(),s(e=>({...e,[n.name]:!(e[n.name]??!1)}));break}case`E`:{if(t.showHelp||t.composing)break;e.preventDefault(),ie.current=Date.now();let n=t.files.some(e=>!(t.collapsed[e.name]??!1));s(Object.fromEntries(t.files.map(e=>[e.name,n])));break}case`s`:if(t.showHelp||t.composing)break;e.preventDefault(),n(e=>{let t=sh[(sh.indexOf(e)+1)%sh.length];return a((0,j.jsxs)(j.Fragment,{children:[`Split mode: `,(0,j.jsx)(`code`,{children:t})]})),t});break;case`c`:{if(!d?.commentsEnabled||t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n||(t.collapsed[n.name]??!1))break;let r=Bm(t.focusedFileIdx)[t.cursorIdx];if(!r||r.lineType===`change-deletion`)break;l({file:n.name,line:r.lineNumber});break}}}return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[a,d?.commentsEnabled]);let oe=(0,A.useRef)(new Set);if((0,A.useEffect)(()=>{oe.current.clear();let e=new IntersectionObserver(e=>{for(let t of e){let e=Number(t.target.dataset.fileIdx);t.isIntersecting?oe.current.add(e):oe.current.delete(e)}if(Date.now()-ie.current<150||oe.current.size===0)return;let t=Math.min(...oe.current),n=ae.current;if(t!==n.focusedFileIdx){S(t);let e=n.files[t];if(e&&!(n.collapsed[e.name]??!1)){let e=Hm(t);e>=0&&w(e)}}},{threshold:0});return document.querySelectorAll(`.file-card`).forEach((t,n)=>{t.dataset.fileIdx=String(n),e.observe(t)}),()=>e.disconnect()},[y]),p||!d)return(0,j.jsx)(`div`,{className:`empty-diff`,children:`Loading...`});if(f)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:String(f)});if(d.error)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:d.error});if(!v)return(0,j.jsxs)(`div`,{className:`empty-diff`,children:[(0,j.jsx)(`code`,{children:d.revset}),` is empty`]});let{fileHashes:se}=d;return(0,j.jsxs)(j.Fragment,{children:[!d.commentsEnabled&&(0,j.jsx)(oh,{vcs:d.vcs,onLearnMore:()=>ne(!0)}),(0,j.jsxs)(`div`,{className:`diff-container`,children:[(0,j.jsx)(th,{fileHashes:se,viewed:b,attrs:d.attrs}),ee&&(0,j.jsx)(ih,{onClose:()=>T(!1)}),te&&(0,j.jsx)(ah,{vcs:d.vcs,onClose:()=>ne(!1)}),i&&(0,j.jsx)(`div`,{className:`toast`,children:i.content},i.key),y.map((e,t)=>{let n=e.name,i=se[n],a=b[n],u=i!=null&&a===i,f=a!=null&&i!=null&&a!==i,p=o[n]??!1;return(0,j.jsx)($m,{fileDiff:e,isViewed:u,isStale:f,diffStyle:r,collapsed:p,composing:c?.file===n?{line:c.line}:null,focused:t===x,onToggleCollapse:()=>s(e=>({...e,[n]:!p})),onToggleViewed:()=>{if(i){let e=!u;s(t=>({...t,[n]:e})),h.mutate({file:n,hash:i,mark:e})}},onStartComment:e=>{g.reset(),l({file:n,line:e})},onSubmitComment:(e,t)=>g.mutate({file:n,afterLine:e,text:t}),onResolveComment:e=>_.mutate({file:n,line:e}),onCancelComment:()=>l(null),commentsEnabled:d.commentsEnabled,submitting:g.isPending&&g.variables?.file===n,submitError:g.variables?.file===n&&g.error?g.error.message:null},n??t)})]})]})}function lh(){return(0,j.jsx)(Je,{client:Nm,children:(0,j.jsx)(ch,{})})}(0,u.createRoot)(document.getElementById(`root`)).render((0,j.jsx)(lh,{})); \ No newline at end of file diff --git a/dist/index.html b/dist/index.html index 6cfceb1..347a6a3 100644 --- a/dist/index.html +++ b/dist/index.html @@ -12,7 +12,7 @@ skepsis - + diff --git a/src/App.tsx b/src/App.tsx index 3003134..683832b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,7 +26,13 @@ import { import { parsePatchFiles } from '@pierre/diffs' import { FileDiff } from '@pierre/diffs/react' import type { DiffLineAnnotation, FileDiffMetadata } from '@pierre/diffs' -import type { DiffResponse, ErrorResponse, ViewedMap, FileHashes } from '../shared/types.ts' +import type { + DiffResponse, + ErrorResponse, + FileAttrs, + FileHashes, + ViewedMap, +} from '../shared/types.ts' import { REVIEW_CLOSE_PATTERN, REVIEW_OPEN_PATTERN } from '../shared/reviewComments.ts' const queryClient = new QueryClient() @@ -61,7 +67,10 @@ function useIsWide(): boolean { } function useToast(duration = 1500) { - const [toast, setToast] = useState<{ content: React.ReactNode; key: number } | null>(null) + const [toast, setToast] = useState<{ + content: React.ReactNode + key: number + } | null>(null) const timer = useRef>(undefined) const key = useRef(0) const showToast = useCallback( @@ -624,17 +633,28 @@ function FileCard({ ) } +/** Auto-collapse applies to generated, vendored, and binary files. + * Documentation files stay expanded (just badged) since they're usually + * worth reading. */ +function isAutoCollapsed(attrs: FileAttrs | undefined): boolean { + if (!attrs) return false + return attrs.generated || attrs.vendored || attrs.binary +} + function ProgressBar({ fileHashes, viewed, + attrs, }: { fileHashes: FileHashes viewed: ViewedMap + attrs: Record }) { - const total = Object.keys(fileHashes).length - const viewedCount = Object.entries(fileHashes).filter( - ([file, hash]) => viewed[file] === hash, - ).length + const reviewable = Object.entries(fileHashes).filter( + ([file]) => !isAutoCollapsed(attrs[file]), + ) + const total = reviewable.length + const viewedCount = reviewable.filter(([file, hash]) => viewed[file] === hash).length if (total === 0) return null @@ -778,7 +798,10 @@ function DiffView() { splitMode === 'responsive' && isWide ? 'split' : 'unified' const { toast, showToast } = useToast() const [collapsed, setCollapsed] = useState>({}) - const [composing, setComposing] = useState<{ file: string; line: number } | null>(null) + const [composing, setComposing] = useState<{ + file: string + line: number + } | null>(null) const seededFromInitialLoad = useRef(false) const { data, error, isLoading } = useQuery({ queryKey: ['diff'], @@ -789,13 +812,14 @@ function DiffView() { if (data?.revset) document.title = `skepsis | ${data.revset}` }, [data?.revset]) - // Seed collapse state from the first successful fetch: viewed files start collapsed + // Seed collapse state from the first successful fetch: viewed files start + // collapsed, as do auto-collapsed files (generated / vendored / binary). useEffect(() => { if (seededFromInitialLoad.current || !data?.fileHashes) return seededFromInitialLoad.current = true const initial: Record = {} for (const [file, hash] of Object.entries(data.fileHashes)) { - if (data.viewed[file] === hash) { + if (data.viewed[file] === hash || isAutoCollapsed(data.attrs[file])) { initial[file] = true } } @@ -1114,7 +1138,7 @@ function DiffView() { /> )}
- + {showHelp && setShowHelp(false)} />} {showCommentsInfo && ( setShowCommentsInfo(false)} /> From a0ab827e13acf8a74a9e9ad36cc166c615f98a26 Mon Sep 17 00:00:00 2001 From: Daniel Rivas Perez Date: Thu, 23 Apr 2026 11:25:44 +0000 Subject: [PATCH 5/5] Show Generated / Vendored / Documentation / Binary badges Pass the file attrs down to FileCard and render a small pill for each attribute that's set, right before the +/- stats in the file header. Documentation files only ever get badged; the other three also auto-collapse (per the previous commit). --- .../{index-Dxy9xqb9.js => index-4lR4GVU8.js} | 2 +- dist/assets/index-BMNMHQlP.css | 1 + dist/assets/index-rQG9O9IS.css | 1 - dist/index.html | 4 +-- src/App.tsx | 25 +++++++++++++++++++ src/styles.css | 13 ++++++++++ 6 files changed, 42 insertions(+), 4 deletions(-) rename dist/assets/{index-Dxy9xqb9.js => index-4lR4GVU8.js} (98%) create mode 100644 dist/assets/index-BMNMHQlP.css delete mode 100644 dist/assets/index-rQG9O9IS.css diff --git a/dist/assets/index-Dxy9xqb9.js b/dist/assets/index-4lR4GVU8.js similarity index 98% rename from dist/assets/index-Dxy9xqb9.js rename to dist/assets/index-4lR4GVU8.js index 92eb0b8..456c4f1 100644 --- a/dist/assets/index-Dxy9xqb9.js +++ b/dist/assets/index-4lR4GVU8.js @@ -1891,4 +1891,4 @@ XID_Start XIDS`.split(/\s/).map(e=>[Fl(e),e])),Su=new Map([[`s`,J(383)],[J(383), color: var(--diffs-fg) !important; } [data-review-comment] [data-gutter-utility-slot] { display: none !important; } - `,e.appendChild(t)}function qm(e,t){let n=e.querySelector(`diffs-container`)?.shadowRoot;if(!n)return;let r=n.querySelector(`pre`);if(!r)return;Km(n);for(let e of n.querySelectorAll(`[data-review-comment]`))e.removeAttribute(`data-review-comment`);if(t.length===0)return;let i=e=>t.some(t=>e>=t.start&&e<=t.end);for(let e of Array.from(r.children)){if(!(e instanceof HTMLElement)||!e.hasAttribute(`data-code`)||e.hasAttribute(`data-deletions`))continue;let t=e.querySelector(`[data-gutter]`),n=e.querySelector(`[data-content]`);if(!(!t||!n))for(let e=0;e{o.current?.focus()},[]);let s=()=>{let t=i.trim();!t||n||e(t)};return(0,j.jsxs)(`div`,{className:`comment-form`,onClick:e=>e.stopPropagation(),children:[(0,j.jsx)(`textarea`,{ref:o,value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.metaKey||e.ctrlKey)&&(e.preventDefault(),s()),e.key===`Escape`&&t()},placeholder:`Leave a review comment... (Cmd+Enter to submit)`,rows:3,disabled:n}),(0,j.jsxs)(`div`,{className:`comment-form-actions`,children:[r&&(0,j.jsx)(`div`,{className:`comment-form-error`,children:r}),(0,j.jsx)(Jm,{onClick:t,disabled:n,children:`Cancel`}),(0,j.jsx)(Jm,{variant:`primary`,disabled:!i.trim()||n,onClick:s,children:`Comment`})]})]})}var Xm=20,Zm=!1,Qm=(0,A.memo)(function({fileDiff:e,diffStyle:t,lineAnnotations:n,renderAnnotation:r,onGutterUtilityClick:i,commentsEnabled:a}){return(0,j.jsx)(`div`,{ref:(0,A.useCallback)(n=>{Zm||!n||requestAnimationFrame(()=>{let r=t===`split`?e.splitLineCount:e.unifiedLineCount;r>0&&n.offsetHeight>0&&(Xm=n.offsetHeight/r,Zm=!0)})},[e,t]),children:(0,j.jsx)(wm,{fileDiff:e,options:{theme:`github-dark-default`,diffStyle:t,diffIndicators:`classic`,hunkSeparators:`line-info-basic`,overflow:`wrap`,disableFileHeader:!0,enableGutterUtility:a,onGutterUtilityClick:i},lineAnnotations:n,renderAnnotation:r})})},(e,t)=>e.fileDiff===t.fileDiff&&e.diffStyle===t.diffStyle&&e.lineAnnotations===t.lineAnnotations);function $m({fileDiff:e,isViewed:t,isStale:n,diffStyle:r,collapsed:i,composing:a,focused:o,onToggleCollapse:s,onToggleViewed:c,onStartComment:l,onSubmitComment:u,onResolveComment:d,onCancelComment:f,commentsEnabled:p,submitting:m,submitError:h}){let{additions:g,deletions:_}=zm(e),v=e.name,y=(0,A.useMemo)(()=>{if(!p)return[];let t=Rm(e,v);return a&&t.push({side:`additions`,lineNumber:a.line,metadata:{type:`composing`,file:v}}),t},[e,v,a,p]),b=(0,A.useCallback)(e=>{let t=e.metadata;return t.type===`review`?(0,j.jsx)(`div`,{className:`review-annotation`,children:(0,j.jsx)(Jm,{onClick:()=>d(t.startLine),children:`Resolve comment`})}):t.type===`composing`?(0,j.jsx)(Ym,{onSubmit:t=>u(e.lineNumber,t),onCancel:f,submitting:m,error:h}):null},[d,u,f,m,h]),x=(0,A.useCallback)(e=>{!p||e.side!==`additions`||y.some(t=>t.metadata.type===`review`&&e.start>=t.metadata.startLine&&e.start<=t.metadata.endLine)||l(e.start)},[l,p,y]),S=(0,A.useRef)(null),[C,w]=(0,A.useState)(!1);(0,A.useEffect)(()=>{let e=S.current;if(!e)return;let t=new IntersectionObserver(e=>{e[0]?.isIntersecting&&(w(!0),t.disconnect())},{rootMargin:`2000px`});return t.observe(e),()=>t.disconnect()},[]);let ee=C&&!i;(0,A.useEffect)(()=>{if(!ee)return;let e=S.current;if(!e)return;let t=y.flatMap(e=>e.metadata.type===`review`?[{start:e.metadata.startLine,end:e.metadata.endLine}]:[]),n=0,r=null,i=0,a=()=>qm(e,t),o=()=>{let t=(e.querySelector(`diffs-container`)?.shadowRoot)?.querySelector(`pre`);if(!t){i++<60&&(n=requestAnimationFrame(o));return}a(),r=new MutationObserver(a),r.observe(t,{childList:!0,subtree:!0})};return n=requestAnimationFrame(o),()=>{cancelAnimationFrame(n),r?.disconnect()}},[y,ee,r,e]);let T=(r===`split`?e.splitLineCount:e.unifiedLineCount)*Xm;return(0,j.jsxs)(`div`,{ref:S,className:`file-card`+(o?` focused`:``),children:[(0,j.jsxs)(`div`,{className:`file-header`,onClick:s,children:[(0,j.jsx)(`span`,{className:`collapse-chevron`+(i?` collapsed`:``),children:`▶`}),(0,j.jsx)(`span`,{className:`file-header-name`,children:v}),(0,j.jsxs)(`span`,{className:`file-header-stats`,children:[g>0&&(0,j.jsxs)(`span`,{className:`stat-add`,children:[`+`,g]}),_>0&&(0,j.jsxs)(`span`,{className:`stat-del`,children:[`-`,_]})]}),(0,j.jsxs)(`button`,{type:`button`,className:`viewed-button`+(n?` stale`:``)+(t?` checked`:``),onClick:e=>{e.stopPropagation(),c()},children:[(0,j.jsx)(`input`,{type:`checkbox`,className:`viewed-checkbox`+(n?` stale`:``),checked:t,readOnly:!0,tabIndex:-1}),n?`Changed`:`Viewed`]})]}),ee?(0,j.jsx)(Qm,{fileDiff:e,diffStyle:r,lineAnnotations:y,renderAnnotation:b,onGutterUtilityClick:x,commentsEnabled:p}):!i&&(0,j.jsx)(`div`,{style:{height:T}})]})}function eh(e){return e?e.generated||e.vendored||e.binary:!1}function th({fileHashes:e,viewed:t,attrs:n}){let r=Object.entries(e).filter(([e])=>!eh(n[e])),i=r.length,a=r.filter(([e,n])=>t[e]===n).length;return i===0?null:(0,j.jsxs)(`div`,{className:`progress-bar`,children:[(0,j.jsxs)(`span`,{children:[a,`/`,i,` files viewed`]}),(0,j.jsx)(`div`,{className:`progress-track`,children:(0,j.jsx)(`div`,{className:`progress-fill`,style:{width:`${a/i*100}%`}})})]})}function nh({onClose:e,children:t}){let n=(0,A.useRef)(null);return(0,A.useEffect)(()=>n.current?.showModal(),[]),(0,j.jsx)(`dialog`,{ref:n,className:`help-modal`,onClose:e,onClick:e=>{e.target===e.currentTarget&&n.current?.close()},children:t})}var rh=[[`j / k`,`Next / previous line`],[`n / p`,`Next / previous file`],[`v`,`Toggle viewed`],[`e / E`,`Toggle collapse file / all files`],[`s`,`Toggle split mode (responsive / unified)`],[`c`,`Comment on line`],[`Esc`,`Close / cancel`],[`?`,`Toggle this help`]];function ih({onClose:e}){return(0,j.jsxs)(nh,{onClose:e,children:[(0,j.jsx)(`h3`,{children:`Keyboard Shortcuts`}),(0,j.jsx)(`table`,{children:(0,j.jsx)(`tbody`,{children:rh.map(([e,t])=>(0,j.jsxs)(`tr`,{children:[(0,j.jsx)(`td`,{children:e.split(` / `).map((e,t)=>(0,j.jsxs)(`span`,{children:[t>0&&` / `,(0,j.jsx)(`kbd`,{children:e})]},e))}),(0,j.jsx)(`td`,{children:t})]},e))})})]})}function ah({onClose:e,vcs:t}){return(0,j.jsxs)(nh,{onClose:e,children:[(0,j.jsxs)(`p`,{children:[`Comments work by inserting lines into the code. This can only work if the`,` `,t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`revset includes `,(0,j.jsx)(`code`,{children:`@`})]}):`commit range includes the working copy`,`. Otherwise, the inserted lines will land in`,` `,t===`jj`?(0,j.jsx)(`code`,{children:`@`}):`the working copy`,` but not in the diff being viewed.`]}),t===`jj`?(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with the`,` `,(0,j.jsx)(`code`,{children:`-f`}),` flag to get a diff that starts at some revision and ends at`,` `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with `,(0,j.jsx)(`code`,{children:`-f`}),` `,`only (no `,(0,j.jsx)(`code`,{children:`-t`}),`), so the diff goes from a commit to the working tree.`]})]})}function oh({onLearnMore:e,vcs:t}){return(0,j.jsxs)(`div`,{className:`comments-disabled-banner`,children:[t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`Comments are disabled because the revset does not include `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):`Comments are disabled because the commit range does not end at the working copy.`,` `,(0,j.jsx)(`a`,{href:`#`,onClick:t=>{t.preventDefault(),e()},children:`Learn more`})]})}var sh=[`responsive`,`unified`];function ch(){let e=Im(),[t,n]=(0,A.useState)(`responsive`),r=t===`responsive`&&e?`split`:`unified`,{toast:i,showToast:a}=Lm(),[o,s]=(0,A.useState)({}),[c,l]=(0,A.useState)(null),u=(0,A.useRef)(!1),{data:d,error:f,isLoading:p}=ct({queryKey:[`diff`],queryFn:()=>Pm(`/api/diff`)});(0,A.useEffect)(()=>{d?.revset&&(document.title=`skepsis | ${d.revset}`)},[d?.revset]),(0,A.useEffect)(()=>{if(u.current||!d?.fileHashes)return;u.current=!0;let e={};for(let[t,n]of Object.entries(d.fileHashes))(d.viewed[t]===n||eh(d.attrs[t]))&&(e[t]=!0);s(e)},[d]);let m=qe(),h=lt({mutationFn:({file:e,hash:t,mark:n})=>Pm(`/api/viewed`,{method:n?`POST`:`DELETE`,body:{file:e,hash:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),g=lt({mutationFn:({file:e,afterLine:t,text:n})=>Pm(`/api/comment`,{method:`POST`,body:{file:e,afterLine:t,text:n}}),onSuccess:()=>l(null),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),_=lt({mutationFn:({file:e,line:t})=>Pm(`/api/comment`,{method:`DELETE`,body:{file:e,line:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),v=d?.patch,y=(0,A.useMemo)(()=>v?qp(v).flatMap(e=>e.files):[],[v]),b=(0,A.useMemo)(()=>{let e={...d?.viewed};if(h.isPending&&h.variables){let{file:t,hash:n,mark:r}=h.variables;r?e[t]=n:delete e[t]}return e},[d,h.isPending,h.variables]),[x,S]=(0,A.useState)(0),[C,w]=(0,A.useState)(0),[ee,T]=(0,A.useState)(!1),[te,ne]=(0,A.useState)(!1),re=(0,A.useRef)(null),ie=(0,A.useRef)(0),ae=(0,A.useRef)({focusedFileIdx:0,cursorIdx:0,showHelp:!1,composing:null,files:[],collapsed:{},fileHashes:{},viewed:{},markMutate:h.mutate});ae.current={focusedFileIdx:x,cursorIdx:C,showHelp:ee,composing:c,files:y,collapsed:o,fileHashes:d?.fileHashes??{},viewed:b,markMutate:h.mutate},(0,A.useLayoutEffect)(()=>{Wm();let e=re.current;re.current=null,e===`file`&&document.querySelectorAll(`.file-card`)[x]?.scrollIntoView({block:`start`});let t=y[x];if(!t||(o[t.name]??!1))return;let n=Bm(x),r=n[Math.min(C,Math.max(n.length-1,0))];if(!r)return;let i=r.gutterEl.getRootNode();i instanceof ShadowRoot&&Gm(i),r.gutterEl.setAttribute(`data-selected-line`,`single`),r.contentEl.setAttribute(`data-selected-line`,`single`),e===`line`&&r.contentEl.scrollIntoView({block:`nearest`})},[x,C,v,c,o,y]),(0,A.useEffect)(()=>{function e(e){if(e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLInputElement||e.ctrlKey||e.metaKey||e.altKey)return;let t=ae.current;switch(e.key){case`?`:e.preventDefault(),T(e=>!e);break;case`Escape`:t.showHelp?(e.preventDefault(),T(!1)):t.composing&&(e.preventDefault(),l(null));break;case`n`:case`p`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=e.key===`n`?1:-1,r=Math.max(0,Math.min(t.focusedFileIdx+n,t.files.length-1));r!==t.focusedFileIdx&&(ie.current=Date.now(),S(r),w(0),re.current=`file`);break}case`j`:case`k`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx]?.name;if(!n||(t.collapsed[n]??!1))break;let r=Bm(t.focusedFileIdx);if(r.length===0)break;let i=t.cursorIdx,a=r[Math.min(i,r.length-1)];if(a&&!Um(a.contentEl,t.focusedFileIdx)){let e=Hm(t.focusedFileIdx);e>=0&&(i=e)}let o=Math.max(0,Math.min(i+(e.key===`j`?1:-1),r.length-1));o!==t.cursorIdx&&(ie.current=Date.now(),w(o),re.current=`line`);break}case`v`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;let r=t.fileHashes[n.name];if(!r)break;let i=t.viewed[n.name]!==r;s(e=>({...e,[n.name]:i})),t.markMutate({file:n.name,hash:r,mark:i});break}case`e`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;ie.current=Date.now(),s(e=>({...e,[n.name]:!(e[n.name]??!1)}));break}case`E`:{if(t.showHelp||t.composing)break;e.preventDefault(),ie.current=Date.now();let n=t.files.some(e=>!(t.collapsed[e.name]??!1));s(Object.fromEntries(t.files.map(e=>[e.name,n])));break}case`s`:if(t.showHelp||t.composing)break;e.preventDefault(),n(e=>{let t=sh[(sh.indexOf(e)+1)%sh.length];return a((0,j.jsxs)(j.Fragment,{children:[`Split mode: `,(0,j.jsx)(`code`,{children:t})]})),t});break;case`c`:{if(!d?.commentsEnabled||t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n||(t.collapsed[n.name]??!1))break;let r=Bm(t.focusedFileIdx)[t.cursorIdx];if(!r||r.lineType===`change-deletion`)break;l({file:n.name,line:r.lineNumber});break}}}return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[a,d?.commentsEnabled]);let oe=(0,A.useRef)(new Set);if((0,A.useEffect)(()=>{oe.current.clear();let e=new IntersectionObserver(e=>{for(let t of e){let e=Number(t.target.dataset.fileIdx);t.isIntersecting?oe.current.add(e):oe.current.delete(e)}if(Date.now()-ie.current<150||oe.current.size===0)return;let t=Math.min(...oe.current),n=ae.current;if(t!==n.focusedFileIdx){S(t);let e=n.files[t];if(e&&!(n.collapsed[e.name]??!1)){let e=Hm(t);e>=0&&w(e)}}},{threshold:0});return document.querySelectorAll(`.file-card`).forEach((t,n)=>{t.dataset.fileIdx=String(n),e.observe(t)}),()=>e.disconnect()},[y]),p||!d)return(0,j.jsx)(`div`,{className:`empty-diff`,children:`Loading...`});if(f)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:String(f)});if(d.error)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:d.error});if(!v)return(0,j.jsxs)(`div`,{className:`empty-diff`,children:[(0,j.jsx)(`code`,{children:d.revset}),` is empty`]});let{fileHashes:se}=d;return(0,j.jsxs)(j.Fragment,{children:[!d.commentsEnabled&&(0,j.jsx)(oh,{vcs:d.vcs,onLearnMore:()=>ne(!0)}),(0,j.jsxs)(`div`,{className:`diff-container`,children:[(0,j.jsx)(th,{fileHashes:se,viewed:b,attrs:d.attrs}),ee&&(0,j.jsx)(ih,{onClose:()=>T(!1)}),te&&(0,j.jsx)(ah,{vcs:d.vcs,onClose:()=>ne(!1)}),i&&(0,j.jsx)(`div`,{className:`toast`,children:i.content},i.key),y.map((e,t)=>{let n=e.name,i=se[n],a=b[n],u=i!=null&&a===i,f=a!=null&&i!=null&&a!==i,p=o[n]??!1;return(0,j.jsx)($m,{fileDiff:e,isViewed:u,isStale:f,diffStyle:r,collapsed:p,composing:c?.file===n?{line:c.line}:null,focused:t===x,onToggleCollapse:()=>s(e=>({...e,[n]:!p})),onToggleViewed:()=>{if(i){let e=!u;s(t=>({...t,[n]:e})),h.mutate({file:n,hash:i,mark:e})}},onStartComment:e=>{g.reset(),l({file:n,line:e})},onSubmitComment:(e,t)=>g.mutate({file:n,afterLine:e,text:t}),onResolveComment:e=>_.mutate({file:n,line:e}),onCancelComment:()=>l(null),commentsEnabled:d.commentsEnabled,submitting:g.isPending&&g.variables?.file===n,submitError:g.variables?.file===n&&g.error?g.error.message:null},n??t)})]})]})}function lh(){return(0,j.jsx)(Je,{client:Nm,children:(0,j.jsx)(ch,{})})}(0,u.createRoot)(document.getElementById(`root`)).render((0,j.jsx)(lh,{})); \ No newline at end of file + `,e.appendChild(t)}function qm(e,t){let n=e.querySelector(`diffs-container`)?.shadowRoot;if(!n)return;let r=n.querySelector(`pre`);if(!r)return;Km(n);for(let e of n.querySelectorAll(`[data-review-comment]`))e.removeAttribute(`data-review-comment`);if(t.length===0)return;let i=e=>t.some(t=>e>=t.start&&e<=t.end);for(let e of Array.from(r.children)){if(!(e instanceof HTMLElement)||!e.hasAttribute(`data-code`)||e.hasAttribute(`data-deletions`))continue;let t=e.querySelector(`[data-gutter]`),n=e.querySelector(`[data-content]`);if(!(!t||!n))for(let e=0;e{o.current?.focus()},[]);let s=()=>{let t=i.trim();!t||n||e(t)};return(0,j.jsxs)(`div`,{className:`comment-form`,onClick:e=>e.stopPropagation(),children:[(0,j.jsx)(`textarea`,{ref:o,value:i,onChange:e=>a(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.metaKey||e.ctrlKey)&&(e.preventDefault(),s()),e.key===`Escape`&&t()},placeholder:`Leave a review comment... (Cmd+Enter to submit)`,rows:3,disabled:n}),(0,j.jsxs)(`div`,{className:`comment-form-actions`,children:[r&&(0,j.jsx)(`div`,{className:`comment-form-error`,children:r}),(0,j.jsx)(Jm,{onClick:t,disabled:n,children:`Cancel`}),(0,j.jsx)(Jm,{variant:`primary`,disabled:!i.trim()||n,onClick:s,children:`Comment`})]})]})}var Xm=20,Zm=!1,Qm=(0,A.memo)(function({fileDiff:e,diffStyle:t,lineAnnotations:n,renderAnnotation:r,onGutterUtilityClick:i,commentsEnabled:a}){return(0,j.jsx)(`div`,{ref:(0,A.useCallback)(n=>{Zm||!n||requestAnimationFrame(()=>{let r=t===`split`?e.splitLineCount:e.unifiedLineCount;r>0&&n.offsetHeight>0&&(Xm=n.offsetHeight/r,Zm=!0)})},[e,t]),children:(0,j.jsx)(wm,{fileDiff:e,options:{theme:`github-dark-default`,diffStyle:t,diffIndicators:`classic`,hunkSeparators:`line-info-basic`,overflow:`wrap`,disableFileHeader:!0,enableGutterUtility:a,onGutterUtilityClick:i},lineAnnotations:n,renderAnnotation:r})})},(e,t)=>e.fileDiff===t.fileDiff&&e.diffStyle===t.diffStyle&&e.lineAnnotations===t.lineAnnotations);function $m({attrs:e}){if(!e)return null;let t=[{key:`generated`,label:`Generated`},{key:`vendored`,label:`Vendored`},{key:`documentation`,label:`Documentation`},{key:`binary`,label:`Binary`}].filter(t=>e[t.key]);return t.length===0?null:(0,j.jsx)(j.Fragment,{children:t.map(e=>(0,j.jsx)(`span`,{className:`attr-badge attr-badge-${e.key}`,children:e.label},e.key))})}function eh({fileDiff:e,attrs:t,isViewed:n,isStale:r,diffStyle:i,collapsed:a,composing:o,focused:s,onToggleCollapse:c,onToggleViewed:l,onStartComment:u,onSubmitComment:d,onResolveComment:f,onCancelComment:p,commentsEnabled:m,submitting:h,submitError:g}){let{additions:_,deletions:v}=zm(e),y=e.name,b=(0,A.useMemo)(()=>{if(!m)return[];let t=Rm(e,y);return o&&t.push({side:`additions`,lineNumber:o.line,metadata:{type:`composing`,file:y}}),t},[e,y,o,m]),x=(0,A.useCallback)(e=>{let t=e.metadata;return t.type===`review`?(0,j.jsx)(`div`,{className:`review-annotation`,children:(0,j.jsx)(Jm,{onClick:()=>f(t.startLine),children:`Resolve comment`})}):t.type===`composing`?(0,j.jsx)(Ym,{onSubmit:t=>d(e.lineNumber,t),onCancel:p,submitting:h,error:g}):null},[f,d,p,h,g]),S=(0,A.useCallback)(e=>{!m||e.side!==`additions`||b.some(t=>t.metadata.type===`review`&&e.start>=t.metadata.startLine&&e.start<=t.metadata.endLine)||u(e.start)},[u,m,b]),C=(0,A.useRef)(null),[w,ee]=(0,A.useState)(!1);(0,A.useEffect)(()=>{let e=C.current;if(!e)return;let t=new IntersectionObserver(e=>{e[0]?.isIntersecting&&(ee(!0),t.disconnect())},{rootMargin:`2000px`});return t.observe(e),()=>t.disconnect()},[]);let T=w&&!a;(0,A.useEffect)(()=>{if(!T)return;let e=C.current;if(!e)return;let t=b.flatMap(e=>e.metadata.type===`review`?[{start:e.metadata.startLine,end:e.metadata.endLine}]:[]),n=0,r=null,i=0,a=()=>qm(e,t),o=()=>{let t=(e.querySelector(`diffs-container`)?.shadowRoot)?.querySelector(`pre`);if(!t){i++<60&&(n=requestAnimationFrame(o));return}a(),r=new MutationObserver(a),r.observe(t,{childList:!0,subtree:!0})};return n=requestAnimationFrame(o),()=>{cancelAnimationFrame(n),r?.disconnect()}},[b,T,i,e]);let te=(i===`split`?e.splitLineCount:e.unifiedLineCount)*Xm;return(0,j.jsxs)(`div`,{ref:C,className:`file-card`+(s?` focused`:``),children:[(0,j.jsxs)(`div`,{className:`file-header`,onClick:c,children:[(0,j.jsx)(`span`,{className:`collapse-chevron`+(a?` collapsed`:``),children:`▶`}),(0,j.jsx)(`span`,{className:`file-header-name`,children:y}),(0,j.jsxs)(`span`,{className:`file-header-stats`,children:[_>0&&(0,j.jsxs)(`span`,{className:`stat-add`,children:[`+`,_]}),v>0&&(0,j.jsxs)(`span`,{className:`stat-del`,children:[`-`,v]})]}),(0,j.jsx)($m,{attrs:t}),(0,j.jsxs)(`button`,{type:`button`,className:`viewed-button`+(r?` stale`:``)+(n?` checked`:``),onClick:e=>{e.stopPropagation(),l()},children:[(0,j.jsx)(`input`,{type:`checkbox`,className:`viewed-checkbox`+(r?` stale`:``),checked:n,readOnly:!0,tabIndex:-1}),r?`Changed`:`Viewed`]})]}),T?(0,j.jsx)(Qm,{fileDiff:e,diffStyle:i,lineAnnotations:b,renderAnnotation:x,onGutterUtilityClick:S,commentsEnabled:m}):!a&&(0,j.jsx)(`div`,{style:{height:te}})]})}function th(e){return e?e.generated||e.vendored||e.binary:!1}function nh({fileHashes:e,viewed:t,attrs:n}){let r=Object.entries(e).filter(([e])=>!th(n[e])),i=r.length,a=r.filter(([e,n])=>t[e]===n).length;return i===0?null:(0,j.jsxs)(`div`,{className:`progress-bar`,children:[(0,j.jsxs)(`span`,{children:[a,`/`,i,` files viewed`]}),(0,j.jsx)(`div`,{className:`progress-track`,children:(0,j.jsx)(`div`,{className:`progress-fill`,style:{width:`${a/i*100}%`}})})]})}function rh({onClose:e,children:t}){let n=(0,A.useRef)(null);return(0,A.useEffect)(()=>n.current?.showModal(),[]),(0,j.jsx)(`dialog`,{ref:n,className:`help-modal`,onClose:e,onClick:e=>{e.target===e.currentTarget&&n.current?.close()},children:t})}var ih=[[`j / k`,`Next / previous line`],[`n / p`,`Next / previous file`],[`v`,`Toggle viewed`],[`e / E`,`Toggle collapse file / all files`],[`s`,`Toggle split mode (responsive / unified)`],[`c`,`Comment on line`],[`Esc`,`Close / cancel`],[`?`,`Toggle this help`]];function ah({onClose:e}){return(0,j.jsxs)(rh,{onClose:e,children:[(0,j.jsx)(`h3`,{children:`Keyboard Shortcuts`}),(0,j.jsx)(`table`,{children:(0,j.jsx)(`tbody`,{children:ih.map(([e,t])=>(0,j.jsxs)(`tr`,{children:[(0,j.jsx)(`td`,{children:e.split(` / `).map((e,t)=>(0,j.jsxs)(`span`,{children:[t>0&&` / `,(0,j.jsx)(`kbd`,{children:e})]},e))}),(0,j.jsx)(`td`,{children:t})]},e))})})]})}function oh({onClose:e,vcs:t}){return(0,j.jsxs)(rh,{onClose:e,children:[(0,j.jsxs)(`p`,{children:[`Comments work by inserting lines into the code. This can only work if the`,` `,t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`revset includes `,(0,j.jsx)(`code`,{children:`@`})]}):`commit range includes the working copy`,`. Otherwise, the inserted lines will land in`,` `,t===`jj`?(0,j.jsx)(`code`,{children:`@`}):`the working copy`,` but not in the diff being viewed.`]}),t===`jj`?(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with the`,` `,(0,j.jsx)(`code`,{children:`-f`}),` flag to get a diff that starts at some revision and ends at`,` `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):(0,j.jsxs)(`p`,{children:[`The easiest way to get a diff with comments enabled is to run with `,(0,j.jsx)(`code`,{children:`-f`}),` `,`only (no `,(0,j.jsx)(`code`,{children:`-t`}),`), so the diff goes from a commit to the working tree.`]})]})}function sh({onLearnMore:e,vcs:t}){return(0,j.jsxs)(`div`,{className:`comments-disabled-banner`,children:[t===`jj`?(0,j.jsxs)(j.Fragment,{children:[`Comments are disabled because the revset does not include `,(0,j.jsx)(`code`,{children:`@`}),`.`]}):`Comments are disabled because the commit range does not end at the working copy.`,` `,(0,j.jsx)(`a`,{href:`#`,onClick:t=>{t.preventDefault(),e()},children:`Learn more`})]})}var ch=[`responsive`,`unified`];function lh(){let e=Im(),[t,n]=(0,A.useState)(`responsive`),r=t===`responsive`&&e?`split`:`unified`,{toast:i,showToast:a}=Lm(),[o,s]=(0,A.useState)({}),[c,l]=(0,A.useState)(null),u=(0,A.useRef)(!1),{data:d,error:f,isLoading:p}=ct({queryKey:[`diff`],queryFn:()=>Pm(`/api/diff`)});(0,A.useEffect)(()=>{d?.revset&&(document.title=`skepsis | ${d.revset}`)},[d?.revset]),(0,A.useEffect)(()=>{if(u.current||!d?.fileHashes)return;u.current=!0;let e={};for(let[t,n]of Object.entries(d.fileHashes))(d.viewed[t]===n||th(d.attrs[t]))&&(e[t]=!0);s(e)},[d]);let m=qe(),h=lt({mutationFn:({file:e,hash:t,mark:n})=>Pm(`/api/viewed`,{method:n?`POST`:`DELETE`,body:{file:e,hash:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),g=lt({mutationFn:({file:e,afterLine:t,text:n})=>Pm(`/api/comment`,{method:`POST`,body:{file:e,afterLine:t,text:n}}),onSuccess:()=>l(null),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),_=lt({mutationFn:({file:e,line:t})=>Pm(`/api/comment`,{method:`DELETE`,body:{file:e,line:t}}),onSettled:()=>m.invalidateQueries({queryKey:[`diff`]})}),v=d?.patch,y=(0,A.useMemo)(()=>v?qp(v).flatMap(e=>e.files):[],[v]),b=(0,A.useMemo)(()=>{let e={...d?.viewed};if(h.isPending&&h.variables){let{file:t,hash:n,mark:r}=h.variables;r?e[t]=n:delete e[t]}return e},[d,h.isPending,h.variables]),[x,S]=(0,A.useState)(0),[C,w]=(0,A.useState)(0),[ee,T]=(0,A.useState)(!1),[te,ne]=(0,A.useState)(!1),re=(0,A.useRef)(null),ie=(0,A.useRef)(0),ae=(0,A.useRef)({focusedFileIdx:0,cursorIdx:0,showHelp:!1,composing:null,files:[],collapsed:{},fileHashes:{},viewed:{},markMutate:h.mutate});ae.current={focusedFileIdx:x,cursorIdx:C,showHelp:ee,composing:c,files:y,collapsed:o,fileHashes:d?.fileHashes??{},viewed:b,markMutate:h.mutate},(0,A.useLayoutEffect)(()=>{Wm();let e=re.current;re.current=null,e===`file`&&document.querySelectorAll(`.file-card`)[x]?.scrollIntoView({block:`start`});let t=y[x];if(!t||(o[t.name]??!1))return;let n=Bm(x),r=n[Math.min(C,Math.max(n.length-1,0))];if(!r)return;let i=r.gutterEl.getRootNode();i instanceof ShadowRoot&&Gm(i),r.gutterEl.setAttribute(`data-selected-line`,`single`),r.contentEl.setAttribute(`data-selected-line`,`single`),e===`line`&&r.contentEl.scrollIntoView({block:`nearest`})},[x,C,v,c,o,y]),(0,A.useEffect)(()=>{function e(e){if(e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLInputElement||e.ctrlKey||e.metaKey||e.altKey)return;let t=ae.current;switch(e.key){case`?`:e.preventDefault(),T(e=>!e);break;case`Escape`:t.showHelp?(e.preventDefault(),T(!1)):t.composing&&(e.preventDefault(),l(null));break;case`n`:case`p`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=e.key===`n`?1:-1,r=Math.max(0,Math.min(t.focusedFileIdx+n,t.files.length-1));r!==t.focusedFileIdx&&(ie.current=Date.now(),S(r),w(0),re.current=`file`);break}case`j`:case`k`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx]?.name;if(!n||(t.collapsed[n]??!1))break;let r=Bm(t.focusedFileIdx);if(r.length===0)break;let i=t.cursorIdx,a=r[Math.min(i,r.length-1)];if(a&&!Um(a.contentEl,t.focusedFileIdx)){let e=Hm(t.focusedFileIdx);e>=0&&(i=e)}let o=Math.max(0,Math.min(i+(e.key===`j`?1:-1),r.length-1));o!==t.cursorIdx&&(ie.current=Date.now(),w(o),re.current=`line`);break}case`v`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;let r=t.fileHashes[n.name];if(!r)break;let i=t.viewed[n.name]!==r;s(e=>({...e,[n.name]:i})),t.markMutate({file:n.name,hash:r,mark:i});break}case`e`:{if(t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n)break;ie.current=Date.now(),s(e=>({...e,[n.name]:!(e[n.name]??!1)}));break}case`E`:{if(t.showHelp||t.composing)break;e.preventDefault(),ie.current=Date.now();let n=t.files.some(e=>!(t.collapsed[e.name]??!1));s(Object.fromEntries(t.files.map(e=>[e.name,n])));break}case`s`:if(t.showHelp||t.composing)break;e.preventDefault(),n(e=>{let t=ch[(ch.indexOf(e)+1)%ch.length];return a((0,j.jsxs)(j.Fragment,{children:[`Split mode: `,(0,j.jsx)(`code`,{children:t})]})),t});break;case`c`:{if(!d?.commentsEnabled||t.showHelp||t.composing)break;e.preventDefault();let n=t.files[t.focusedFileIdx];if(!n||(t.collapsed[n.name]??!1))break;let r=Bm(t.focusedFileIdx)[t.cursorIdx];if(!r||r.lineType===`change-deletion`)break;l({file:n.name,line:r.lineNumber});break}}}return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[a,d?.commentsEnabled]);let oe=(0,A.useRef)(new Set);if((0,A.useEffect)(()=>{oe.current.clear();let e=new IntersectionObserver(e=>{for(let t of e){let e=Number(t.target.dataset.fileIdx);t.isIntersecting?oe.current.add(e):oe.current.delete(e)}if(Date.now()-ie.current<150||oe.current.size===0)return;let t=Math.min(...oe.current),n=ae.current;if(t!==n.focusedFileIdx){S(t);let e=n.files[t];if(e&&!(n.collapsed[e.name]??!1)){let e=Hm(t);e>=0&&w(e)}}},{threshold:0});return document.querySelectorAll(`.file-card`).forEach((t,n)=>{t.dataset.fileIdx=String(n),e.observe(t)}),()=>e.disconnect()},[y]),p||!d)return(0,j.jsx)(`div`,{className:`empty-diff`,children:`Loading...`});if(f)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:String(f)});if(d.error)return(0,j.jsx)(`pre`,{style:{color:`red`,padding:20},children:d.error});if(!v)return(0,j.jsxs)(`div`,{className:`empty-diff`,children:[(0,j.jsx)(`code`,{children:d.revset}),` is empty`]});let{fileHashes:se}=d;return(0,j.jsxs)(j.Fragment,{children:[!d.commentsEnabled&&(0,j.jsx)(sh,{vcs:d.vcs,onLearnMore:()=>ne(!0)}),(0,j.jsxs)(`div`,{className:`diff-container`,children:[(0,j.jsx)(nh,{fileHashes:se,viewed:b,attrs:d.attrs}),ee&&(0,j.jsx)(ah,{onClose:()=>T(!1)}),te&&(0,j.jsx)(oh,{vcs:d.vcs,onClose:()=>ne(!1)}),i&&(0,j.jsx)(`div`,{className:`toast`,children:i.content},i.key),y.map((e,t)=>{let n=e.name,i=se[n],a=b[n],u=i!=null&&a===i,f=a!=null&&i!=null&&a!==i,p=o[n]??!1,m=c?.file===n?{line:c.line}:null;return(0,j.jsx)(eh,{fileDiff:e,attrs:d.attrs[n],isViewed:u,isStale:f,diffStyle:r,collapsed:p,composing:m,focused:t===x,onToggleCollapse:()=>s(e=>({...e,[n]:!p})),onToggleViewed:()=>{if(i){let e=!u;s(t=>({...t,[n]:e})),h.mutate({file:n,hash:i,mark:e})}},onStartComment:e=>{g.reset(),l({file:n,line:e})},onSubmitComment:(e,t)=>g.mutate({file:n,afterLine:e,text:t}),onResolveComment:e=>_.mutate({file:n,line:e}),onCancelComment:()=>l(null),commentsEnabled:d.commentsEnabled,submitting:g.isPending&&g.variables?.file===n,submitError:g.variables?.file===n&&g.error?g.error.message:null},n??t)})]})]})}function uh(){return(0,j.jsx)(Je,{client:Nm,children:(0,j.jsx)(lh,{})})}(0,u.createRoot)(document.getElementById(`root`)).render((0,j.jsx)(uh,{})); \ No newline at end of file diff --git a/dist/assets/index-BMNMHQlP.css b/dist/assets/index-BMNMHQlP.css new file mode 100644 index 0000000..2c73957 --- /dev/null +++ b/dist/assets/index-BMNMHQlP.css @@ -0,0 +1 @@ +body{color:#c9d1d9;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#151b23;margin:0;font-family:system-ui,-apple-system,sans-serif}.diff-container{flex-direction:column;gap:16px;max-width:1800px;margin:0 auto;padding:16px 24px;display:flex}.file-card{--diffs-font-size:13px;--diffs-font-family:monospace;--diffs-bg-separator-override:#1c2333;--diffs-modified-color-override:#1f6feb;border:1px solid #30363d;border-radius:8px;overflow:clip}.file-header{z-index:10;cursor:pointer;-webkit-user-select:none;user-select:none;background:#161b22;border-bottom:1px solid #30363d;align-items:center;gap:12px;padding:8px 16px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;display:flex;position:sticky;top:0}.file-card:has(>.file-header:only-child)>.file-header{border-bottom:none}.collapse-chevron{color:#8b949e;flex-shrink:0;font-size:10px;transition:transform .15s;transform:rotate(90deg)}.collapse-chevron.collapsed{transform:rotate(0)}.file-header-name{color:#e6edf3;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-weight:600;overflow:hidden}.file-header-stats{flex-shrink:0;gap:6px;font-family:monospace;font-size:12px;display:flex}.stat-add{color:#3fb950}.stat-del{color:#f85149}.attr-badge{color:#8b949e;text-transform:uppercase;letter-spacing:.04em;background:#161b22;border:1px solid #30363d;border-radius:10px;align-self:center;padding:1px 6px;font-family:-apple-system,system-ui,sans-serif;font-size:10px}.viewed-button{color:#8b949e;cursor:pointer;-webkit-user-select:none;user-select:none;background:#21262d;border:1px solid #30363d;border-radius:6px;flex-shrink:0;align-items:center;gap:6px;padding:4px 10px 4px 6px;font-family:inherit;font-size:12px;font-weight:500;display:flex}.viewed-button:hover{background:#292e36;border-color:#484f58}.viewed-button.checked{color:#58a6ff}.viewed-button.stale{color:#d29922}.viewed-checkbox{appearance:none;cursor:pointer;pointer-events:none;background:0 0;border:1px solid #484f58;border-radius:3px;flex-shrink:0;width:14px;height:14px;position:relative}.viewed-checkbox:checked{background:#1f6feb;border-color:#1f6feb}.viewed-checkbox:checked:after{content:"";border:2px solid #fff;border-width:0 2px 2px 0;width:5px;height:8px;position:absolute;top:0;left:3px;transform:rotate(45deg)}.viewed-checkbox.stale{border-color:#d29922}.viewed-checkbox.stale:checked{background:#9e6a03;border-color:#9e6a03}.progress-bar{color:#8b949e;align-items:center;gap:10px;padding:8px 0;font-family:system-ui,-apple-system,sans-serif;font-size:13px;display:flex}.progress-track{background:#21262d;border-radius:2px;flex:1;height:4px;overflow:hidden}.progress-fill{background:#238636;border-radius:2px;height:100%;transition:width .2s}.add-comment-button{color:#fff;cursor:pointer;background:#1f6feb;border:none;border-radius:50%;justify-content:center;align-items:center;width:22px;height:22px;padding:0;font-size:16px;font-weight:600;line-height:1;display:flex}.add-comment-button:hover{background:#388bfd}.comment-form{background:#1c2128;border-top:1px solid #30363d;padding:8px 12px}.comment-form textarea{color:#e6edf3;resize:vertical;box-sizing:border-box;background:#0d1117;border:1px solid #30363d;border-radius:6px;width:100%;min-height:60px;padding:8px;font-family:system-ui,-apple-system,sans-serif;font-size:14px}.comment-form textarea:focus{border-color:#1f6feb;outline:none}.comment-form-actions{justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;display:flex}.comment-form-error{color:#f85149;margin-right:auto;font-family:system-ui,-apple-system,sans-serif;font-size:13px}.btn{cursor:pointer;border-radius:6px;padding:6px 16px;font-family:system-ui,-apple-system,sans-serif;font-size:14px;font-weight:500}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-secondary{color:#e6edf3;background:#262c34;border:1px solid #484f58}.btn-secondary:hover:not(:disabled){background:#2d333b;border-color:#6e7681}.btn-primary{color:#fff;background:#238636;border:1px solid #0000}.btn-primary:hover:not(:disabled){background:#2ea043}.review-annotation{background:#1c2128;border-top:1px solid #30363d;justify-content:flex-end;padding:4px 12px;display:flex}.file-card.focused{border-color:#1f6feb}.file-card.focused>.file-header{border-bottom-color:#1f6feb}.help-modal{color:#e6edf3;background:#161b22;border:1px solid #30363d;border-radius:10px;min-width:320px;max-width:400px;padding:20px;font-family:system-ui,-apple-system,sans-serif}.help-modal p{margin:0 0 12px;font-size:14px}.help-modal p:last-of-type{margin:0}.help-modal:focus{outline:none}.help-modal::backdrop{background:#00000080}.help-modal h3{margin:0 0 16px;font-size:15px;font-weight:600}.help-modal table{border-spacing:0;width:100%}.help-modal td{padding:5px 0;font-size:13px}.help-modal td:first-child{white-space:nowrap;color:#8b949e;padding-right:24px}.toast{color:#e6edf3;z-index:200;pointer-events:none;background:#21262d;border:1px solid #484f58;border-radius:8px;padding:12px 20px;font-family:system-ui,-apple-system,sans-serif;font-size:16px;font-weight:500;animation:1.5s forwards toast-fade;position:fixed;bottom:24px;left:24px}@keyframes toast-fade{0%,60%{opacity:1}to{opacity:0}}.comments-disabled-banner{color:oklch(70% .14 260);text-align:center;background:oklch(22% .05 260);border-bottom:1px solid oklch(35% .07 260);padding:8px 16px;font-size:13px}.comments-disabled-banner a{color:oklch(78% .14 260);cursor:pointer;text-decoration:underline}.empty-diff{padding:20px;font-size:16px}.empty-diff code{color:#e6edf3;background:#30363d;border-radius:3px;padding:2px 4px;font-size:14px}.help-modal kbd{color:#e6edf3;text-align:center;background:#21262d;border:1px solid #30363d;border-radius:4px;min-width:18px;padding:2px 6px;font-family:monospace;font-size:12px;display:inline-block} diff --git a/dist/assets/index-rQG9O9IS.css b/dist/assets/index-rQG9O9IS.css deleted file mode 100644 index 9c0f150..0000000 --- a/dist/assets/index-rQG9O9IS.css +++ /dev/null @@ -1 +0,0 @@ -body{color:#c9d1d9;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#151b23;margin:0;font-family:system-ui,-apple-system,sans-serif}.diff-container{flex-direction:column;gap:16px;max-width:1800px;margin:0 auto;padding:16px 24px;display:flex}.file-card{--diffs-font-size:13px;--diffs-font-family:monospace;--diffs-bg-separator-override:#1c2333;--diffs-modified-color-override:#1f6feb;border:1px solid #30363d;border-radius:8px;overflow:clip}.file-header{z-index:10;cursor:pointer;-webkit-user-select:none;user-select:none;background:#161b22;border-bottom:1px solid #30363d;align-items:center;gap:12px;padding:8px 16px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;display:flex;position:sticky;top:0}.file-card:has(>.file-header:only-child)>.file-header{border-bottom:none}.collapse-chevron{color:#8b949e;flex-shrink:0;font-size:10px;transition:transform .15s;transform:rotate(90deg)}.collapse-chevron.collapsed{transform:rotate(0)}.file-header-name{color:#e6edf3;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-weight:600;overflow:hidden}.file-header-stats{flex-shrink:0;gap:6px;font-family:monospace;font-size:12px;display:flex}.stat-add{color:#3fb950}.stat-del{color:#f85149}.viewed-button{color:#8b949e;cursor:pointer;-webkit-user-select:none;user-select:none;background:#21262d;border:1px solid #30363d;border-radius:6px;flex-shrink:0;align-items:center;gap:6px;padding:4px 10px 4px 6px;font-family:inherit;font-size:12px;font-weight:500;display:flex}.viewed-button:hover{background:#292e36;border-color:#484f58}.viewed-button.checked{color:#58a6ff}.viewed-button.stale{color:#d29922}.viewed-checkbox{appearance:none;cursor:pointer;pointer-events:none;background:0 0;border:1px solid #484f58;border-radius:3px;flex-shrink:0;width:14px;height:14px;position:relative}.viewed-checkbox:checked{background:#1f6feb;border-color:#1f6feb}.viewed-checkbox:checked:after{content:"";border:2px solid #fff;border-width:0 2px 2px 0;width:5px;height:8px;position:absolute;top:0;left:3px;transform:rotate(45deg)}.viewed-checkbox.stale{border-color:#d29922}.viewed-checkbox.stale:checked{background:#9e6a03;border-color:#9e6a03}.progress-bar{color:#8b949e;align-items:center;gap:10px;padding:8px 0;font-family:system-ui,-apple-system,sans-serif;font-size:13px;display:flex}.progress-track{background:#21262d;border-radius:2px;flex:1;height:4px;overflow:hidden}.progress-fill{background:#238636;border-radius:2px;height:100%;transition:width .2s}.add-comment-button{color:#fff;cursor:pointer;background:#1f6feb;border:none;border-radius:50%;justify-content:center;align-items:center;width:22px;height:22px;padding:0;font-size:16px;font-weight:600;line-height:1;display:flex}.add-comment-button:hover{background:#388bfd}.comment-form{background:#1c2128;border-top:1px solid #30363d;padding:8px 12px}.comment-form textarea{color:#e6edf3;resize:vertical;box-sizing:border-box;background:#0d1117;border:1px solid #30363d;border-radius:6px;width:100%;min-height:60px;padding:8px;font-family:system-ui,-apple-system,sans-serif;font-size:14px}.comment-form textarea:focus{border-color:#1f6feb;outline:none}.comment-form-actions{justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;display:flex}.comment-form-error{color:#f85149;margin-right:auto;font-family:system-ui,-apple-system,sans-serif;font-size:13px}.btn{cursor:pointer;border-radius:6px;padding:6px 16px;font-family:system-ui,-apple-system,sans-serif;font-size:14px;font-weight:500}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-secondary{color:#e6edf3;background:#262c34;border:1px solid #484f58}.btn-secondary:hover:not(:disabled){background:#2d333b;border-color:#6e7681}.btn-primary{color:#fff;background:#238636;border:1px solid #0000}.btn-primary:hover:not(:disabled){background:#2ea043}.review-annotation{background:#1c2128;border-top:1px solid #30363d;justify-content:flex-end;padding:4px 12px;display:flex}.file-card.focused{border-color:#1f6feb}.file-card.focused>.file-header{border-bottom-color:#1f6feb}.help-modal{color:#e6edf3;background:#161b22;border:1px solid #30363d;border-radius:10px;min-width:320px;max-width:400px;padding:20px;font-family:system-ui,-apple-system,sans-serif}.help-modal p{margin:0 0 12px;font-size:14px}.help-modal p:last-of-type{margin:0}.help-modal:focus{outline:none}.help-modal::backdrop{background:#00000080}.help-modal h3{margin:0 0 16px;font-size:15px;font-weight:600}.help-modal table{border-spacing:0;width:100%}.help-modal td{padding:5px 0;font-size:13px}.help-modal td:first-child{white-space:nowrap;color:#8b949e;padding-right:24px}.toast{color:#e6edf3;z-index:200;pointer-events:none;background:#21262d;border:1px solid #484f58;border-radius:8px;padding:12px 20px;font-family:system-ui,-apple-system,sans-serif;font-size:16px;font-weight:500;animation:1.5s forwards toast-fade;position:fixed;bottom:24px;left:24px}@keyframes toast-fade{0%,60%{opacity:1}to{opacity:0}}.comments-disabled-banner{color:oklch(70% .14 260);text-align:center;background:oklch(22% .05 260);border-bottom:1px solid oklch(35% .07 260);padding:8px 16px;font-size:13px}.comments-disabled-banner a{color:oklch(78% .14 260);cursor:pointer;text-decoration:underline}.empty-diff{padding:20px;font-size:16px}.empty-diff code{color:#e6edf3;background:#30363d;border-radius:3px;padding:2px 4px;font-size:14px}.help-modal kbd{color:#e6edf3;text-align:center;background:#21262d;border:1px solid #30363d;border-radius:4px;min-width:18px;padding:2px 6px;font-family:monospace;font-size:12px;display:inline-block} diff --git a/dist/index.html b/dist/index.html index 347a6a3..44b6103 100644 --- a/dist/index.html +++ b/dist/index.html @@ -12,9 +12,9 @@ skepsis - + - +
diff --git a/src/App.tsx b/src/App.tsx index 683832b..ccbcce2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -427,8 +427,30 @@ const MemoizedFileDiff = memo( // --- FileCard --- +function AttrBadges({ attrs }: { attrs: FileAttrs | undefined }) { + if (!attrs) return null + const labels: { key: keyof FileAttrs; label: string }[] = [ + { key: 'generated', label: 'Generated' }, + { key: 'vendored', label: 'Vendored' }, + { key: 'documentation', label: 'Documentation' }, + { key: 'binary', label: 'Binary' }, + ] + const active = labels.filter((l) => attrs[l.key]) + if (active.length === 0) return null + return ( + <> + {active.map((l) => ( + + {l.label} + + ))} + + ) +} + function FileCard({ fileDiff, + attrs, isViewed, isStale, diffStyle, @@ -446,6 +468,7 @@ function FileCard({ submitError, }: { fileDiff: FileDiffMetadata + attrs: FileAttrs | undefined isViewed: boolean isStale: boolean diffStyle: 'split' | 'unified' @@ -597,6 +620,7 @@ function FileCard({ {additions > 0 && +{additions}} {deletions > 0 && -{deletions}} +