mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
afac44b940
Replaces the all-files renderer with Pierre's virtualized CodeView (one scroller, identical look, scales to huge diffs); deletes the legacy AllFilesDiffView/LazyFileDiff and their flag; single-file panels stay on FileDiff. @pierre/diffs pinned exact 1.2.8 (1.2.9's tree is broken on npm — see #880). Also ships: diff staleness detection ('Diff out of date · Refresh' toolbar notice backed by shared per-VCS fingerprints + GET /api/diff/fresh on both Bun and Pi servers), a stale-content guard so files edited mid-review degrade to raw-patch view instead of breaking virtualization, the Ask AI wrong-file fix (pre-existing), and toolbar/header UI polish (global settings cog, aligned sem badges, no all-files split dragger). Hardened through five review waves; tested with 1384 passing tests incl. real-git fingerprint and patch-consistency suites. Known minor gaps documented in the PR.
526 lines
17 KiB
TypeScript
526 lines
17 KiB
TypeScript
import {
|
|
type DiffResult,
|
|
type DiffType,
|
|
type GitContext,
|
|
type GitDiffOptions,
|
|
type ReviewGitRuntime,
|
|
detectRemoteDefaultBranch,
|
|
getFileContentsForDiff as getGitFileContentsForDiff,
|
|
getGitContext,
|
|
getGitDiffFingerprint,
|
|
gitAddFile,
|
|
gitResetFile,
|
|
parseWorktreeDiffType,
|
|
runGitDiff,
|
|
} from "./review-core";
|
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
import {
|
|
type ReviewJjRuntime,
|
|
detectJjWorkspace,
|
|
getJjContext,
|
|
getJjDiffFingerprint,
|
|
getJjFileContentsForDiff,
|
|
runJjDiff,
|
|
} from "./jj-core";
|
|
|
|
export type {
|
|
DiffOption,
|
|
DiffResult,
|
|
DiffType,
|
|
GitContext,
|
|
GitDiffOptions,
|
|
WorktreeInfo,
|
|
} from "./review-core";
|
|
|
|
export {
|
|
JJ_TRUNK_REVSET,
|
|
jjCompareTargetRevset,
|
|
jjLineBaseRevset,
|
|
parseRemoteBookmark,
|
|
parseWorktreeDiffType,
|
|
validateFilePath,
|
|
} from "./review-core";
|
|
|
|
export interface VcsProvider {
|
|
readonly id: string;
|
|
detect(cwd?: string): Promise<boolean>;
|
|
getRoot?(cwd?: string): Promise<string | null>;
|
|
ownsDiffType(diffType: string): boolean;
|
|
canStageFiles?(diffType: string): boolean;
|
|
getContext(cwd?: string): Promise<GitContext>;
|
|
runDiff(diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions): Promise<DiffResult>;
|
|
getFileContents(
|
|
diffType: DiffType,
|
|
defaultBranch: string,
|
|
filePath: string,
|
|
oldPath?: string,
|
|
cwd?: string,
|
|
): Promise<{ oldContent: string | null; newContent: string | null }>;
|
|
/** Cheap staleness fingerprint for a diff (see review-core/jj-core). Providers
|
|
* without an implementation (e.g. p4) are treated as always-fresh. */
|
|
getDiffFingerprint?(
|
|
diffType: DiffType,
|
|
defaultBranch: string,
|
|
cwd?: string,
|
|
options?: GitDiffOptions,
|
|
): Promise<string | null>;
|
|
stageFile?(filePath: string, cwd?: string): Promise<void>;
|
|
unstageFile?(filePath: string, cwd?: string): Promise<void>;
|
|
resolveCwd?(diffType: string, fallbackCwd?: string): string | undefined;
|
|
detectRemoteDefaultCompareTarget?(cwd?: string): Promise<string | null>;
|
|
}
|
|
|
|
export type VcsSelection = "auto" | "git" | "jj" | "p4";
|
|
|
|
export interface VcsApi {
|
|
detectVcs(cwd?: string): Promise<VcsProvider>;
|
|
detectManagedVcs(cwd?: string, vcsType?: VcsSelection): Promise<VcsProvider | null>;
|
|
getVcsContext(cwd?: string, vcsType?: VcsSelection): Promise<GitContext>;
|
|
detectRemoteDefaultCompareTarget(cwd?: string, vcsType?: VcsSelection): Promise<string | null>;
|
|
prepareLocalReviewDiff(options: PrepareLocalReviewDiffOptions): Promise<PreparedLocalReviewDiff>;
|
|
runVcsDiff(
|
|
diffType: DiffType,
|
|
defaultBranch?: string,
|
|
cwd?: string,
|
|
options?: GitDiffOptions,
|
|
): Promise<DiffResult>;
|
|
getVcsFileContentsForDiff(
|
|
diffType: DiffType,
|
|
defaultBranch: string,
|
|
filePath: string,
|
|
oldPath?: string,
|
|
cwd?: string,
|
|
): Promise<{ oldContent: string | null; newContent: string | null }>;
|
|
/** Best-effort staleness fingerprint for the given diff parameters. `null`
|
|
* means "cannot fingerprint" and must be treated as always-fresh. */
|
|
getVcsDiffFingerprint(
|
|
diffType: DiffType,
|
|
defaultBranch?: string,
|
|
cwd?: string,
|
|
options?: GitDiffOptions,
|
|
): Promise<string | null>;
|
|
canStageFiles(diffType: string, cwd?: string): Promise<boolean>;
|
|
stageFile(diffType: string, filePath: string, cwd?: string): Promise<void>;
|
|
unstageFile(diffType: string, filePath: string, cwd?: string): Promise<void>;
|
|
resolveVcsCwd(diffType: string, fallbackCwd?: string): string | undefined;
|
|
}
|
|
|
|
export interface PrepareLocalReviewDiffOptions {
|
|
cwd?: string;
|
|
vcsType?: VcsSelection;
|
|
requestedDiffType?: DiffType;
|
|
requestedBase?: string;
|
|
configuredDiffType: DiffType;
|
|
hideWhitespace?: boolean;
|
|
}
|
|
|
|
export interface PreparedLocalReviewDiff {
|
|
gitContext: GitContext;
|
|
diffType: DiffType;
|
|
base: string;
|
|
rawPatch: string;
|
|
gitRef: string;
|
|
error?: string;
|
|
}
|
|
|
|
const GIT_DIFF_TYPES = new Set(["uncommitted", "staged", "unstaged", "last-commit", "branch", "merge-base", "all"]);
|
|
const JJ_DIFF_TYPES = new Set(["jj-current", "jj-last", "jj-line", "jj-evolog", "jj-all"]);
|
|
|
|
function selectNearestProvider(
|
|
candidates: Array<{ provider: VcsProvider; root: string | null; order: number }>,
|
|
cwd?: string,
|
|
): VcsProvider | null {
|
|
if (candidates.length === 0) return null;
|
|
|
|
const effectiveCwd = resolve(cwd ?? process.cwd());
|
|
const ranked = candidates
|
|
.map((candidate) => ({
|
|
...candidate,
|
|
rootDepth: candidate.root ? vcsRootDepth(candidate.root) : -1,
|
|
containsCwd: candidate.root ? isSameOrAncestor(candidate.root, effectiveCwd) : false,
|
|
}))
|
|
.sort((a, b) => {
|
|
if (a.containsCwd !== b.containsCwd) return a.containsCwd ? -1 : 1;
|
|
if (a.rootDepth !== b.rootDepth) return b.rootDepth - a.rootDepth;
|
|
return a.order - b.order;
|
|
});
|
|
|
|
return ranked[0]?.provider ?? null;
|
|
}
|
|
|
|
function isSameOrAncestor(root: string, child: string): boolean {
|
|
const relativePath = relative(resolve(root), child);
|
|
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
}
|
|
|
|
function vcsRootDepth(root: string): number {
|
|
return resolve(root).split(/[\\/]+/).filter(Boolean).length;
|
|
}
|
|
|
|
export function createGitProvider(runtime: ReviewGitRuntime): VcsProvider {
|
|
return {
|
|
id: "git",
|
|
|
|
async detect(cwd?: string): Promise<boolean> {
|
|
try {
|
|
const result = await runtime.runGit(["rev-parse", "--is-inside-work-tree"], { cwd });
|
|
return result.exitCode === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
async getRoot(cwd?: string): Promise<string | null> {
|
|
const result = await runtime.runGit(["rev-parse", "--show-toplevel"], { cwd });
|
|
return result.exitCode === 0 ? result.stdout.trim() || null : null;
|
|
},
|
|
|
|
ownsDiffType(diffType: string): boolean {
|
|
return GIT_DIFF_TYPES.has(diffType) || diffType.startsWith("worktree:");
|
|
},
|
|
|
|
canStageFiles(diffType: string): boolean {
|
|
const effectiveDiffType = parseWorktreeDiffType(diffType)?.subType ?? diffType;
|
|
return effectiveDiffType === "uncommitted" || effectiveDiffType === "unstaged";
|
|
},
|
|
|
|
getContext(cwd?: string): Promise<GitContext> {
|
|
return getGitContext(runtime, cwd);
|
|
},
|
|
|
|
runDiff(diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions): Promise<DiffResult> {
|
|
return runGitDiff(runtime, diffType, defaultBranch, cwd, options);
|
|
},
|
|
|
|
getFileContents(diffType, defaultBranch, filePath, oldPath?, cwd?) {
|
|
return getGitFileContentsForDiff(runtime, diffType, defaultBranch, filePath, oldPath, cwd);
|
|
},
|
|
|
|
getDiffFingerprint(diffType, defaultBranch, cwd?, options?) {
|
|
return getGitDiffFingerprint(runtime, diffType, defaultBranch, cwd, options);
|
|
},
|
|
|
|
stageFile(filePath: string, cwd?: string): Promise<void> {
|
|
return gitAddFile(runtime, filePath, cwd);
|
|
},
|
|
|
|
unstageFile(filePath: string, cwd?: string): Promise<void> {
|
|
return gitResetFile(runtime, filePath, cwd);
|
|
},
|
|
|
|
detectRemoteDefaultCompareTarget(cwd?: string): Promise<string | null> {
|
|
return detectRemoteDefaultBranch(runtime, cwd);
|
|
},
|
|
|
|
resolveCwd(diffType: string, fallbackCwd?: string): string | undefined {
|
|
const parsed = parseWorktreeDiffType(diffType);
|
|
return parsed?.path ?? fallbackCwd;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createJjProvider(runtime: ReviewJjRuntime): VcsProvider {
|
|
return {
|
|
id: "jj",
|
|
|
|
async detect(cwd?: string): Promise<boolean> {
|
|
return (await detectJjWorkspace(runtime, cwd)) !== null;
|
|
},
|
|
|
|
getRoot(cwd?: string): Promise<string | null> {
|
|
return detectJjWorkspace(runtime, cwd);
|
|
},
|
|
|
|
ownsDiffType(diffType: string): boolean {
|
|
return JJ_DIFF_TYPES.has(diffType);
|
|
},
|
|
|
|
getContext(cwd?: string): Promise<GitContext> {
|
|
return getJjContext(runtime, cwd);
|
|
},
|
|
|
|
runDiff(diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions): Promise<DiffResult> {
|
|
return runJjDiff(runtime, diffType, defaultBranch, cwd, options);
|
|
},
|
|
|
|
getFileContents(diffType, defaultBranch, filePath, oldPath?, cwd?) {
|
|
return getJjFileContentsForDiff(runtime, diffType, defaultBranch, filePath, oldPath, cwd);
|
|
},
|
|
|
|
getDiffFingerprint(diffType, defaultBranch, cwd?) {
|
|
return getJjDiffFingerprint(runtime, diffType, defaultBranch, cwd);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createVcsApi(providers: readonly VcsProvider[]): VcsApi {
|
|
const providerList = [...providers];
|
|
const defaultProvider = providerList.find((provider) => provider.id === "git") ?? providerList[0];
|
|
const vcsCache = new Map<string, VcsProvider>();
|
|
const managedVcsCache = new Map<string, VcsProvider>();
|
|
|
|
if (!defaultProvider) {
|
|
throw new Error("createVcsApi requires at least one provider");
|
|
}
|
|
|
|
async function collectDetectedProviders(cwd?: string): Promise<Array<{ provider: VcsProvider; root: string | null; order: number }>> {
|
|
const candidates: Array<{ provider: VcsProvider; root: string | null; order: number }> = [];
|
|
for (let index = 0; index < providerList.length; index++) {
|
|
const provider = providerList[index];
|
|
let root: string | null = null;
|
|
let detected = false;
|
|
try {
|
|
if (provider.getRoot) {
|
|
root = await provider.getRoot(cwd);
|
|
detected = root !== null;
|
|
} else {
|
|
detected = await provider.detect(cwd);
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (detected) {
|
|
candidates.push({ provider, root, order: index });
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
async function detectManagedVcs(cwd?: string, vcsType?: VcsSelection): Promise<VcsProvider | null> {
|
|
const key = `${vcsType ?? "auto"}:${cwd ?? process.cwd()}`;
|
|
const cached = managedVcsCache.get(key);
|
|
if (cached) return cached;
|
|
|
|
if (vcsType && vcsType !== "auto") {
|
|
const provider = getProviderById(vcsType);
|
|
let detected = false;
|
|
try {
|
|
detected = provider ? await provider.detect(cwd) : false;
|
|
} catch {
|
|
detected = false;
|
|
}
|
|
const result = detected ? provider : null;
|
|
if (result) managedVcsCache.set(key, result);
|
|
return result;
|
|
}
|
|
|
|
const candidates = await collectDetectedProviders(cwd);
|
|
const detected = selectNearestProvider(candidates, cwd);
|
|
if (detected) managedVcsCache.set(key, detected);
|
|
return detected;
|
|
}
|
|
|
|
async function detectVcs(cwd?: string): Promise<VcsProvider> {
|
|
const key = cwd ?? process.cwd();
|
|
const cached = vcsCache.get(key);
|
|
if (cached) return cached;
|
|
|
|
const detected = (await detectManagedVcs(cwd)) ?? defaultProvider;
|
|
vcsCache.set(key, detected);
|
|
return detected;
|
|
}
|
|
|
|
function getProviderForDiffType(diffType: string): VcsProvider | null {
|
|
for (const provider of providerList) {
|
|
if (provider.ownsDiffType(diffType)) {
|
|
return provider;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getProviderById(id: Exclude<VcsSelection, "auto">): VcsProvider | null {
|
|
return providerList.find((provider) => provider.id === id) ?? null;
|
|
}
|
|
|
|
function formatVcsName(id: Exclude<VcsSelection, "auto">): string {
|
|
switch (id) {
|
|
case "git":
|
|
return "Git";
|
|
case "jj":
|
|
return "JJ";
|
|
case "p4":
|
|
return "P4";
|
|
}
|
|
}
|
|
|
|
async function getProviderForSelection(
|
|
vcsType: VcsSelection | undefined,
|
|
cwd?: string,
|
|
): Promise<VcsProvider> {
|
|
if (!vcsType || vcsType === "auto") {
|
|
return detectVcs(cwd);
|
|
}
|
|
|
|
const provider = getProviderById(vcsType);
|
|
const vcsName = formatVcsName(vcsType);
|
|
if (!provider) {
|
|
throw new Error(`${vcsName} support is not available in this runtime.`);
|
|
}
|
|
if (!(await provider.detect(cwd))) {
|
|
throw new Error(`${vcsName} workspace not found.`);
|
|
}
|
|
return provider;
|
|
}
|
|
|
|
async function getProviderForOperation(diffType: string, cwd?: string): Promise<VcsProvider> {
|
|
return getProviderForDiffType(diffType) ?? detectVcs(cwd);
|
|
}
|
|
|
|
async function getContextWithProvider(
|
|
cwd?: string,
|
|
vcsType?: VcsSelection,
|
|
): Promise<{ provider: VcsProvider; gitContext: GitContext }> {
|
|
const provider = await getProviderForSelection(vcsType, cwd);
|
|
return { provider, gitContext: await provider.getContext(cwd) };
|
|
}
|
|
|
|
function resolveRequestedDiffType(
|
|
provider: VcsProvider,
|
|
gitContext: GitContext,
|
|
requestedDiffType: DiffType | undefined,
|
|
configuredDiffType: DiffType,
|
|
): DiffType {
|
|
if (requestedDiffType && provider.ownsDiffType(requestedDiffType)) {
|
|
return requestedDiffType;
|
|
}
|
|
return resolveInitialDiffType(gitContext, configuredDiffType);
|
|
}
|
|
|
|
function resolveInitialBase(
|
|
gitContext: GitContext,
|
|
diffType: DiffType,
|
|
requestedBase: string | undefined,
|
|
ownsRequestedDiffType: boolean,
|
|
): string {
|
|
if (gitContext.vcsType === "jj") {
|
|
if (diffType === "jj-line" && ownsRequestedDiffType && requestedBase) {
|
|
return requestedBase;
|
|
}
|
|
return gitContext.defaultBranch;
|
|
}
|
|
return requestedBase ?? gitContext.defaultBranch;
|
|
}
|
|
|
|
return {
|
|
detectVcs,
|
|
detectManagedVcs,
|
|
|
|
async getVcsContext(cwd?: string, vcsType?: VcsSelection): Promise<GitContext> {
|
|
return (await getContextWithProvider(cwd, vcsType)).gitContext;
|
|
},
|
|
|
|
async detectRemoteDefaultCompareTarget(cwd?: string, vcsType?: VcsSelection): Promise<string | null> {
|
|
const provider = await getProviderForSelection(vcsType, cwd);
|
|
return provider.detectRemoteDefaultCompareTarget?.(cwd) ?? null;
|
|
},
|
|
|
|
async prepareLocalReviewDiff(options: PrepareLocalReviewDiffOptions): Promise<PreparedLocalReviewDiff> {
|
|
const { provider, gitContext } = await getContextWithProvider(options.cwd, options.vcsType);
|
|
const ownsRequestedDiffType = options.requestedDiffType !== undefined
|
|
&& provider.ownsDiffType(options.requestedDiffType);
|
|
const diffType = resolveRequestedDiffType(
|
|
provider,
|
|
gitContext,
|
|
options.requestedDiffType,
|
|
options.configuredDiffType,
|
|
);
|
|
const base = resolveInitialBase(gitContext, diffType, options.requestedBase, ownsRequestedDiffType);
|
|
const result = await provider.runDiff(diffType, base, gitContext.cwd ?? options.cwd, {
|
|
hideWhitespace: options.hideWhitespace,
|
|
});
|
|
|
|
return {
|
|
gitContext,
|
|
diffType,
|
|
base,
|
|
rawPatch: result.patch,
|
|
gitRef: result.label,
|
|
error: result.error,
|
|
};
|
|
},
|
|
|
|
async runVcsDiff(
|
|
diffType: DiffType,
|
|
defaultBranch: string = "main",
|
|
cwd?: string,
|
|
options?: GitDiffOptions,
|
|
): Promise<DiffResult> {
|
|
const provider = await getProviderForOperation(diffType, cwd);
|
|
return provider.runDiff(diffType, defaultBranch, cwd, options);
|
|
},
|
|
|
|
async getVcsFileContentsForDiff(
|
|
diffType: DiffType,
|
|
defaultBranch: string,
|
|
filePath: string,
|
|
oldPath?: string,
|
|
cwd?: string,
|
|
): Promise<{ oldContent: string | null; newContent: string | null }> {
|
|
const provider = await getProviderForOperation(diffType, cwd);
|
|
return provider.getFileContents(diffType, defaultBranch, filePath, oldPath, cwd);
|
|
},
|
|
|
|
async getVcsDiffFingerprint(
|
|
diffType: DiffType,
|
|
defaultBranch: string = "main",
|
|
cwd?: string,
|
|
options?: GitDiffOptions,
|
|
): Promise<string | null> {
|
|
try {
|
|
const provider = await getProviderForOperation(diffType, cwd);
|
|
if (!provider.getDiffFingerprint) return null;
|
|
return await provider.getDiffFingerprint(diffType, defaultBranch, cwd, options);
|
|
} catch {
|
|
// Fingerprinting is best-effort: failure means "always fresh", never
|
|
// a user-facing error.
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async canStageFiles(diffType: string, cwd?: string): Promise<boolean> {
|
|
const provider = await getProviderForOperation(diffType, cwd);
|
|
return provider.stageFile !== undefined && (provider.canStageFiles?.(diffType) ?? false);
|
|
},
|
|
|
|
async stageFile(diffType: string, filePath: string, cwd?: string): Promise<void> {
|
|
const provider = await getProviderForOperation(diffType, cwd);
|
|
if (!provider.stageFile || !(provider.canStageFiles?.(diffType) ?? false)) {
|
|
throw new Error(`Staging not available for ${provider.id}`);
|
|
}
|
|
return provider.stageFile(filePath, cwd);
|
|
},
|
|
|
|
async unstageFile(diffType: string, filePath: string, cwd?: string): Promise<void> {
|
|
const provider = await getProviderForOperation(diffType, cwd);
|
|
if (!provider.unstageFile || !(provider.canStageFiles?.(diffType) ?? false)) {
|
|
throw new Error(`Unstaging not available for ${provider.id}`);
|
|
}
|
|
return provider.unstageFile(filePath, cwd);
|
|
},
|
|
|
|
resolveVcsCwd(diffType: string, fallbackCwd?: string): string | undefined {
|
|
const provider = getProviderForDiffType(diffType);
|
|
return provider?.resolveCwd?.(diffType, fallbackCwd) ?? fallbackCwd;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function resolveInitialDiffType(
|
|
gitContext: GitContext,
|
|
configuredDiffType: DiffType,
|
|
): DiffType {
|
|
if (gitContext.vcsType === "p4") {
|
|
return "p4-default";
|
|
}
|
|
if (gitContext.vcsType === "jj") {
|
|
return "jj-current";
|
|
}
|
|
if (gitContext.diffOptions.some((option) => option.id === configuredDiffType)) {
|
|
return configuredDiffType;
|
|
}
|
|
|
|
const fallback = gitContext.diffOptions[0]?.id;
|
|
return fallback ? fallback as DiffType : configuredDiffType;
|
|
}
|