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.
363 lines
16 KiB
TypeScript
363 lines
16 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { SemanticFileBadge } from './SemanticFileBadge';
|
|
import { CallFlowFileBadge } from './CallFlowFileBadge';
|
|
import { OpenInAppButton } from '@plannotator/ui/components/OpenInAppButton';
|
|
import { useReviewStateOptional } from '../dock/ReviewStateContext';
|
|
import type { DiffFileStatus } from '../types';
|
|
|
|
interface FileHeaderProps {
|
|
/** Read-only host: no open-in affordance (it probes the review server). */
|
|
readOnly?: boolean;
|
|
filePath: string;
|
|
patch: string;
|
|
/** Change type — added/deleted/renamed get an icon; modified is undecorated. */
|
|
status?: DiffFileStatus;
|
|
/** Previous path for renames — rendered as "old → new" (diffshub treatment). */
|
|
oldPath?: string;
|
|
isViewed?: boolean;
|
|
onToggleViewed?: () => void;
|
|
/** Chrome preference: false hides the Viewed button (the `V` shortcut and
|
|
* viewed state are unaffected). */
|
|
showViewedControl?: boolean;
|
|
isStaged?: boolean;
|
|
isStaging?: boolean;
|
|
onStage?: () => void;
|
|
canStage?: boolean;
|
|
/** Same preference for the Git Add button (the `A` shortcut still works). */
|
|
showStageControl?: boolean;
|
|
stageError?: string | null;
|
|
onFileComment?: (anchorEl: HTMLElement) => void;
|
|
/**
|
|
* Eager registration of the comment button element on mount/unmount (ref
|
|
* callback semantics: element on attach, null on detach). Lets keyboard
|
|
* shortcuts anchor the comment popover without the button ever being
|
|
* clicked. onFileComment alone only surfaces the element on click.
|
|
*/
|
|
fileCommentButtonRef?: (el: HTMLButtonElement | null) => void;
|
|
collapseToggle?: React.ReactNode;
|
|
onCollapseToggle?: () => void;
|
|
/** EXPERIMENTAL edit-to-suggestion mode: enter edit mode on this file.
|
|
* Absent = the feature is off and no edit UI renders. */
|
|
onEditFile?: () => void;
|
|
/** This file currently hosts the active edit session. Suppresses the Edit
|
|
* entry button; the session controls live in the EditSessionHud strip the
|
|
* owner renders below this header, never here. */
|
|
isEditing?: boolean;
|
|
/** When set, the Edit button is disabled with this tooltip. */
|
|
editDisabledReason?: string | null;
|
|
/** Compact coarse-pointer treatment. Defaults to the enclosing review state. */
|
|
compactTouchLayout?: boolean;
|
|
/** Marked `linguist-generated` in `.gitattributes` (#1317) — renders a
|
|
* "generated" tag next to the +/- counts. Collapse seeding is the owner's
|
|
* concern; this prop is display-only. */
|
|
isGenerated?: boolean;
|
|
}
|
|
|
|
function splitFilePath(filePath: string): { directory: string; name: string } {
|
|
const lastSlash = filePath.lastIndexOf('/');
|
|
if (lastSlash === -1) {
|
|
return { directory: '', name: filePath };
|
|
}
|
|
|
|
return {
|
|
directory: filePath.slice(0, lastSlash + 1),
|
|
name: filePath.slice(lastSlash + 1),
|
|
};
|
|
}
|
|
|
|
function frontEllipsize(text: string, visibleChars: number): string {
|
|
if (text.length <= visibleChars) return text;
|
|
return `...${text.slice(-visibleChars)}`;
|
|
}
|
|
|
|
/**
|
|
* Change-type letter shown to the right of the +/- counts (matches the file
|
|
* tree): A added · D deleted · R renamed, colored so the critical changes pop.
|
|
* Modified is bare — the +/- counts already say it changed within the file.
|
|
*/
|
|
const STATUS_LETTER: Record<DiffFileStatus, { letter: string; className: string; title: string }> = {
|
|
added: { letter: 'A', className: 'text-success', title: 'Added file' },
|
|
modified: { letter: 'M', className: 'text-muted-foreground', title: 'Modified file' },
|
|
deleted: { letter: 'D', className: 'text-destructive', title: 'Deleted file' },
|
|
renamed: { letter: 'R', className: 'text-[#007aff]', title: 'Renamed file' },
|
|
};
|
|
|
|
const FileStatusLetter: React.FC<{ status: DiffFileStatus; oldPath?: string }> = ({ status, oldPath }) => {
|
|
// Match the file tree: only added/deleted/renamed get a badge; modified is
|
|
// bare (the +/- counts already convey that it changed within the file).
|
|
if (status === 'modified') return null;
|
|
const meta = STATUS_LETTER[status];
|
|
const title = status === 'renamed' && oldPath ? `Renamed from ${oldPath}` : meta.title;
|
|
return (
|
|
<span
|
|
className={`flex-none font-semibold leading-none ${meta.className}`}
|
|
title={title}
|
|
aria-label={title}
|
|
>
|
|
{meta.letter}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
/** Count +/- lines in a unified patch (ignores the +++/--- file headers). */
|
|
function countChanges(patch: string): { additions: number; deletions: number } {
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
for (const line of patch.split('\n')) {
|
|
if (line[0] === '+' && !line.startsWith('+++')) additions++;
|
|
else if (line[0] === '-' && !line.startsWith('---')) deletions++;
|
|
}
|
|
return { additions, deletions };
|
|
}
|
|
|
|
/** Sticky file header with file path, Viewed toggle, Git Add, and Copy Diff button */
|
|
export const FileHeader: React.FC<FileHeaderProps> = ({
|
|
filePath,
|
|
patch,
|
|
status,
|
|
oldPath,
|
|
isViewed = false,
|
|
onToggleViewed,
|
|
showViewedControl = true,
|
|
isStaged = false,
|
|
isStaging = false,
|
|
onStage,
|
|
canStage = false,
|
|
showStageControl = true,
|
|
stageError,
|
|
onFileComment,
|
|
fileCommentButtonRef,
|
|
collapseToggle,
|
|
onCollapseToggle,
|
|
onEditFile,
|
|
isEditing = false,
|
|
editDisabledReason,
|
|
compactTouchLayout,
|
|
readOnly = false,
|
|
isGenerated = false,
|
|
}) => {
|
|
const [headerWidth, setHeaderWidth] = useState<number>(0);
|
|
const state = useReviewStateOptional();
|
|
const isCompactTouchLayout = compactTouchLayout ?? state?.isCompactTouchLayout ?? false;
|
|
const headerRef = useRef<HTMLDivElement>(null);
|
|
const fileCommentRef = useRef<HTMLButtonElement>(null);
|
|
const { directory, name } = splitFilePath(filePath);
|
|
const isCompact = headerWidth > 0 && headerWidth < 760;
|
|
const isVeryTight = headerWidth > 0 && headerWidth < 480;
|
|
const showFilenameOnly = headerWidth > 0 && headerWidth < 560;
|
|
const truncatedName = showFilenameOnly
|
|
? frontEllipsize(
|
|
name,
|
|
headerWidth < 360 ? 14 : headerWidth < 420 ? 18 : headerWidth < 500 ? 24 : 32,
|
|
)
|
|
: name;
|
|
|
|
useEffect(() => {
|
|
if (!headerRef.current || typeof ResizeObserver === 'undefined') return;
|
|
|
|
const node = headerRef.current;
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
setHeaderWidth(entry.contentRect.width);
|
|
});
|
|
|
|
observer.observe(node);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const stageLabel = isVeryTight ? '' : isCompact ? (isStaging ? 'Adding' : isStaged ? 'Added' : 'Add') : (isStaging ? 'Adding...' : isStaged ? 'Added' : 'Git Add');
|
|
const commentLabel = isVeryTight ? '' : 'Comment';
|
|
const viewedLabel = isVeryTight ? '' : 'Viewed';
|
|
const { additions, deletions } = React.useMemo(() => countChanges(patch), [patch]);
|
|
|
|
return (
|
|
<div
|
|
ref={headerRef}
|
|
className={`flex-shrink-0 border-b border-border/50 flex items-center justify-between gap-2 transition-colors duration-150 hover:bg-muted/30 ${isCompactTouchLayout ? 'pr-3' : 'px-3'}`}
|
|
style={{ height: isCompactTouchLayout ? '44px' : 'var(--panel-header-h)' }}
|
|
>
|
|
<div className="min-w-0 flex flex-1 items-center" onClick={onCollapseToggle} style={onCollapseToggle ? { cursor: 'pointer' } : undefined}>
|
|
{collapseToggle}
|
|
<span
|
|
className={`min-w-0 flex items-center text-xs font-semibold leading-normal whitespace-nowrap ${isCompactTouchLayout ? 'flex-1' : ''}`}
|
|
title={status === 'renamed' && oldPath ? `${oldPath} → ${filePath}` : filePath}
|
|
>
|
|
{/* Rename: dimmed old path → new path (diffshub treatment). Dropped
|
|
under tight widths — the icon + tooltip still carry it. */}
|
|
{status === 'renamed' && oldPath && !showFilenameOnly && !isCompactTouchLayout && (
|
|
<>
|
|
<span className="min-w-0 overflow-hidden text-ellipsis text-muted-foreground/60">
|
|
{oldPath}
|
|
</span>
|
|
<svg
|
|
className="w-3 h-3 mx-1 flex-none text-muted-foreground/60"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14M13 6l6 6-6 6" />
|
|
</svg>
|
|
</>
|
|
)}
|
|
{isCompactTouchLayout ? (
|
|
/* Match Diffshub's filename treatment: retain the complete path in
|
|
* the accessible DOM and place the ellipsis at the leading edge so
|
|
* the basename/extension receive the available phone width. */
|
|
<span
|
|
data-pn-compact-file-path
|
|
className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-foreground [direction:rtl]"
|
|
>
|
|
<bdi>{filePath}</bdi>
|
|
</span>
|
|
) : (
|
|
<>
|
|
{!showFilenameOnly && directory && (
|
|
<span className="min-w-0 overflow-hidden text-ellipsis text-muted-foreground/70">
|
|
{directory}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={showFilenameOnly ? 'block min-w-0 overflow-hidden whitespace-nowrap text-foreground' : 'flex-none whitespace-nowrap text-foreground'}
|
|
>
|
|
{truncatedName}
|
|
</span>
|
|
</>
|
|
)}
|
|
</span>
|
|
{(additions > 0 || deletions > 0 || (status && status !== 'modified') || isGenerated) && (
|
|
<span className="flex-none ml-2 flex items-center gap-1.5 text-xs leading-none">
|
|
{additions > 0 && <span className="font-mono text-success">+{additions}</span>}
|
|
{deletions > 0 && <span className="font-mono text-destructive">-{deletions}</span>}
|
|
{status && <FileStatusLetter status={status} oldPath={oldPath} />}
|
|
{isGenerated && (
|
|
<span
|
|
data-pn-generated-badge
|
|
className="flex-none rounded-sm border border-border/60 bg-muted/60 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground"
|
|
title="Marked linguist-generated in .gitattributes"
|
|
>
|
|
generated
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{!isCompactTouchLayout && <div className={`flex flex-shrink-0 items-center pl-2 ${isCompact ? 'gap-1' : 'gap-2'}`}>
|
|
{showViewedControl && onToggleViewed && (
|
|
<button
|
|
onClick={onToggleViewed}
|
|
className={`text-xs rounded transition-colors flex items-center ${viewedLabel ? 'gap-1 px-2 py-1' : 'px-1.5 py-1'} ${
|
|
isViewed
|
|
? 'bg-success/15 text-success'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
}`}
|
|
title={isViewed ? "Mark as not viewed (V)" : "Mark as viewed (V)"}
|
|
>
|
|
{isViewed ? (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
</svg>
|
|
)}
|
|
{viewedLabel && <span>{viewedLabel}</span>}
|
|
</button>
|
|
)}
|
|
{showStageControl && canStage && onStage && (
|
|
<button
|
|
onClick={onStage}
|
|
disabled={isStaging}
|
|
className={`text-xs rounded transition-colors flex items-center ${stageLabel ? 'gap-1 px-2 py-1' : 'px-1.5 py-1'} ${
|
|
isStaging
|
|
? 'opacity-50 cursor-not-allowed text-muted-foreground'
|
|
: isStaged
|
|
? 'bg-primary/15 text-primary'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
}`}
|
|
title={isStaged ? "Unstage this file (A)" : "Stage this file (A)"}
|
|
>
|
|
{isStaging ? (
|
|
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
) : isStaged ? (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
)}
|
|
{stageLabel && <span>{stageLabel}</span>}
|
|
</button>
|
|
)}
|
|
{stageError && (
|
|
<span className="max-w-24 truncate text-xs text-destructive" title={stageError}>
|
|
{stageError}
|
|
</span>
|
|
)}
|
|
{onFileComment && (
|
|
<button
|
|
ref={(el) => {
|
|
fileCommentRef.current = el;
|
|
fileCommentButtonRef?.(el);
|
|
}}
|
|
onClick={() => fileCommentRef.current && onFileComment(fileCommentRef.current)}
|
|
className={`text-xs rounded transition-colors flex items-center text-muted-foreground hover:text-foreground hover:bg-muted ${commentLabel ? 'gap-1 px-2 py-1' : 'px-1.5 py-1'}`}
|
|
title="Add file-scoped comment"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-4l-4 4v-4z" />
|
|
</svg>
|
|
{commentLabel && <span>{commentLabel}</span>}
|
|
</button>
|
|
)}
|
|
<CallFlowFileBadge filePath={filePath} oldPath={oldPath} />
|
|
<SemanticFileBadge filePath={filePath} />
|
|
{/* Edit entry lives at the far right of the row, next to the file
|
|
actions dropdown, so the experimental affordance stays out of the
|
|
everyday Viewed/Add/Comment cluster. */}
|
|
{onEditFile && !isEditing && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (!editDisabledReason) onEditFile();
|
|
}}
|
|
disabled={!!editDisabledReason}
|
|
className={`text-xs rounded transition-colors flex items-center ${isVeryTight ? 'px-1.5 py-1' : 'gap-1 px-2 py-1'} ${
|
|
editDisabledReason
|
|
? 'opacity-50 cursor-not-allowed text-muted-foreground'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
}`}
|
|
title={editDisabledReason ?? 'Edit this file to author a suggestion (experimental)'}
|
|
data-testid="edit-session-start"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
{!isVeryTight && <span>Edit</span>}
|
|
</button>
|
|
)}
|
|
{/* File actions: open in app (when the live checkout matches this
|
|
snapshot), copy path, copy file diff. Copy actions remain when a
|
|
PR has no checkout or a committed GitButler layer is selected. */}
|
|
{/* Icon-only in the header (the picked app's name shows in the dropdown),
|
|
matching the plan/annotate side. */}
|
|
{!readOnly && <OpenInAppButton
|
|
filePath={filePath}
|
|
base={state?.agentCwd ?? null}
|
|
diffText={patch}
|
|
canOpen={
|
|
state?.canUseLiveWorkspaceActions !== false &&
|
|
!(state?.prMetadata && !state?.agentCwd) &&
|
|
status !== 'deleted'
|
|
}
|
|
/>}
|
|
</div>}
|
|
</div>
|
|
);
|
|
};
|