mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
15f8d4fe4c
* feat(review): collapse linguist-generated files by default (#1317) Code review now respects linguist-generated (and linguist-generated=true) from .gitattributes, collapsing those diffs by default the way GitHub does. Server (Bun + Pi mirror): a generatedFiles sidecar rides /api/diff and /api/diff/switch, resolved through git's own attribute machinery — one batched 'git check-attr --stdin -z' over the served patch's paths at the review cwd, so stacked and negated rules land exactly as git resolves them. Plain local git sessions only; PR worktrees, workspace multi-repo, jj, GitButler, and P4 omit the sidecar (degrade to no-collapse). Shared logic in packages/shared/generated-files.ts, vendored to Pi. Client: generated files SEED their CodeView item collapsed (the existing Pierre collapse state — same mechanism as commit-diff folding), render the one-line FileHeader bar with a 'generated' tag next to the +/- counts, and expand per file on click. Expansion is session-local App state so it survives remounts and diff switches. Presentation-only: the diff data, annotations, search, and Edit Mode are untouched; the file tree and single-file tabs list generated files normally (tag, no auto-collapse). Guide viewer manifest pin regenerated (AllFilesCodeView/FileHeader are bundled into the guides.show viewer) from a clean frozen-lockfile install. * feat(review): built-in generated defaults, visible collapsed strip, review-round fixes (#1317) Round 2 on PR #1346, per maintainer review. Built-in generated defaults (industry-standard two-layer detection): packages/shared/generated-files.ts (vendored to Pi) now carries DEFAULT_GENERATED_PATTERNS — lockfiles (package-lock.json, yarn.lock, bun.lock, Cargo.lock, go.sum, ...) plus *.min.js / *.min.css / *.map — matched against the path's last segment only. Explicit .gitattributes wins in BOTH directions: linguist-generated (set/true) marks any file, -linguist-generated / =false un-marks even a built-in name, unspecified falls through to the defaults. In plain local git sessions check-attr refines the defaults; the non-git degrade modes (piped patches, PR worktrees, workspace, jj, GitButler, P4) now emit the sidecar from the name-based defaults alone instead of omitting it. Visible collapsed state: a collapsed generated card no longer renders as a bare header — a GeneratedFileNotice strip ('Generated file collapsed', +N/-N, 'Click to view') styled like the other below-header notices sits in the card, and clicking it expands through the SAME reportFileCollapsed funnel as the chevron. Review findings: - F1: search-match and sidebar-comment navigation expanded items without reporting through the funnel, so those expansions died on diff switch. Both now call syncAllCollapsedMirror + reportFileCollapsed; the funnel invariant comment lists the navigation-driven sites. - F2: the check-attr call gets the same 5000ms timeout as review-core's stdin git callers, and Pi's vcs.ts stdin write gets the one-line EPIPE guard (call-flow.ts shape) a timeout kill makes reachable. - F3: removed the dead prevGeneratedRef + collectSetDelta leg — a changed generated set always remounts via fileSetKey, so the delta path was unreachable. Tests: default-list matching (glob + directory-named-bun.lock), both- direction precedence, non-git sidecar from defaults (dual-runtime), the placeholder strip through the funnel, and search expansion surviving a re-seed round-trip. AGENTS.md payload docs updated. Guide viewer manifest pin regenerated from this clean frozen-lockfile worktree.
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
/**
|
|
* DOM-gated tests (DOM_TESTS=1) for generated-file default collapse (#1317).
|
|
* Registered in .github/workflows/test.yml's "Run UI seam-contract + DOM
|
|
* tests" step.
|
|
*
|
|
* The behaviors under guard:
|
|
* - files in `generatedFiles` SEED their CodeView item collapsed (Pierre
|
|
* renders a collapsed item as its header bar only), other files don't;
|
|
* - collapse is a VIEW state, never a data filter: a collapsed generated
|
|
* item still carries its full fileDiff and seeded line annotations;
|
|
* - the header chevron expands in place (no CodeView remount) and reports
|
|
* the expansion to the owner so it can outlive remounts;
|
|
* - a file listed in `expandedGeneratedFiles` seeds expanded — the
|
|
* remount-survival half of the session contract;
|
|
* - a collapsed generated card shows the explicit placeholder strip, whose
|
|
* click expands through the SAME collapse-report funnel as the chevron;
|
|
* - search-match navigation expands through the funnel too, so a
|
|
* search-driven expansion survives a diff-switch re-seed.
|
|
*/
|
|
import { afterAll, afterEach, describe, expect, mock, test } from 'bun:test';
|
|
import React, { act, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import type { CodeAnnotation } from '@plannotator/ui/types';
|
|
import type { DiffFile } from '../types';
|
|
|
|
let codeViewMounts = 0;
|
|
let lastCodeViewProps: Record<string, unknown> | null = null;
|
|
|
|
// Same capture/restore idiom as AllFilesCodeView.lifecycle.test.tsx — the
|
|
// SPREAD is load-bearing (mock.module rewrites the live module record).
|
|
const realPierreDiffs = { ...(await import('@pierre/diffs')) };
|
|
const realPierreDiffsReact = { ...(await import('@pierre/diffs/react')) };
|
|
const realResolveSyntaxTheme = (await import('@plannotator/ui/utils/syntaxTheme')).resolveSyntaxTheme;
|
|
|
|
mock.module('../workerPool', () => ({
|
|
useIsWorkerPoolReadyOrDisabled: () => true,
|
|
useWorkerPoolThemeSync: () => {},
|
|
}));
|
|
|
|
mock.module('../hooks/usePierreTheme', () => ({
|
|
buildLineBgOverrides: () => '',
|
|
resolveSyntaxTheme: realResolveSyntaxTheme,
|
|
usePierreTheme: () => ({ type: 'light', css: '' }),
|
|
}));
|
|
|
|
mock.module('@pierre/diffs', () => ({
|
|
getSingularPatch: (patch: string) => ({
|
|
name: /diff --git a\/(\S+)/.exec(patch)?.[1] ?? 'file.ts',
|
|
type: 'change',
|
|
hunks: [],
|
|
splitLineCount: 1,
|
|
unifiedLineCount: 1,
|
|
isPartial: true,
|
|
deletionLines: [],
|
|
additionLines: [],
|
|
}),
|
|
processFile: () => null,
|
|
}));
|
|
|
|
mock.module('@pierre/diffs/react', () => ({
|
|
CodeView: React.forwardRef(function MockCodeView(
|
|
props: {
|
|
initialItems?: Array<{ id: string }>;
|
|
className?: string;
|
|
containerRef?: React.Ref<HTMLDivElement>;
|
|
},
|
|
ref: React.ForwardedRef<unknown>,
|
|
) {
|
|
const itemsRef = useRef(new Map((props.initialItems ?? []).map((item) => [item.id, item])));
|
|
lastCodeViewProps = props as unknown as Record<string, unknown>;
|
|
useEffect(() => {
|
|
codeViewMounts += 1;
|
|
}, []);
|
|
useImperativeHandle(ref, () => ({
|
|
addItems: () => {},
|
|
getItem: (id: string) => itemsRef.current.get(id),
|
|
updateItem: (item: { id: string }) => {
|
|
itemsRef.current.set(item.id, item);
|
|
return true;
|
|
},
|
|
updateItemId: () => true,
|
|
scrollTo: () => {},
|
|
setSelectedLines: () => {},
|
|
getSelectedLines: () => null,
|
|
clearSelectedLines: () => {},
|
|
getInstance: () => ({
|
|
getRenderedItems: () => [],
|
|
getScrollTop: () => 0,
|
|
getScrollHeight: () => 0,
|
|
getHeight: () => 0,
|
|
getTopForItem: () => 0,
|
|
scrollTo: () => {},
|
|
}),
|
|
}));
|
|
return <div ref={props.containerRef} className={props.className} />;
|
|
}),
|
|
EditProvider: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
useStableCallback: <T extends (...args: never[]) => unknown>(callback: T): T => {
|
|
const callbackRef = useRef(callback);
|
|
callbackRef.current = callback;
|
|
return useCallback(((...args: Parameters<T>) => callbackRef.current(...args)) as T, []);
|
|
},
|
|
}));
|
|
|
|
mock.module('./ToolbarHost', () => ({
|
|
ToolbarHost: React.forwardRef(function MockToolbarHost(_props, ref) {
|
|
useImperativeHandle(ref, () => ({
|
|
handleLineSelectionEnd: () => {},
|
|
openLineAnnotation: () => {},
|
|
handleTokenClick: () => {},
|
|
startEdit: () => {},
|
|
}));
|
|
return null;
|
|
}),
|
|
}));
|
|
|
|
const { AllFilesCodeView } = await import('./AllFilesCodeView');
|
|
|
|
const hasDom = typeof document !== 'undefined';
|
|
let root: Root | null = null;
|
|
let host: HTMLElement | null = null;
|
|
let headerRoot: Root | null = null;
|
|
let headerHost: HTMLElement | null = null;
|
|
|
|
function makeFile(path: string): DiffFile {
|
|
return {
|
|
path,
|
|
patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+new`,
|
|
additions: 1,
|
|
deletions: 1,
|
|
status: 'modified',
|
|
};
|
|
}
|
|
|
|
const generatedFile = makeFile('gen/schema.sql');
|
|
const normalFile = makeFile('src/app.ts');
|
|
|
|
type Props = Partial<React.ComponentProps<typeof AllFilesCodeView>>;
|
|
|
|
async function render(overrides: Props = {}) {
|
|
if (!host) {
|
|
host = document.createElement('div');
|
|
host.style.height = '400px';
|
|
document.body.appendChild(host);
|
|
root = createRoot(host);
|
|
}
|
|
await act(async () => {
|
|
root!.render(
|
|
<AllFilesCodeView
|
|
files={[generatedFile, normalFile]}
|
|
diffStyle="unified"
|
|
annotations={[]}
|
|
selectedAnnotationId={null}
|
|
scrollTargetAnnotation={null}
|
|
pendingSelection={null}
|
|
onLineSelection={() => {}}
|
|
onAddAnnotationForFile={() => {}}
|
|
onEditAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
onDeleteAnnotation={() => {}}
|
|
generatedFiles={new Set(['gen/schema.sql'])}
|
|
{...overrides}
|
|
/>,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
});
|
|
}
|
|
|
|
function seededItems(): Array<{ id: string; collapsed?: boolean; fileDiff?: unknown; annotations?: unknown[] }> {
|
|
return (lastCodeViewProps?.initialItems ?? []) as Array<{
|
|
id: string;
|
|
collapsed?: boolean;
|
|
fileDiff?: unknown;
|
|
annotations?: unknown[];
|
|
}>;
|
|
}
|
|
|
|
async function renderHeaderFor(itemId: string): Promise<HTMLElement> {
|
|
const renderCustomHeader = lastCodeViewProps?.renderCustomHeader as
|
|
| ((item: { id: string }) => React.ReactNode)
|
|
| undefined;
|
|
expect(renderCustomHeader).toBeDefined();
|
|
const item = seededItems().find((i) => i.id === itemId);
|
|
expect(item).toBeDefined();
|
|
if (!headerHost) {
|
|
headerHost = document.createElement('div');
|
|
document.body.appendChild(headerHost);
|
|
headerRoot = createRoot(headerHost);
|
|
}
|
|
await act(async () => {
|
|
headerRoot!.render(<>{renderCustomHeader!(item as { id: string })}</>);
|
|
});
|
|
return headerHost;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
if (root) await act(async () => root?.unmount());
|
|
if (headerRoot) await act(async () => headerRoot?.unmount());
|
|
root = null;
|
|
headerRoot = null;
|
|
host?.remove();
|
|
headerHost?.remove();
|
|
host = null;
|
|
headerHost = null;
|
|
codeViewMounts = 0;
|
|
lastCodeViewProps = null;
|
|
});
|
|
|
|
afterAll(() => {
|
|
mock.module('@pierre/diffs', () => realPierreDiffs);
|
|
mock.module('@pierre/diffs/react', () => realPierreDiffsReact);
|
|
});
|
|
|
|
describe.if(hasDom)('generated-file default collapse (#1317)', () => {
|
|
test('generated files seed collapsed with their diff data and annotations intact; others seed expanded', async () => {
|
|
const annotation = {
|
|
id: 'a1',
|
|
type: 'comment',
|
|
filePath: 'gen/schema.sql',
|
|
lineStart: 1,
|
|
lineEnd: 1,
|
|
side: 'new',
|
|
text: 'why is this regenerated?',
|
|
createdAt: 1,
|
|
} as CodeAnnotation;
|
|
await render({ annotations: [annotation] });
|
|
|
|
const gen = seededItems().find((i) => i.id === 'gen/schema.sql');
|
|
const normal = seededItems().find((i) => i.id === 'src/app.ts');
|
|
expect(gen?.collapsed).toBe(true);
|
|
expect(normal?.collapsed).toBeUndefined();
|
|
// Collapse is a view seed, never a data filter: the collapsed item still
|
|
// carries the parsed diff and the projected line annotation.
|
|
expect(gen?.fileDiff).toBeDefined();
|
|
expect(gen?.annotations).toHaveLength(1);
|
|
});
|
|
|
|
test('the header chevron expands in place, reports to the owner, and shows the generated tag', async () => {
|
|
const reports: Array<[string, boolean]> = [];
|
|
await render({
|
|
onGeneratedFileCollapsedChange: (path, collapsed) => reports.push([path, collapsed]),
|
|
});
|
|
expect(codeViewMounts).toBe(1);
|
|
|
|
const collapsedHeader = await renderHeaderFor('gen/schema.sql');
|
|
expect(collapsedHeader.querySelector('[data-pn-generated-badge]')).not.toBeNull();
|
|
const chevron = collapsedHeader.querySelector<HTMLButtonElement>('button[title="Expand diff"]');
|
|
expect(chevron).not.toBeNull();
|
|
|
|
await act(async () => chevron!.click());
|
|
expect(reports).toEqual([['gen/schema.sql', false]]);
|
|
// Expansion is live item state — no CodeView remount (which would lose
|
|
// scroll/selection state).
|
|
expect(codeViewMounts).toBe(1);
|
|
|
|
// The header re-render reflects the expanded item.
|
|
const expandedHeader = await renderHeaderFor('gen/schema.sql');
|
|
expect(expandedHeader.querySelector('button[title="Collapse diff"]')).not.toBeNull();
|
|
|
|
// A normal file's header carries no generated tag.
|
|
const normalHeader = await renderHeaderFor('src/app.ts');
|
|
expect(normalHeader.querySelector('[data-pn-generated-badge]')).toBeNull();
|
|
});
|
|
|
|
test('a file the user already expanded seeds expanded (remount survival)', async () => {
|
|
await render({ expandedGeneratedFiles: new Set(['gen/schema.sql']) });
|
|
const gen = seededItems().find((i) => i.id === 'gen/schema.sql');
|
|
expect(gen?.collapsed).toBeUndefined();
|
|
});
|
|
|
|
test('the collapsed card shows the placeholder strip, which expands through the funnel', async () => {
|
|
const reports: Array<[string, boolean]> = [];
|
|
await render({
|
|
onGeneratedFileCollapsedChange: (path, collapsed) => reports.push([path, collapsed]),
|
|
});
|
|
|
|
const collapsedHeader = await renderHeaderFor('gen/schema.sql');
|
|
const strip = collapsedHeader.querySelector<HTMLButtonElement>('[data-pn-generated-collapsed-notice]');
|
|
expect(strip).not.toBeNull();
|
|
// The fold is explicit, not a failed render: the strip carries the file's
|
|
// +/- counts (server-derived data, so assert the data, not the prose).
|
|
expect(strip!.textContent).toContain('+1');
|
|
expect(strip!.textContent).toContain('-1');
|
|
|
|
await act(async () => strip!.click());
|
|
// Same funnel as the chevron: the expansion reports to the owner and the
|
|
// live item expands in place (no CodeView remount).
|
|
expect(reports).toEqual([['gen/schema.sql', false]]);
|
|
expect(seededItems().find((i) => i.id === 'gen/schema.sql')?.collapsed).toBe(false);
|
|
expect(codeViewMounts).toBe(1);
|
|
|
|
// The re-rendered expanded card drops the strip.
|
|
const expandedHeader = await renderHeaderFor('gen/schema.sql');
|
|
expect(expandedHeader.querySelector('[data-pn-generated-collapsed-notice]')).toBeNull();
|
|
});
|
|
|
|
test('search-match navigation expands through the funnel, surviving a re-seed round-trip', async () => {
|
|
const reports: Array<[string, boolean]> = [];
|
|
const onChange = (path: string, collapsed: boolean) => reports.push([path, collapsed]);
|
|
await render({ onGeneratedFileCollapsedChange: onChange });
|
|
expect(seededItems().find((i) => i.id === 'gen/schema.sql')?.collapsed).toBe(true);
|
|
|
|
// Search navigation lands in the collapsed generated file.
|
|
await render({
|
|
onGeneratedFileCollapsedChange: onChange,
|
|
activeSearchMatch: {
|
|
id: 'm1',
|
|
filePath: 'gen/schema.sql',
|
|
side: 'addition',
|
|
lineNumber: 1,
|
|
text: 'new',
|
|
matchStart: 0,
|
|
matchEnd: 3,
|
|
snippet: 'new',
|
|
},
|
|
});
|
|
// The expansion reached the owner — before the F1 fix this site mutated
|
|
// item.collapsed directly and the report was silently dropped, so the
|
|
// expansion died on the next diff switch.
|
|
expect(reports).toEqual([['gen/schema.sql', false]]);
|
|
|
|
// Round trip: the owner feeds the reported expansion back while a diff
|
|
// switch re-seeds items (new files identity + snapshot id) — the file the
|
|
// search opened stays open.
|
|
const expanded = new Set(reports.filter(([, collapsed]) => !collapsed).map(([path]) => path));
|
|
await render({
|
|
files: [makeFile('gen/schema.sql'), makeFile('src/app.ts')],
|
|
reviewSnapshotId: 'post-switch',
|
|
generatedFiles: new Set(['gen/schema.sql']),
|
|
expandedGeneratedFiles: expanded,
|
|
});
|
|
expect(seededItems().find((i) => i.id === 'gen/schema.sql')?.collapsed).toBeUndefined();
|
|
});
|
|
});
|