forked from farhoodlabs/paperclip
[codex] Add workspace diff viewer plugin (#6071)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Operators need to inspect what agents changed inside execution and project workspaces. > - The existing workspace detail views did not provide a first-party rich diff surface for staged, unstaged, head, renamed, binary, oversized, and untracked changes. > - The plugin system is the intended extension point for optional rich UI surfaces. > - This pull request adds a workspace diff plugin plus host services and shared contracts so Changes tabs can render workspace diffs through plugin slots. > - The diff-renderer dependency should stay owned by the plugin package rather than the core UI app. > - The dependency surface must stay aligned with repository PR policy, including intentionally omitting `pnpm-lock.yaml` from the PR. > - The benefit is a more reviewable workspace surface without hard-coding the renderer into every page. ## What Changed - Added `@paperclipai/plugin-workspace-diff`, including diff normalization, plugin manifest/worker/UI entrypoints, and focused plugin tests. - Kept `@pierre/diffs` scoped to `@paperclipai/plugin-workspace-diff`; removed the core UI lab diff-renderer surface and direct UI package dependency. - Added shared workspace diff types and validators, plus plugin SDK surface for workspace diff host services. - Added server workspace diff service support and route coverage for execution/project workspace diff flows. - Wired Execution Workspace and Project Workspace Changes tabs to load the diff plugin, including loading/error fallback behavior. - Added UI tests and fixtures for the Changes tabs and plugin bridge behavior. - Added the new plugin package manifest to the Docker deps stage so PR policy can validate dependency coverage. - Addressed review hardening around empty untracked patches, workspace path exposure, project workspace read capability checks, and default base refs. ## Verification - `pnpm --filter @paperclipai/plugin-workspace-diff test` - `pnpm exec vitest run packages/shared/src/validators/workspace-diff.test.ts server/src/__tests__/workspace-diff-service.test.ts ui/src/pages/ProjectWorkspaceDetail.test.tsx ui/src/pages/ExecutionWorkspaceDetail.test.tsx` - `pnpm exec vitest run ui/src/plugins/bridge.test.ts server/src/__tests__/workspace-runtime-routes-authz.test.ts` - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/plugin-workspace-diff typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `node ./scripts/check-docker-deps-stage.mjs` - Browser screenshot captured from the local worktree dev server: https://files.catbox.moe/ofdpsp.png - Confirmed branch is rebased onto `public-gh/master`, `.github/workflows/pr.yml` is not included in the PR diff, `ui/package.json` is not included in the PR diff, and `pnpm-lock.yaml` is not included in the PR diff. ## Risks - Medium UI integration risk: the Changes tab depends on the plugin slot and host diff service path. - Medium dependency risk: this adds `@pierre/diffs` in the plugin package, but `pnpm-lock.yaml` is intentionally omitted per packaging instructions because repository automation manages lockfile updates. - Current CI blocker: downstream frozen installs fail until the repository policy path for new plugin package dependencies is chosen. - Diff rendering edge cases are covered for common working-tree and head diff states, but very large repositories may still expose performance limits. - No migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 class coding model, tool-enabled local execution environment. Exact context window was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-workspace-diff",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party execution workspace Changes tab powered by plugin-local workspace metadata",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/plugins/plugin-workspace-diff"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"workspace",
|
||||
"diff"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
||||
"test": "vitest run",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../scripts/generate-plugin-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@pierre/diffs": "^1.1.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"esbuild": "^0.27.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { z } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]);
|
||||
|
||||
export const workspaceDiffFileStatusSchema = z.enum([
|
||||
"added",
|
||||
"modified",
|
||||
"deleted",
|
||||
"renamed",
|
||||
"copied",
|
||||
"type_changed",
|
||||
"untracked",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
export const workspaceDiffPatchKindSchema = z.enum(["staged", "unstaged", "head", "untracked"]);
|
||||
|
||||
export const workspaceDiffWarningCodeSchema = z.enum([
|
||||
"base_ref_missing",
|
||||
"base_ref_invalid",
|
||||
"binary_file",
|
||||
"file_count_truncated",
|
||||
"file_oversized",
|
||||
"git_command_failed",
|
||||
"missing_cwd",
|
||||
"non_git_workspace",
|
||||
"patch_truncated",
|
||||
"path_filter_invalid",
|
||||
"symlink_target_outside_workspace",
|
||||
"workspace_path_invalid",
|
||||
]);
|
||||
|
||||
const queryBooleanSchema = z
|
||||
.union([z.boolean(), z.enum(["true", "false"])])
|
||||
.transform((value) => value === true || value === "true");
|
||||
|
||||
function normalizePathQuery(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
return values.flatMap((entry) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
return entry
|
||||
.split(",")
|
||||
.map((filePath) => filePath.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
}
|
||||
|
||||
export const workspaceDiffQuerySchema = z
|
||||
.object({
|
||||
view: workspaceDiffViewSchema.optional().default("working-tree"),
|
||||
baseRef: z.string().trim().min(1).max(240).optional().nullable(),
|
||||
includeUntracked: queryBooleanSchema.optional().default(true),
|
||||
path: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
paths: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
view: value.view,
|
||||
baseRef: value.baseRef?.trim() || null,
|
||||
includeUntracked: value.includeUntracked,
|
||||
paths: normalizePathQuery(value.paths ?? value.path),
|
||||
}));
|
||||
|
||||
export const workspaceDiffWarningSchema = z.object({
|
||||
code: workspaceDiffWarningCodeSchema,
|
||||
message: z.string(),
|
||||
path: z.string().nullable(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffCapsSchema = z.object({
|
||||
maxFiles: z.number().int().positive(),
|
||||
maxFileBytes: z.number().int().positive(),
|
||||
maxPatchBytes: z.number().int().positive(),
|
||||
maxTotalPatchBytes: z.number().int().positive(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffFilePatchSchema = z.object({
|
||||
kind: workspaceDiffPatchKindSchema,
|
||||
patch: z.string().nullable(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
binary: z.boolean(),
|
||||
oversized: z.boolean(),
|
||||
truncated: z.boolean(),
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffFileSchema = z.object({
|
||||
path: z.string(),
|
||||
oldPath: z.string().nullable(),
|
||||
status: workspaceDiffFileStatusSchema,
|
||||
staged: z.boolean(),
|
||||
unstaged: z.boolean(),
|
||||
untracked: z.boolean(),
|
||||
binary: z.boolean(),
|
||||
oversized: z.boolean(),
|
||||
truncated: z.boolean(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
sizeBytes: z.number().int().nonnegative().nullable(),
|
||||
patches: z.array(workspaceDiffFilePatchSchema),
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffStatsSchema = z.object({
|
||||
fileCount: z.number().int().nonnegative(),
|
||||
stagedFileCount: z.number().int().nonnegative(),
|
||||
unstagedFileCount: z.number().int().nonnegative(),
|
||||
untrackedFileCount: z.number().int().nonnegative(),
|
||||
binaryFileCount: z.number().int().nonnegative(),
|
||||
oversizedFileCount: z.number().int().nonnegative(),
|
||||
truncatedFileCount: z.number().int().nonnegative(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffResponseSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
companyId: z.string(),
|
||||
view: workspaceDiffViewSchema,
|
||||
baseRef: z.string().nullable(),
|
||||
defaultBaseRef: z.string().nullable(),
|
||||
headSha: z.string().nullable(),
|
||||
includeUntracked: z.boolean(),
|
||||
paths: z.array(z.string()),
|
||||
files: z.array(workspaceDiffFileSchema),
|
||||
stats: workspaceDiffStatsSchema,
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
caps: workspaceDiffCapsSchema,
|
||||
truncated: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export type WorkspaceDiffView = z.infer<typeof workspaceDiffViewSchema>;
|
||||
export type WorkspaceDiffFileStatus = z.infer<typeof workspaceDiffFileStatusSchema>;
|
||||
export type WorkspaceDiffPatchKind = z.infer<typeof workspaceDiffPatchKindSchema>;
|
||||
export type WorkspaceDiffWarningCode = z.infer<typeof workspaceDiffWarningCodeSchema>;
|
||||
export type WorkspaceDiffQueryOptions = z.infer<typeof workspaceDiffQuerySchema>;
|
||||
export type WorkspaceDiffWarning = z.infer<typeof workspaceDiffWarningSchema>;
|
||||
export type WorkspaceDiffCaps = z.infer<typeof workspaceDiffCapsSchema>;
|
||||
export type WorkspaceDiffFilePatch = z.infer<typeof workspaceDiffFilePatchSchema>;
|
||||
export type WorkspaceDiffFile = z.infer<typeof workspaceDiffFileSchema>;
|
||||
export type WorkspaceDiffStats = z.infer<typeof workspaceDiffStatsSchema>;
|
||||
export type WorkspaceDiffResponse = z.infer<typeof workspaceDiffResponseSchema>;
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
} from "./contracts.js";
|
||||
|
||||
export type DiffRenderMode = "unified" | "split";
|
||||
|
||||
export interface DiffPatchViewModel {
|
||||
kind: WorkspaceDiffFilePatch["kind"];
|
||||
patch: string | null;
|
||||
lineCount: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
binary: boolean;
|
||||
oversized: boolean;
|
||||
truncated: boolean;
|
||||
warnings: WorkspaceDiffWarning[];
|
||||
}
|
||||
|
||||
export interface DiffFileViewModel {
|
||||
path: string;
|
||||
oldPath: string | null;
|
||||
status: WorkspaceDiffFile["status"];
|
||||
additions: number;
|
||||
deletions: number;
|
||||
binary: boolean;
|
||||
oversized: boolean;
|
||||
truncated: boolean;
|
||||
warnings: WorkspaceDiffWarning[];
|
||||
patchKinds: WorkspaceDiffFilePatch["kind"][];
|
||||
patches: DiffPatchViewModel[];
|
||||
patch: string | null;
|
||||
lineCount: number;
|
||||
longDiff: boolean;
|
||||
}
|
||||
|
||||
export interface DiffSummaryViewModel {
|
||||
changedLabel: string;
|
||||
lineLabel: string;
|
||||
warningCount: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<WorkspaceDiffFile["status"], string> = {
|
||||
added: "Added",
|
||||
modified: "Modified",
|
||||
deleted: "Deleted",
|
||||
renamed: "Renamed",
|
||||
copied: "Copied",
|
||||
type_changed: "Type changed",
|
||||
untracked: "Untracked",
|
||||
unknown: "Changed",
|
||||
};
|
||||
|
||||
export const LONG_DIFF_LINE_THRESHOLD = 400;
|
||||
|
||||
export function statusLabel(status: WorkspaceDiffFile["status"]) {
|
||||
return STATUS_LABELS[status] ?? "Changed";
|
||||
}
|
||||
|
||||
export function fileName(filePath: string) {
|
||||
return filePath.split("/").filter(Boolean).pop() ?? filePath;
|
||||
}
|
||||
|
||||
export function buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] {
|
||||
return file.patches.map((patch) => {
|
||||
const textPatch = patch.patch?.trimEnd() ?? null;
|
||||
const lineCount = textPatch ? textPatch.split("\n").length : 0;
|
||||
return {
|
||||
kind: patch.kind,
|
||||
patch: textPatch && textPatch.length > 0 ? textPatch : null,
|
||||
lineCount,
|
||||
additions: patch.additions,
|
||||
deletions: patch.deletions,
|
||||
binary: patch.binary,
|
||||
oversized: patch.oversized,
|
||||
truncated: patch.truncated,
|
||||
warnings: patch.warnings,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFilePatch(file: WorkspaceDiffFile): string | null {
|
||||
return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null;
|
||||
}
|
||||
|
||||
export function isLongDiffFile(file: Pick<DiffFileViewModel, "lineCount">) {
|
||||
return file.lineCount > LONG_DIFF_LINE_THRESHOLD;
|
||||
}
|
||||
|
||||
export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] {
|
||||
return (diff?.files ?? []).map((file) => {
|
||||
const patches = buildFilePatches(file);
|
||||
const lineCount = patches.reduce((count, patch) => count + patch.lineCount, 0);
|
||||
return {
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
status: file.status,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
binary: file.binary,
|
||||
oversized: file.oversized,
|
||||
truncated: file.truncated,
|
||||
warnings: file.warnings,
|
||||
patchKinds: file.patches.map((patch) => patch.kind),
|
||||
patches,
|
||||
patch: patches.find((patch) => patch.patch)?.patch ?? null,
|
||||
lineCount,
|
||||
longDiff: isLongDiffFile({ lineCount }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function diffSummary(diff: WorkspaceDiffResponse | null | undefined): DiffSummaryViewModel {
|
||||
const stats = diff?.stats;
|
||||
const fileCount = stats?.fileCount ?? 0;
|
||||
const additions = stats?.additions ?? 0;
|
||||
const deletions = stats?.deletions ?? 0;
|
||||
const warningCount = diff?.warnings.length ?? 0;
|
||||
|
||||
return {
|
||||
changedLabel: `${fileCount} ${fileCount === 1 ? "file" : "files"}`,
|
||||
lineLabel: `+${additions} / -${deletions}`,
|
||||
warningCount,
|
||||
truncated: Boolean(diff?.truncated),
|
||||
};
|
||||
}
|
||||
|
||||
export function nextExpandedFileSet(
|
||||
current: ReadonlySet<string>,
|
||||
filePath: string,
|
||||
): Set<string> {
|
||||
const next = new Set(current);
|
||||
if (next.has(filePath)) next.delete(filePath);
|
||||
else next.add(filePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function initialExpandedFileSet(files: readonly DiffFileViewModel[]): Set<string> {
|
||||
return new Set(files.filter((file) => !file.longDiff).map((file) => file.path));
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.workspace-diff";
|
||||
const CHANGES_TAB_SLOT_ID = "workspace-changes-tab";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Workspace Changes",
|
||||
description: "Adds a Changes tab to execution and project workspaces using plugin-local Git diff computation and @pierre/diffs.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
"ui.detailTab.register",
|
||||
"execution.workspaces.read",
|
||||
"project.workspaces.read",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "detailTab",
|
||||
id: CHANGES_TAB_SLOT_ID,
|
||||
displayName: "Changes",
|
||||
exportName: "ChangesTab",
|
||||
entityTypes: ["execution_workspace", "project_workspace"],
|
||||
order: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,617 @@
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
|
||||
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
|
||||
import type { PatchDiffProps } from "@pierre/diffs/react";
|
||||
import { useFileDiffInstance } from "@pierre/diffs/react";
|
||||
import { createElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
diffSummary,
|
||||
fileName,
|
||||
initialExpandedFileSet,
|
||||
nextExpandedFileSet,
|
||||
statusLabel,
|
||||
toFileViewModels,
|
||||
type DiffFileViewModel,
|
||||
type DiffPatchViewModel,
|
||||
type DiffRenderMode,
|
||||
} from "../diff-model.js";
|
||||
import type { WorkspaceDiffResponse } from "../contracts.js";
|
||||
|
||||
type WorkspaceDiffData = WorkspaceDiffResponse;
|
||||
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["options"];
|
||||
type DiffViewMode = "working-tree" | "head";
|
||||
|
||||
type LucideIconProps = { size?: number };
|
||||
|
||||
function makeLucideIcon(paths: ReactNode) {
|
||||
return function LucideIcon({ size = 16 }: LucideIconProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ width: size, height: size, display: "block" }}
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw.
|
||||
const RefreshCwIcon = makeLucideIcon(
|
||||
<>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</>,
|
||||
);
|
||||
|
||||
function readInitialView(): DiffViewMode {
|
||||
if (typeof window === "undefined") return "working-tree";
|
||||
return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree";
|
||||
}
|
||||
|
||||
function readInitialBaseRef() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("baseRef") ?? "";
|
||||
}
|
||||
|
||||
function buttonClass(active = false) {
|
||||
return [
|
||||
"inline-flex h-8 items-center justify-center rounded-md border px-2.5 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-foreground/20 bg-foreground text-background"
|
||||
: "border-border bg-background text-muted-foreground hover:text-foreground",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function iconButtonClass(active = false) {
|
||||
return [
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md border text-xs transition-colors",
|
||||
active
|
||||
? "border-foreground/20 bg-foreground text-background"
|
||||
: "border-border bg-background text-muted-foreground hover:text-foreground",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function warningText(file: DiffFileViewModel) {
|
||||
if (file.binary) return "Binary file";
|
||||
if (file.oversized) return "Too large to render";
|
||||
if (file.truncated) return "Patch truncated";
|
||||
if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning";
|
||||
if (file.patches.every((patch) => !patch.patch)) return "No text patch";
|
||||
return null;
|
||||
}
|
||||
|
||||
const PATCH_KIND_LABELS: Record<DiffPatchViewModel["kind"], string> = {
|
||||
staged: "Staged",
|
||||
unstaged: "Unstaged",
|
||||
head: "Head",
|
||||
untracked: "Untracked",
|
||||
};
|
||||
|
||||
function patchKindLabel(kind: DiffPatchViewModel["kind"]) {
|
||||
return PATCH_KIND_LABELS[kind] ?? "Patch";
|
||||
}
|
||||
|
||||
function patchWarningText(patch: DiffPatchViewModel) {
|
||||
if (patch.binary) return "Binary file";
|
||||
if (patch.oversized) return "Too large to render";
|
||||
if (patch.truncated) return "Patch truncated";
|
||||
if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning";
|
||||
if (!patch.patch) return "No text patch";
|
||||
return null;
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
file,
|
||||
active,
|
||||
expanded,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onCopy,
|
||||
}: {
|
||||
file: DiffFileViewModel;
|
||||
active: boolean;
|
||||
expanded: boolean;
|
||||
onSelect: () => void;
|
||||
onToggle: () => void;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
const warning = warningText(file);
|
||||
const expandLabel = expanded ? "Collapse file" : "Expand file";
|
||||
const fileAriaLabel = expanded ? `Collapse ${file.path}` : `Expand ${file.path}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"group border-b border-border/70 px-3 py-2 last:border-b-0",
|
||||
active ? "bg-accent/60" : "bg-background hover:bg-muted/45",
|
||||
].join(" ")}
|
||||
>
|
||||
<div key="main" className="flex min-w-0 items-start gap-2">
|
||||
<button
|
||||
key="toggle"
|
||||
type="button"
|
||||
className="mt-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
title={expandLabel}
|
||||
aria-label={fileAriaLabel}
|
||||
>
|
||||
{expanded ? "−" : "+"}
|
||||
</button>
|
||||
<button
|
||||
key="select"
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div key="name" className="truncate text-sm font-medium text-foreground">{fileName(file.path)}</div>
|
||||
<div key="path" className="truncate font-mono text-[11px] text-muted-foreground">{file.path}</div>
|
||||
</button>
|
||||
<button
|
||||
key="copy"
|
||||
type="button"
|
||||
className="text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
onClick={onCopy}
|
||||
title="Copy path"
|
||||
aria-label={`Copy ${file.path}`}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
</div>
|
||||
<div key="meta" className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-5 text-[11px] text-muted-foreground">
|
||||
<span key="status">{statusLabel(file.status)}</span>
|
||||
<span key="additions" className="font-mono text-emerald-700 dark:text-emerald-300">{`+${file.additions}`}</span>
|
||||
<span key="deletions" className="font-mono text-red-700 dark:text-red-300">{`-${file.deletions}`}</span>
|
||||
{warning ? <span key="warning" className="text-amber-700 dark:text-amber-300">{warning}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The upstream React wrapper emits React 19 key warnings for its internal slot array.
|
||||
// This mounts the same Diffs custom element through the exported imperative hook.
|
||||
function WorkspacePatchDiff({
|
||||
patch,
|
||||
options,
|
||||
}: {
|
||||
patch: string;
|
||||
options: WorkspacePatchDiffOptions;
|
||||
}) {
|
||||
const fileDiff = useMemo(() => getSingularPatch(patch), [patch]);
|
||||
const { ref } = useFileDiffInstance({
|
||||
fileDiff,
|
||||
options,
|
||||
metrics: undefined,
|
||||
lineAnnotations: undefined,
|
||||
selectedLines: undefined,
|
||||
prerenderedHTML: undefined,
|
||||
hasGutterRenderUtility: false,
|
||||
hasCustomHeader: false,
|
||||
disableWorkerPool: false,
|
||||
});
|
||||
|
||||
return createElement(DIFFS_TAG_NAME, { ref });
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-8 text-center">
|
||||
<div className="text-sm font-medium text-foreground">No workspace changes</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
The workspace matches its current comparison target.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Loading workspace changes…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm" role="alert">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">Unable to load workspace changes.</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Retry the request or open the details below for the technical error.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass(false)}
|
||||
onClick={onRetry}
|
||||
aria-label="Retry loading workspace changes"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground">
|
||||
Troubleshooting details
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words border border-border bg-background px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{message || "No error message was provided."}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffPanel({
|
||||
file,
|
||||
mode,
|
||||
}: {
|
||||
file: DiffFileViewModel;
|
||||
mode: DiffRenderMode;
|
||||
}) {
|
||||
const warning = warningText(file);
|
||||
if (warning) {
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-6 text-sm text-muted-foreground">
|
||||
{warning ?? "No renderable patch is available for this file."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{file.patches.map((patch, index) => {
|
||||
const patchWarning = patchWarningText(patch);
|
||||
return (
|
||||
<div key={`${patch.kind}:${index}`} className="overflow-hidden border border-border bg-background">
|
||||
{file.patches.length > 1 ? (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{patchKindLabel(patch.kind)}</span>
|
||||
<span className="font-mono text-emerald-700 dark:text-emerald-300">{`+${patch.additions}`}</span>
|
||||
<span className="font-mono text-red-700 dark:text-red-300">{`-${patch.deletions}`}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{patchWarning || !patch.patch ? (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground">
|
||||
{patchWarning ?? "No renderable patch is available for this file."}
|
||||
</div>
|
||||
) : (
|
||||
<WorkspacePatchDiff
|
||||
patch={patch.patch}
|
||||
options={{
|
||||
diffStyle: mode,
|
||||
overflow: "scroll",
|
||||
disableLineNumbers: false,
|
||||
themeType: "system",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedFilePanel({
|
||||
file,
|
||||
onExpand,
|
||||
}: {
|
||||
file: DiffFileViewModel;
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const title = file.longDiff ? "Large diff folded" : "Diff folded";
|
||||
const details = file.lineCount > 0
|
||||
? `${file.lineCount.toLocaleString()} lines`
|
||||
: statusLabel(file.status);
|
||||
|
||||
return (
|
||||
<div className="border border-dashed border-border bg-background px-4 py-5 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{title}</div>
|
||||
<div className="mt-1 font-mono text-xs">{details}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass(false)}
|
||||
onClick={onExpand}
|
||||
aria-label={`Show diff for ${file.path}`}
|
||||
>
|
||||
Show file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
const toast = usePluginToast();
|
||||
const [mode, setMode] = useState<DiffRenderMode>("split");
|
||||
const [view, setView] = useState<DiffViewMode>(() => readInitialView());
|
||||
const [baseRef, setBaseRef] = useState(() => readInitialBaseRef());
|
||||
const baseRefTouchedRef = useRef(Boolean(baseRef.trim()));
|
||||
const [includeUntracked, setIncludeUntracked] = useState(false);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
|
||||
const diffScrollRef = useRef<HTMLElement | null>(null);
|
||||
const scrollSyncFrameRef = useRef<number | null>(null);
|
||||
|
||||
const params = useMemo(() => ({
|
||||
workspaceId: context.entityId,
|
||||
companyId: context.companyId ?? "",
|
||||
projectId: context.projectId ?? "",
|
||||
entityType: context.entityType,
|
||||
view,
|
||||
baseRef: baseRef.trim() || null,
|
||||
includeUntracked,
|
||||
}), [baseRef, context.companyId, context.entityId, context.entityType, context.projectId, includeUntracked, view]);
|
||||
|
||||
const { data, loading, error, refresh } = usePluginData<WorkspaceDiffData>("workspace-diff", params);
|
||||
const files = useMemo(() => toFileViewModels(data), [data]);
|
||||
const summary = useMemo(() => diffSummary(data), [data]);
|
||||
const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null;
|
||||
const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`;
|
||||
|
||||
const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => {
|
||||
if (node) fileSectionRefs.current.set(filePath, node);
|
||||
else fileSectionRefs.current.delete(filePath);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback((filePath: string) => {
|
||||
setSelectedPath(filePath);
|
||||
window.requestAnimationFrame(() => {
|
||||
fileSectionRefs.current.get(filePath)?.scrollIntoView({
|
||||
block: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const syncSelectedPathFromScroll = useCallback(() => {
|
||||
const container = diffScrollRef.current;
|
||||
if (!container || files.length === 0) return;
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
let nextPath = files[0]?.path ?? null;
|
||||
for (const file of files) {
|
||||
const section = fileSectionRefs.current.get(file.path);
|
||||
if (!section) continue;
|
||||
const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop;
|
||||
if (offsetFromScrollTop <= 48) {
|
||||
nextPath = file.path;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextPath) {
|
||||
setSelectedPath((current) => current === nextPath ? current : nextPath);
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
const handleDiffScroll = useCallback(() => {
|
||||
if (scrollSyncFrameRef.current !== null) return;
|
||||
scrollSyncFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollSyncFrameRef.current = null;
|
||||
syncSelectedPathFromScroll();
|
||||
});
|
||||
}, [syncSelectedPathFromScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultBaseRef = data?.defaultBaseRef?.trim();
|
||||
if (!defaultBaseRef || baseRef.trim() || baseRefTouchedRef.current) return;
|
||||
setBaseRef(defaultBaseRef);
|
||||
}, [baseRef, data?.defaultBaseRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length === 0) {
|
||||
setExpandedFiles(new Set());
|
||||
setSelectedPath(null);
|
||||
return;
|
||||
}
|
||||
setExpandedFiles(initialExpandedFileSet(files));
|
||||
setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollSyncFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollSyncFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyPath = async (filePath: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(filePath);
|
||||
toast({ title: "Path copied", body: filePath });
|
||||
} catch {
|
||||
toast({ title: "Copy failed", body: filePath, tone: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div key="toolbar" className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div key="summary" className="min-w-0">
|
||||
<div key="summary-line" className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span key="changed" className="font-medium text-foreground">{summary.changedLabel}</span>
|
||||
<span key="lines" className="font-mono text-xs text-muted-foreground">{summary.lineLabel}</span>
|
||||
{summary.truncated ? (
|
||||
<span key="truncated" className="text-xs text-amber-700 dark:text-amber-300">Truncated</span>
|
||||
) : null}
|
||||
{summary.warningCount > 0 ? (
|
||||
<span key="warnings" className="text-xs text-muted-foreground">{summary.warningCount} warnings</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div key="compare" className="mt-1 truncate font-mono text-xs text-muted-foreground">
|
||||
{compareLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="actions" className="flex flex-wrap items-center gap-2">
|
||||
<div key="layout" className="inline-flex gap-1" aria-label="Diff layout">
|
||||
<button key="split" type="button" className={buttonClass(mode === "split")} onClick={() => setMode("split")}>
|
||||
Split
|
||||
</button>
|
||||
<button key="unified" type="button" className={buttonClass(mode === "unified")} onClick={() => setMode("unified")}>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
<div key="view" className="inline-flex gap-1" aria-label="Diff comparison">
|
||||
<button key="working-tree" type="button" className={buttonClass(view === "working-tree")} onClick={() => setView("working-tree")}>
|
||||
Working tree
|
||||
</button>
|
||||
<button key="head" type="button" className={buttonClass(view === "head")} onClick={() => setView("head")}>
|
||||
Against ref
|
||||
</button>
|
||||
</div>
|
||||
{view === "head" ? (
|
||||
<input
|
||||
key="base-ref"
|
||||
className="h-8 w-40 rounded-md border border-border bg-background px-2.5 font-mono text-xs outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40"
|
||||
value={baseRef}
|
||||
onChange={(event) => {
|
||||
baseRefTouchedRef.current = true;
|
||||
setBaseRef(event.target.value);
|
||||
}}
|
||||
placeholder="origin/master"
|
||||
aria-label="Base ref"
|
||||
/>
|
||||
) : null}
|
||||
{view === "working-tree" ? (
|
||||
<button
|
||||
key="untracked"
|
||||
type="button"
|
||||
className={buttonClass(includeUntracked)}
|
||||
onClick={() => setIncludeUntracked((value) => !value)}
|
||||
>
|
||||
{includeUntracked ? "Untracked shown" : "Show untracked"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
key="refresh"
|
||||
type="button"
|
||||
className={iconButtonClass(false)}
|
||||
onClick={() => refresh()}
|
||||
title="Refresh changes"
|
||||
aria-label="Refresh changes"
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<ErrorState message={error.message} onRetry={refresh} />
|
||||
) : files.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div key="content" className="grid gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside key="files" className="flex min-w-0 flex-col border border-border bg-background lg:h-full lg:overflow-hidden">
|
||||
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
<div key="list" className="max-h-[70vh] overflow-auto lg:max-h-none lg:flex-1">
|
||||
{files.map((file, index) => (
|
||||
<FileRow
|
||||
key={`${file.path}:${index}`}
|
||||
file={file}
|
||||
active={file.path === selectedFile?.path}
|
||||
expanded={expandedFiles.has(file.path)}
|
||||
onSelect={() => selectFile(file.path)}
|
||||
onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
onCopy={() => void copyPath(file.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
key="diffs"
|
||||
ref={diffScrollRef}
|
||||
className="max-h-[70vh] min-w-0 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
|
||||
onScroll={handleDiffScroll}
|
||||
>
|
||||
{files
|
||||
.map((file, index) => (
|
||||
<section
|
||||
key={`${file.path}:${index}`}
|
||||
ref={setFileSectionRef(file.path)}
|
||||
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
|
||||
>
|
||||
<div key="header" className="flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-muted/35 px-3 py-2">
|
||||
<div key="left" className="flex min-w-0 items-start gap-2">
|
||||
<button
|
||||
key="collapse"
|
||||
type="button"
|
||||
className="mt-0.5 text-muted-foreground hover:text-foreground"
|
||||
title={expandedFiles.has(file.path) ? "Collapse file" : "Expand file"}
|
||||
aria-label={expandedFiles.has(file.path) ? `Collapse ${file.path}` : `Expand ${file.path}`}
|
||||
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
>
|
||||
{expandedFiles.has(file.path) ? "−" : "+"}
|
||||
</button>
|
||||
<button
|
||||
key="select"
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => selectFile(file.path)}
|
||||
>
|
||||
<div key="path" className="truncate text-sm font-medium">{file.path}</div>
|
||||
{file.oldPath ? (
|
||||
<div key="old-path" className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
from {file.oldPath}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
<div key="actions" className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
key="copy"
|
||||
type="button"
|
||||
className={iconButtonClass(false)}
|
||||
title="Copy path"
|
||||
aria-label={`Copy ${file.path}`}
|
||||
onClick={() => void copyPath(file.path)}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedFiles.has(file.path) ? (
|
||||
<FileDiffPanel key="diff" file={file} mode={mode} />
|
||||
) : (
|
||||
<CollapsedFilePanel
|
||||
key="collapsed"
|
||||
file={file}
|
||||
onExpand={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import { workspaceDiffQuerySchema } from "./contracts.js";
|
||||
import { workspaceDiffService } from "./workspace-diff.js";
|
||||
|
||||
const PLUGIN_NAME = "workspace-diff";
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | null {
|
||||
const trimmed = readString(value);
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
export function resolveDefaultBaseRef(input: {
|
||||
workspaceBaseRef?: unknown;
|
||||
projectWorkspaceDefaultRef?: unknown;
|
||||
projectWorkspaceRepoRef?: unknown;
|
||||
}): string | null {
|
||||
return readOptionalString(input.workspaceBaseRef)
|
||||
?? readOptionalString(input.projectWorkspaceDefaultRef)
|
||||
?? readOptionalString(input.projectWorkspaceRepoRef);
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
const workspaceDiff = workspaceDiffService();
|
||||
|
||||
ctx.data.register("workspace-diff", async (params: Record<string, unknown>) => {
|
||||
const workspaceId = readString(params.workspaceId);
|
||||
const companyId = readString(params.companyId);
|
||||
if (!workspaceId || !companyId) {
|
||||
throw new Error("workspaceId and companyId are required");
|
||||
}
|
||||
|
||||
if (params.entityType === "project_workspace") {
|
||||
const projectId = readString(params.projectId);
|
||||
if (!projectId) {
|
||||
throw new Error("projectId is required for project workspace diffs");
|
||||
}
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
return workspaceDiff.getDiff({
|
||||
id: workspace.id,
|
||||
companyId,
|
||||
cwd: workspace.path,
|
||||
baseRef: resolveDefaultBaseRef({
|
||||
projectWorkspaceDefaultRef: workspace.defaultRef,
|
||||
projectWorkspaceRepoRef: workspace.repoRef,
|
||||
}),
|
||||
}, workspaceDiffQuerySchema.parse(params));
|
||||
}
|
||||
|
||||
const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
let projectWorkspaceDefaultBaseRef: string | null = null;
|
||||
if (!readOptionalString(workspace.baseRef) && workspace.projectWorkspaceId) {
|
||||
const workspaces = await ctx.projects.listWorkspaces(workspace.projectId, companyId);
|
||||
const projectWorkspace = workspaces.find((candidate) => candidate.id === workspace.projectWorkspaceId);
|
||||
projectWorkspaceDefaultBaseRef = projectWorkspace
|
||||
? resolveDefaultBaseRef({
|
||||
projectWorkspaceDefaultRef: projectWorkspace.defaultRef,
|
||||
projectWorkspaceRepoRef: projectWorkspace.repoRef,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
return workspaceDiff.getDiff({
|
||||
...workspace,
|
||||
baseRef: resolveDefaultBaseRef({
|
||||
workspaceBaseRef: workspace.baseRef,
|
||||
projectWorkspaceDefaultRef: projectWorkspaceDefaultBaseRef,
|
||||
}),
|
||||
}, workspaceDiffQuerySchema.parse(params));
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: `${PLUGIN_NAME} ready` };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,787 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffFileStatus,
|
||||
WorkspaceDiffPatchKind,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
} from "./contracts.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const WORKSPACE_DIFF_CAPS: WorkspaceDiffCaps = {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 512 * 1024,
|
||||
maxPatchBytes: 256 * 1024,
|
||||
maxTotalPatchBytes: 1024 * 1024,
|
||||
};
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024;
|
||||
const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
||||
|
||||
interface GitStatusEntry {
|
||||
status: WorkspaceDiffFileStatus;
|
||||
path: string;
|
||||
oldPath: string | null;
|
||||
}
|
||||
|
||||
type DiffScope = "staged" | "unstaged" | "head";
|
||||
|
||||
interface MutableWorkspaceDiffFile extends WorkspaceDiffFile {
|
||||
patchScopes: DiffScope[];
|
||||
}
|
||||
|
||||
interface PatchBudget {
|
||||
totalPatchBytes: number;
|
||||
}
|
||||
|
||||
type WorkspaceDiffTarget = Pick<PluginExecutionWorkspaceMetadata, "id" | "companyId" | "cwd" | "baseRef">;
|
||||
|
||||
function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
|
||||
return { code, message, path: filePath };
|
||||
}
|
||||
|
||||
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
|
||||
const error = new Error(message);
|
||||
Object.assign(error, { code, status: 422, details: { code, ...details } });
|
||||
return error;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) {
|
||||
try {
|
||||
return await execFileAsync("git", ["-C", cwd, ...args], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer,
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
|
||||
? String((error as { stderr?: unknown }).stderr).trim()
|
||||
: "";
|
||||
const message = stderr || toErrorMessage(error);
|
||||
throw workspaceDiffError("git_command_failed", message, { args });
|
||||
}
|
||||
}
|
||||
|
||||
async function realDirectory(value: string, code: WorkspaceDiffWarningCode) {
|
||||
if (!path.isAbsolute(value)) {
|
||||
throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value });
|
||||
}
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stat = await fs.stat(value);
|
||||
} catch {
|
||||
throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value });
|
||||
}
|
||||
return await fs.realpath(value);
|
||||
}
|
||||
|
||||
function isWithinDirectory(childPath: string, parentPath: string) {
|
||||
const relative = path.relative(parentPath, childPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) {
|
||||
if (!workspace.cwd?.trim()) {
|
||||
throw workspaceDiffError(
|
||||
"missing_cwd",
|
||||
"Execution workspace needs a local path before Paperclip can inspect diffs",
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
}
|
||||
|
||||
const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid");
|
||||
let repoRoot: string;
|
||||
try {
|
||||
repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
} catch {
|
||||
throw workspaceDiffError(
|
||||
"non_git_workspace",
|
||||
"Execution workspace path is not inside a git repository",
|
||||
{ workspaceId: workspace.id, cwd },
|
||||
);
|
||||
}
|
||||
|
||||
const repoRootReal = await realDirectory(repoRoot, "non_git_workspace");
|
||||
if (!isWithinDirectory(cwd, repoRootReal)) {
|
||||
throw workspaceDiffError(
|
||||
"workspace_path_invalid",
|
||||
"Execution workspace path resolved outside its git repository",
|
||||
{ workspaceId: workspace.id, cwd, repoRoot: repoRootReal },
|
||||
);
|
||||
}
|
||||
|
||||
return { cwd, repoRoot: repoRootReal };
|
||||
}
|
||||
|
||||
function normalizePathFilter(rawPath: string) {
|
||||
const value = rawPath.trim().replaceAll("\\", "/");
|
||||
if (!value || value === ".") return null;
|
||||
if (value.includes("\0") || value.startsWith("/")) {
|
||||
throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath });
|
||||
}
|
||||
const normalized = path.posix.normalize(value);
|
||||
if (
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized.includes("/../")
|
||||
) {
|
||||
throw workspaceDiffError(
|
||||
"path_filter_invalid",
|
||||
"Path filters must not contain traversal segments",
|
||||
{ path: rawPath },
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePathFilters(paths: string[]) {
|
||||
return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
function statusFromGitStatus(status: string): WorkspaceDiffFileStatus {
|
||||
if (status.startsWith("R")) return "renamed";
|
||||
if (status.startsWith("C")) return "copied";
|
||||
switch (status[0]) {
|
||||
case "A":
|
||||
return "added";
|
||||
case "D":
|
||||
return "deleted";
|
||||
case "M":
|
||||
return "modified";
|
||||
case "T":
|
||||
return "type_changed";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function parseNameStatus(output: string): GitStatusEntry[] {
|
||||
const tokens = output.split("\0").filter(Boolean);
|
||||
const entries: GitStatusEntry[] = [];
|
||||
let index = 0;
|
||||
while (index < tokens.length) {
|
||||
const statusCode = tokens[index++] ?? "";
|
||||
if (!statusCode) continue;
|
||||
if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
|
||||
const oldPath = tokens[index++] ?? "";
|
||||
const newPath = tokens[index++] ?? "";
|
||||
if (newPath) {
|
||||
entries.push({
|
||||
status: statusFromGitStatus(statusCode),
|
||||
path: newPath,
|
||||
oldPath: oldPath || null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = tokens[index++] ?? "";
|
||||
if (filePath) {
|
||||
entries.push({
|
||||
status: statusFromGitStatus(statusCode),
|
||||
path: filePath,
|
||||
oldPath: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) {
|
||||
const result = await runGit(cwd, [
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
...scopeArgs,
|
||||
"--",
|
||||
...paths,
|
||||
]);
|
||||
return parseNameStatus(result.stdout);
|
||||
}
|
||||
|
||||
async function readUntrackedPaths(cwd: string, paths: string[]) {
|
||||
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]);
|
||||
return result.stdout.split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureFile(
|
||||
files: Map<string, MutableWorkspaceDiffFile>,
|
||||
filePath: string,
|
||||
status: WorkspaceDiffFileStatus,
|
||||
oldPath: string | null,
|
||||
) {
|
||||
const existing = files.get(filePath);
|
||||
if (existing) {
|
||||
if (existing.status === "unknown" || status === "renamed" || status === "copied") {
|
||||
existing.status = status;
|
||||
}
|
||||
if (!existing.oldPath && oldPath) existing.oldPath = oldPath;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const file: MutableWorkspaceDiffFile = {
|
||||
path: filePath,
|
||||
oldPath,
|
||||
status,
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
sizeBytes: null,
|
||||
patches: [],
|
||||
warnings: [],
|
||||
patchScopes: [],
|
||||
};
|
||||
files.set(filePath, file);
|
||||
return file;
|
||||
}
|
||||
|
||||
function addStatusEntries(
|
||||
files: Map<string, MutableWorkspaceDiffFile>,
|
||||
entries: GitStatusEntry[],
|
||||
scope: DiffScope,
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
const file = ensureFile(files, entry.path, entry.status, entry.oldPath);
|
||||
if (scope === "staged") file.staged = true;
|
||||
else if (scope === "unstaged") file.unstaged = true;
|
||||
if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumstat(output: string) {
|
||||
const line = output.split(/\r?\n/).find(Boolean);
|
||||
if (!line) return { additions: 0, deletions: 0, binary: false };
|
||||
const [additionsRaw, deletionsRaw] = line.split(/\t/);
|
||||
if (additionsRaw === "-" || deletionsRaw === "-") {
|
||||
return { additions: 0, deletions: 0, binary: true };
|
||||
}
|
||||
return {
|
||||
additions: Number.parseInt(additionsRaw ?? "0", 10) || 0,
|
||||
deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0,
|
||||
binary: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) {
|
||||
const result = await runGit(cwd, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
...scopeArgs,
|
||||
"--",
|
||||
filePath,
|
||||
], 128 * 1024);
|
||||
return parseNumstat(result.stdout);
|
||||
}
|
||||
|
||||
async function statWorkspaceFile(repoRoot: string, filePath: string) {
|
||||
const resolved = await resolveWorkspaceFilePath(repoRoot, filePath);
|
||||
if (resolved.status !== "ok") return null;
|
||||
let handle: Awaited<ReturnType<typeof fs.open>>;
|
||||
try {
|
||||
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
return stat.isFile() ? stat.size : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWorkspaceFilePath(repoRoot: string, filePath: string): Promise<
|
||||
| { status: "ok"; realPath: string }
|
||||
| { status: "missing" }
|
||||
| { status: "outside_workspace" }
|
||||
> {
|
||||
const target = path.resolve(repoRoot, filePath);
|
||||
if (!isWithinDirectory(target, repoRoot)) return { status: "outside_workspace" };
|
||||
try {
|
||||
const realPath = await fs.realpath(target);
|
||||
if (!isWithinDirectory(realPath, repoRoot)) return { status: "outside_workspace" };
|
||||
return { status: "ok", realPath };
|
||||
} catch {
|
||||
return { status: "missing" };
|
||||
}
|
||||
}
|
||||
|
||||
function isMaxBufferError(error: unknown) {
|
||||
return typeof error === "object"
|
||||
&& error !== null
|
||||
&& "code" in error
|
||||
&& (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
||||
}
|
||||
|
||||
async function readPatchOutput(cwd: string, args: string[]) {
|
||||
try {
|
||||
return await execFileAsync("git", ["-C", cwd, ...args], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMaxBufferError(error)) {
|
||||
return null;
|
||||
}
|
||||
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
|
||||
? String((error as { stderr?: unknown }).stderr).trim()
|
||||
: "";
|
||||
throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args });
|
||||
}
|
||||
}
|
||||
|
||||
function reservePatchBytes(
|
||||
patch: string,
|
||||
budget: PatchBudget,
|
||||
filePath: string,
|
||||
warnings: WorkspaceDiffWarning[],
|
||||
) {
|
||||
const patchBytes = Buffer.byteLength(patch, "utf8");
|
||||
if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) {
|
||||
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath));
|
||||
return null;
|
||||
}
|
||||
if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) {
|
||||
warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath));
|
||||
return null;
|
||||
}
|
||||
budget.totalPatchBytes += patchBytes;
|
||||
return patch;
|
||||
}
|
||||
|
||||
async function buildTrackedPatch(input: {
|
||||
cwd: string;
|
||||
repoRoot: string;
|
||||
filePath: string;
|
||||
kind: WorkspaceDiffPatchKind;
|
||||
scopeArgs: string[];
|
||||
budget: PatchBudget;
|
||||
}): Promise<WorkspaceDiffFilePatch> {
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath);
|
||||
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
|
||||
|
||||
if (numstat.binary) {
|
||||
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: true,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
||||
warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: true,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const patchOutput = await readPatchOutput(input.cwd, [
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--find-renames",
|
||||
"--unified=3",
|
||||
...input.scopeArgs,
|
||||
"--",
|
||||
input.filePath,
|
||||
]);
|
||||
if (!patchOutput) {
|
||||
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath));
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch: null,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: true,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings);
|
||||
return {
|
||||
kind: input.kind,
|
||||
patch,
|
||||
additions: numstat.additions,
|
||||
deletions: numstat.deletions,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: patch === null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function isProbablyBinary(buffer: Buffer) {
|
||||
return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0);
|
||||
}
|
||||
|
||||
function countAddedLines(content: string) {
|
||||
if (content.length === 0) return 0;
|
||||
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
||||
}
|
||||
|
||||
function buildUntrackedPatch(filePath: string, content: string) {
|
||||
const lines = content.length === 0 ? [] : content.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
const lineCount = countAddedLines(content);
|
||||
const header = [
|
||||
`diff --git a/${filePath} b/${filePath}`,
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
`+++ b/${filePath}`,
|
||||
];
|
||||
if (lineCount === 0) return `${header.join("\n")}\n`;
|
||||
const hunkLines = lines.map((line) => `+${line}`).join("\n");
|
||||
return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n");
|
||||
}
|
||||
|
||||
async function buildUntrackedFilePatch(input: {
|
||||
repoRoot: string;
|
||||
filePath: string;
|
||||
budget: PatchBudget;
|
||||
}): Promise<WorkspaceDiffFilePatch> {
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const resolved = await resolveWorkspaceFilePath(input.repoRoot, input.filePath);
|
||||
if (resolved.status === "outside_workspace") {
|
||||
warnings.push(warning(
|
||||
"symlink_target_outside_workspace",
|
||||
"Untracked file resolves outside the workspace and is summarized without reading target bytes.",
|
||||
input.filePath,
|
||||
));
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (resolved.status === "missing") {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let handle: Awaited<ReturnType<typeof fs.open>>;
|
||||
try {
|
||||
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
|
||||
} catch {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let sizeBytes: number;
|
||||
let buffer: Buffer | null = null;
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
sizeBytes = stat.size;
|
||||
if (sizeBytes <= WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
||||
buffer = await handle.readFile();
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
|
||||
warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath));
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: true,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (isProbablyBinary(buffer)) {
|
||||
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: true,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const content = buffer.toString("utf8");
|
||||
const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings);
|
||||
return {
|
||||
kind: "untracked",
|
||||
patch,
|
||||
additions: countAddedLines(content),
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: patch === null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) {
|
||||
file.patches.push(patch);
|
||||
file.additions += patch.additions;
|
||||
file.deletions += patch.deletions;
|
||||
file.binary = file.binary || patch.binary;
|
||||
file.oversized = file.oversized || patch.oversized;
|
||||
file.truncated = file.truncated || patch.truncated;
|
||||
file.warnings.push(...patch.warnings);
|
||||
if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes;
|
||||
}
|
||||
|
||||
function finalizeStats(files: WorkspaceDiffFile[]) {
|
||||
return {
|
||||
fileCount: files.length,
|
||||
stagedFileCount: files.filter((file) => file.staged).length,
|
||||
unstagedFileCount: files.filter((file) => file.unstaged).length,
|
||||
untrackedFileCount: files.filter((file) => file.untracked).length,
|
||||
binaryFileCount: files.filter((file) => file.binary).length,
|
||||
oversizedFileCount: files.filter((file) => file.oversized).length,
|
||||
truncatedFileCount: files.filter((file) => file.truncated).length,
|
||||
additions: files.reduce((sum, file) => sum + file.additions, 0),
|
||||
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveHeadSha(cwd: string) {
|
||||
try {
|
||||
return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
|
||||
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
|
||||
if (!resolvedBaseRef) {
|
||||
throw workspaceDiffError(
|
||||
"base_ref_missing",
|
||||
"A baseRef query parameter or execution workspace baseRef is required for head diffs",
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
}
|
||||
try {
|
||||
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: 128 * 1024,
|
||||
});
|
||||
} catch {
|
||||
throw workspaceDiffError(
|
||||
"base_ref_invalid",
|
||||
`Could not resolve baseRef "${resolvedBaseRef}" in this workspace`,
|
||||
{ workspaceId: workspace.id, baseRef: resolvedBaseRef },
|
||||
);
|
||||
}
|
||||
return resolvedBaseRef;
|
||||
}
|
||||
|
||||
async function collectFiles(input: {
|
||||
cwd: string;
|
||||
workspace: WorkspaceDiffTarget;
|
||||
query: WorkspaceDiffQueryOptions;
|
||||
paths: string[];
|
||||
}) {
|
||||
const files = new Map<string, MutableWorkspaceDiffFile>();
|
||||
let baseRef: string | null = null;
|
||||
|
||||
if (input.query.view === "head") {
|
||||
baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace);
|
||||
addStatusEntries(
|
||||
files,
|
||||
await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths),
|
||||
"head",
|
||||
);
|
||||
} else {
|
||||
addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged");
|
||||
addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged");
|
||||
if (input.query.includeUntracked) {
|
||||
for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) {
|
||||
const file = ensureFile(files, untrackedPath, "untracked", null);
|
||||
file.untracked = true;
|
||||
if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { files, baseRef };
|
||||
}
|
||||
|
||||
export function workspaceDiffService() {
|
||||
return {
|
||||
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
|
||||
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
|
||||
const paths = normalizePathFilters(query.paths);
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
const { files: filesByPath, baseRef } = await collectFiles({ cwd, workspace, query, paths });
|
||||
const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
|
||||
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
|
||||
if (allFiles.length > cappedFiles.length) {
|
||||
warnings.push(warning(
|
||||
"file_count_truncated",
|
||||
`Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`,
|
||||
));
|
||||
}
|
||||
|
||||
const patchBudget: PatchBudget = { totalPatchBytes: 0 };
|
||||
for (const file of cappedFiles) {
|
||||
if (query.view === "head") {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "head",
|
||||
scopeArgs: [`${baseRef}...HEAD`],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.staged) {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "staged",
|
||||
scopeArgs: ["--cached"],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
if (file.unstaged) {
|
||||
const patch = await buildTrackedPatch({
|
||||
cwd,
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
kind: "unstaged",
|
||||
scopeArgs: [],
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
if (file.untracked) {
|
||||
const patch = await buildUntrackedFilePatch({
|
||||
repoRoot,
|
||||
filePath: file.path,
|
||||
budget: patchBudget,
|
||||
});
|
||||
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
|
||||
}
|
||||
}
|
||||
|
||||
const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file);
|
||||
const patchWarnings = files.flatMap((file) => file.warnings);
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
companyId: workspace.companyId,
|
||||
view: query.view,
|
||||
baseRef,
|
||||
defaultBaseRef: workspace.baseRef,
|
||||
headSha: await resolveHeadSha(cwd),
|
||||
includeUntracked: query.includeUntracked,
|
||||
paths,
|
||||
files,
|
||||
stats: finalizeStats(files),
|
||||
warnings: [...warnings, ...patchWarnings],
|
||||
caps: WORKSPACE_DIFF_CAPS,
|
||||
truncated: warnings.some((item) => item.code === "file_count_truncated")
|
||||
|| files.some((file) => file.truncated),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "../src/contracts.js";
|
||||
import { diffResponse } from "./fixtures.js";
|
||||
|
||||
describe("workspace diff plugin contracts", () => {
|
||||
it("normalizes query options from plugin data parameters", () => {
|
||||
expect(workspaceDiffQuerySchema.parse({
|
||||
view: "head",
|
||||
baseRef: " main ",
|
||||
includeUntracked: "false",
|
||||
path: ["src/app.ts, README.md", "packages/shared/src/index.ts"],
|
||||
})).toEqual({
|
||||
view: "head",
|
||||
baseRef: "main",
|
||||
includeUntracked: false,
|
||||
paths: ["src/app.ts", "README.md", "packages/shared/src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("validates the plugin-owned response shape", () => {
|
||||
expect(workspaceDiffResponseSchema.parse(diffResponse())).toMatchObject({
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
stats: { fileCount: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFilePatch,
|
||||
buildFilePatches,
|
||||
diffSummary,
|
||||
initialExpandedFileSet,
|
||||
LONG_DIFF_LINE_THRESHOLD,
|
||||
nextExpandedFileSet,
|
||||
statusLabel,
|
||||
toFileViewModels,
|
||||
} from "../src/diff-model.js";
|
||||
import { changedFile, diffResponse } from "./fixtures.js";
|
||||
|
||||
describe("workspace diff UI model", () => {
|
||||
it("summarizes changed files and line counts", () => {
|
||||
const diff = diffResponse();
|
||||
|
||||
expect(diffSummary(diff)).toMatchObject({
|
||||
changedLabel: "1 file",
|
||||
lineLabel: "+1 / -1",
|
||||
warningCount: 0,
|
||||
truncated: false,
|
||||
});
|
||||
expect(toFileViewModels(diff)[0]).toMatchObject({
|
||||
path: "src/app.ts",
|
||||
status: "modified",
|
||||
patchKinds: ["unstaged"],
|
||||
lineCount: 7,
|
||||
longDiff: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("represents empty workspace diffs", () => {
|
||||
const diff = diffResponse({ files: [] });
|
||||
|
||||
expect(toFileViewModels(diff)).toEqual([]);
|
||||
expect(diffSummary(diff).changedLabel).toBe("0 files");
|
||||
});
|
||||
|
||||
it("surfaces truncation and file warnings", () => {
|
||||
const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" };
|
||||
const file = changedFile({
|
||||
truncated: true,
|
||||
warnings: [warning],
|
||||
patches: [],
|
||||
});
|
||||
const diff = diffResponse({ files: [file], truncated: true, warnings: [warning] });
|
||||
|
||||
expect(buildFilePatch(file)).toBeNull();
|
||||
expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]);
|
||||
expect(diffSummary(diff)).toMatchObject({
|
||||
warningCount: 1,
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate aggregated patch warnings", () => {
|
||||
const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" };
|
||||
const file = changedFile({
|
||||
warnings: [warning],
|
||||
patches: [
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: true,
|
||||
warnings: [warning],
|
||||
},
|
||||
],
|
||||
});
|
||||
const diff = diffResponse({ files: [file], warnings: [warning] });
|
||||
|
||||
expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]);
|
||||
expect(diffSummary(diff).warningCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps staged and unstaged patches renderable as separate single-file diffs", () => {
|
||||
const stagedPatch = [
|
||||
"diff --git a/src/app.ts b/src/app.ts",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/src/app.ts",
|
||||
"+++ b/src/app.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-export const value = 1;",
|
||||
"+export const value = 2;",
|
||||
"",
|
||||
].join("\n");
|
||||
const unstagedPatch = [
|
||||
"diff --git a/src/app.ts b/src/app.ts",
|
||||
"index 2222222..3333333 100644",
|
||||
"--- a/src/app.ts",
|
||||
"+++ b/src/app.ts",
|
||||
"@@ -3 +3 @@",
|
||||
"-export const label = 'old';",
|
||||
"+export const label = 'new';",
|
||||
"",
|
||||
].join("\n");
|
||||
const file = changedFile({
|
||||
staged: true,
|
||||
unstaged: true,
|
||||
patches: [
|
||||
{
|
||||
kind: "staged",
|
||||
patch: stagedPatch,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: unstagedPatch,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const patches = buildFilePatches(file);
|
||||
const viewModel = toFileViewModels(diffResponse({ files: [file] }))[0];
|
||||
|
||||
expect(buildFilePatch(file)).toBe(stagedPatch.trimEnd());
|
||||
expect(patches.map((patch) => patch.kind)).toEqual(["staged", "unstaged"]);
|
||||
expect(patches.map((patch) => patch.patch?.match(/^diff --git/gm)?.length ?? 0)).toEqual([1, 1]);
|
||||
expect(viewModel?.patches).toHaveLength(2);
|
||||
expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]);
|
||||
});
|
||||
|
||||
it("marks long text diffs so the UI can fold them by default", () => {
|
||||
const longPatch = [
|
||||
"diff --git a/src/large.ts b/src/large.ts",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/src/large.ts",
|
||||
"+++ b/src/large.ts",
|
||||
"@@ -1,1 +1,1 @@",
|
||||
...Array.from({ length: LONG_DIFF_LINE_THRESHOLD }, (_, index) => `+export const value${index} = ${index};`),
|
||||
"",
|
||||
].join("\n");
|
||||
const files = toFileViewModels(diffResponse({
|
||||
files: [
|
||||
changedFile({ path: "src/small.ts" }),
|
||||
changedFile({
|
||||
path: "src/large.ts",
|
||||
additions: LONG_DIFF_LINE_THRESHOLD,
|
||||
deletions: 0,
|
||||
patches: [
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: longPatch,
|
||||
additions: LONG_DIFF_LINE_THRESHOLD,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const longFile = files.find((file) => file.path === "src/large.ts");
|
||||
const defaultExpanded = initialExpandedFileSet(files);
|
||||
|
||||
expect(longFile?.lineCount).toBeGreaterThan(LONG_DIFF_LINE_THRESHOLD);
|
||||
expect(longFile?.longDiff).toBe(true);
|
||||
expect(defaultExpanded.has("src/small.ts")).toBe(true);
|
||||
expect(defaultExpanded.has("src/large.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles expanded file state without mutating the current set", () => {
|
||||
const current = new Set(["a.ts"]);
|
||||
const collapsed = nextExpandedFileSet(current, "a.ts");
|
||||
const expanded = nextExpandedFileSet(current, "b.ts");
|
||||
|
||||
expect(current.has("a.ts")).toBe(true);
|
||||
expect(collapsed.has("a.ts")).toBe(false);
|
||||
expect(expanded.has("b.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("labels file statuses for the sidebar", () => {
|
||||
expect(statusLabel("untracked")).toBe("Untracked");
|
||||
expect(statusLabel("type_changed")).toBe("Type changed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js";
|
||||
|
||||
export function changedFile(overrides: Partial<WorkspaceDiffFile> = {}): WorkspaceDiffFile {
|
||||
return {
|
||||
path: "src/app.ts",
|
||||
oldPath: null,
|
||||
status: "modified",
|
||||
staged: false,
|
||||
unstaged: true,
|
||||
untracked: false,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
sizeBytes: 120,
|
||||
patches: [
|
||||
{
|
||||
kind: "unstaged",
|
||||
patch: [
|
||||
"diff --git a/src/app.ts b/src/app.ts",
|
||||
"index 1111111..2222222 100644",
|
||||
"--- a/src/app.ts",
|
||||
"+++ b/src/app.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-export const value = 1;",
|
||||
"+export const value = 2;",
|
||||
"",
|
||||
].join("\n"),
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
oversized: false,
|
||||
truncated: false,
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function diffResponse(overrides: Partial<WorkspaceDiffResponse> = {}): WorkspaceDiffResponse {
|
||||
const files = overrides.files ?? [changedFile()];
|
||||
const additions = files.reduce((sum, file) => sum + file.additions, 0);
|
||||
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
|
||||
return {
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
view: "working-tree",
|
||||
baseRef: null,
|
||||
defaultBaseRef: null,
|
||||
headSha: null,
|
||||
includeUntracked: true,
|
||||
paths: [],
|
||||
files,
|
||||
stats: {
|
||||
fileCount: files.length,
|
||||
stagedFileCount: files.filter((file) => file.staged).length,
|
||||
unstagedFileCount: files.filter((file) => file.unstaged).length,
|
||||
untrackedFileCount: files.filter((file) => file.untracked).length,
|
||||
binaryFileCount: files.filter((file) => file.binary).length,
|
||||
oversizedFileCount: files.filter((file) => file.oversized).length,
|
||||
truncatedFileCount: files.filter((file) => file.truncated).length,
|
||||
additions,
|
||||
deletions,
|
||||
},
|
||||
warnings: [],
|
||||
caps: {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 524288,
|
||||
maxPatchBytes: 131072,
|
||||
maxTotalPatchBytes: 1048576,
|
||||
},
|
||||
truncated: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin, { resolveDefaultBaseRef } from "../src/worker.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function git(cwd: string, args: string[]) {
|
||||
return execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function createGitWorkspace() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-plugin-"));
|
||||
tempRoots.push(root);
|
||||
await fs.mkdir(path.join(root, "src"), { recursive: true });
|
||||
await git(root, ["init"]);
|
||||
await git(root, ["config", "user.email", "paperclip@example.com"]);
|
||||
await git(root, ["config", "user.name", "Paperclip Test"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "initial"]);
|
||||
await git(root, ["branch", "-M", "main"]);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("workspace diff plugin", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
it("declares workspace Changes tabs and workspace read capabilities", () => {
|
||||
expect(manifest.capabilities).toContain("ui.detailTab.register");
|
||||
expect(manifest.capabilities).toContain("execution.workspaces.read");
|
||||
expect(manifest.capabilities).toContain("project.workspaces.read");
|
||||
expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({
|
||||
type: "detailTab",
|
||||
displayName: "Changes",
|
||||
entityTypes: ["execution_workspace", "project_workspace"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("fetches changed execution workspace diffs from host metadata", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 2;\n");
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
executionWorkspaces: [{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
path: root,
|
||||
cwd: root,
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName: "main",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
}],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
view: "working-tree",
|
||||
includeUntracked: false,
|
||||
paths: ["src/app.ts"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty diff when the workspace has no changes", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
executionWorkspaces: [{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
path: root,
|
||||
cwd: root,
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName: "main",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
}],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
})).resolves.toMatchObject({ files: [], truncated: false });
|
||||
});
|
||||
|
||||
it("fetches project workspace diffs from generic project workspace metadata", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await git(root, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 3;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "project workspace change"]);
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
|
||||
expect(projectId).toBe("project-1");
|
||||
expect(companyId).toBe("company-1");
|
||||
return [{
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary",
|
||||
path: root,
|
||||
repoUrl: null,
|
||||
repoRef: "feature",
|
||||
defaultRef: "main",
|
||||
isPrimary: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}];
|
||||
};
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
entityType: "project_workspace",
|
||||
view: "head",
|
||||
includeUntracked: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
baseRef: "main",
|
||||
defaultBaseRef: "main",
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the default base ref from workspace and project workspace metadata", () => {
|
||||
expect(resolveDefaultBaseRef({
|
||||
workspaceBaseRef: " release/main ",
|
||||
projectWorkspaceDefaultRef: "origin/main",
|
||||
projectWorkspaceRepoRef: "feature",
|
||||
})).toBe("release/main");
|
||||
expect(resolveDefaultBaseRef({
|
||||
workspaceBaseRef: null,
|
||||
projectWorkspaceDefaultRef: " origin/main ",
|
||||
projectWorkspaceRepoRef: "feature",
|
||||
})).toBe("origin/main");
|
||||
expect(resolveDefaultBaseRef({
|
||||
workspaceBaseRef: "",
|
||||
projectWorkspaceDefaultRef: null,
|
||||
projectWorkspaceRepoRef: " feature ",
|
||||
})).toBe("feature");
|
||||
expect(resolveDefaultBaseRef({
|
||||
workspaceBaseRef: "",
|
||||
projectWorkspaceDefaultRef: null,
|
||||
projectWorkspaceRepoRef: "",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("uses project workspace default refs for execution workspace head diffs", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await git(root, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 4;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "feature change"]);
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
executionWorkspaces: [{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "project-workspace-1",
|
||||
path: root,
|
||||
cwd: root,
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
}],
|
||||
});
|
||||
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
|
||||
expect(projectId).toBe("project-1");
|
||||
expect(companyId).toBe("company-1");
|
||||
return [{
|
||||
id: "project-workspace-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary",
|
||||
path: root,
|
||||
repoUrl: null,
|
||||
repoRef: "feature",
|
||||
defaultRef: "main",
|
||||
isPrimary: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}];
|
||||
};
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
view: "head",
|
||||
includeUntracked: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
baseRef: "main",
|
||||
defaultBaseRef: "main",
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a clear bridge error when required context is missing", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
})).rejects.toThrow("workspaceId and companyId are required");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ErrorState } from "../src/ui/index.js";
|
||||
|
||||
describe("workspace diff error state", () => {
|
||||
it("keeps bridge error details out of the primary headline", () => {
|
||||
const rawError = "Execution workspace not found";
|
||||
const html = renderToStaticMarkup(createElement(ErrorState, {
|
||||
message: rawError,
|
||||
onRetry: () => undefined,
|
||||
}));
|
||||
|
||||
expect(html).toContain("Unable to load workspace changes.");
|
||||
expect(html).toContain("Retry");
|
||||
expect(html).toContain("Troubleshooting details");
|
||||
expect(html).not.toContain(`font-medium text-foreground">${rawError}`);
|
||||
expect(html.indexOf(rawError)).toBeGreaterThan(html.indexOf("Troubleshooting details"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffQueryOptions } from "../src/contracts.js";
|
||||
import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../src/workspace-diff.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-"));
|
||||
tempDirs.add(repoRoot);
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "delete-me.txt"), "charlie\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "rename-me.txt"), "delta\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3]));
|
||||
await runGit(repoRoot, ["add", "."]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||
await runGit(repoRoot, ["branch", "-M", "main"]);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function createWorkspace(cwd: string | null, overrides: Partial<PluginExecutionWorkspaceMetadata> = {}): PluginExecutionWorkspaceMetadata {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: randomUUID(),
|
||||
projectId: randomUUID(),
|
||||
projectWorkspaceId: null,
|
||||
path: cwd,
|
||||
cwd,
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workingTreeQuery(overrides: Partial<WorkspaceDiffQueryOptions> = {}): WorkspaceDiffQueryOptions {
|
||||
return {
|
||||
view: "working-tree",
|
||||
baseRef: null,
|
||||
includeUntracked: true,
|
||||
paths: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
describe("plugin workspace diff service", () => {
|
||||
it("returns staged, unstaged, renamed, deleted, untracked, binary, and oversized working-tree changes", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\nstaged\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\nunstaged\n", "utf8");
|
||||
await runGit(repoRoot, ["mv", "rename-me.txt", "renamed.txt"]);
|
||||
await fs.rm(path.join(repoRoot, "delete-me.txt"));
|
||||
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3, 4, 5]));
|
||||
await fs.writeFile(path.join(repoRoot, "untracked.txt"), "brand new\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "empty-untracked.txt"), "", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "oversized.txt"), "x".repeat(WORKSPACE_DIFF_CAPS.maxFileBytes + 1), "utf8");
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
const byPath = new Map(diff.files.map((file) => [file.path, file]));
|
||||
|
||||
expect(diff.view).toBe("working-tree");
|
||||
expect(byPath.get("tracked-staged.txt")).toMatchObject({ staged: true, unstaged: false, status: "modified", additions: 1 });
|
||||
expect(byPath.get("tracked-staged.txt")?.patches.map((patch) => patch.kind)).toEqual(["staged"]);
|
||||
expect(byPath.get("tracked-unstaged.txt")).toMatchObject({ staged: false, unstaged: true, status: "modified", additions: 1 });
|
||||
expect(byPath.get("renamed.txt")).toMatchObject({ oldPath: "rename-me.txt", staged: true, status: "renamed" });
|
||||
expect(byPath.get("delete-me.txt")).toMatchObject({ unstaged: true, status: "deleted", deletions: 1 });
|
||||
expect(byPath.get("untracked.txt")).toMatchObject({ untracked: true, status: "untracked", additions: 1 });
|
||||
expect(byPath.get("untracked.txt")?.patches[0]?.patch).toContain("+brand new");
|
||||
expect(byPath.get("empty-untracked.txt")?.patches[0]?.patch).toBe([
|
||||
"diff --git a/empty-untracked.txt b/empty-untracked.txt",
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
"+++ b/empty-untracked.txt",
|
||||
"",
|
||||
].join("\n"));
|
||||
expect(byPath.get("binary.bin")).toMatchObject({ binary: true, unstaged: true });
|
||||
expect(byPath.get("oversized.txt")).toMatchObject({ oversized: true, untracked: true });
|
||||
expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining(["binary_file", "file_oversized"]));
|
||||
}, 20_000);
|
||||
|
||||
it("returns head diffs against the requested base ref", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await runGit(repoRoot, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\ncommitted\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Feature change"]);
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(
|
||||
createWorkspace(repoRoot, { baseRef: "main" }),
|
||||
workingTreeQuery({ view: "head", includeUntracked: false }),
|
||||
);
|
||||
|
||||
expect(diff.baseRef).toBe("main");
|
||||
expect(diff.files).toHaveLength(1);
|
||||
expect(diff.files[0]).toMatchObject({
|
||||
path: "tracked-staged.txt",
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
});
|
||||
expect(diff.files[0]?.patches.map((patch) => patch.kind)).toEqual(["head"]);
|
||||
}, 20_000);
|
||||
|
||||
it("filters changed files by relative workspace paths", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\none\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\ntwo\n", "utf8");
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(
|
||||
createWorkspace(repoRoot),
|
||||
workingTreeQuery({ paths: ["tracked-staged.txt"] }),
|
||||
);
|
||||
|
||||
expect(diff.paths).toEqual(["tracked-staged.txt"]);
|
||||
expect(diff.files.map((file) => file.path)).toEqual(["tracked-staged.txt"]);
|
||||
}, 20_000);
|
||||
|
||||
it("applies output caps to large workspace responses", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
for (let index = 0; index < WORKSPACE_DIFF_CAPS.maxFiles + 1; index += 1) {
|
||||
await fs.writeFile(path.join(repoRoot, `untracked-${String(index).padStart(3, "0")}.txt`), "", "utf8");
|
||||
}
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
|
||||
expect(diff.files).toHaveLength(WORKSPACE_DIFF_CAPS.maxFiles);
|
||||
expect(diff.truncated).toBe(true);
|
||||
expect(diff.warnings).toContainEqual(expect.objectContaining({ code: "file_count_truncated" }));
|
||||
}, 20_000);
|
||||
|
||||
it("does not follow untracked symlinks outside the repo", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-secret-"));
|
||||
tempDirs.add(outsideDir);
|
||||
const secretContent = "external secret should not appear\n";
|
||||
const secretPath = path.join(outsideDir, "secret.txt");
|
||||
await fs.writeFile(secretPath, secretContent, "utf8");
|
||||
await fs.symlink(secretPath, path.join(repoRoot, "leak.txt"));
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
const leak = diff.files.find((file) => file.path === "leak.txt");
|
||||
const serialized = JSON.stringify(diff);
|
||||
|
||||
expect(leak).toMatchObject({ untracked: true, status: "untracked", additions: 0, sizeBytes: null });
|
||||
expect(leak?.patches[0]).toMatchObject({
|
||||
kind: "untracked",
|
||||
patch: null,
|
||||
warnings: [expect.objectContaining({ code: "symlink_target_outside_workspace" })],
|
||||
});
|
||||
expect(diff.warnings).toContainEqual(expect.objectContaining({
|
||||
code: "symlink_target_outside_workspace",
|
||||
path: "leak.txt",
|
||||
}));
|
||||
expect(serialized).not.toContain(secretContent.trim());
|
||||
}, 20_000);
|
||||
|
||||
it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as plugin errors", async () => {
|
||||
const svc = workspaceDiffService();
|
||||
await expect(svc.getDiff(createWorkspace(null), workingTreeQuery()))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "missing_cwd" } });
|
||||
|
||||
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-non-git-"));
|
||||
tempDirs.add(nonGitDir);
|
||||
await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery()))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } });
|
||||
|
||||
const repoRoot = await createTempRepo();
|
||||
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ paths: ["../secret"] })))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "path_filter_invalid" } });
|
||||
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ view: "head", baseRef: "missing-ref" })))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "base_ref_invalid" } });
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -181,6 +181,11 @@ export interface HostServices {
|
||||
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `executionWorkspaces.get`. */
|
||||
executionWorkspaces: {
|
||||
get(params: WorkerToHostMethods["executionWorkspaces.get"][0]): Promise<WorkerToHostMethods["executionWorkspaces.get"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `routines.managed.*`. */
|
||||
routines: {
|
||||
managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise<WorkerToHostMethods["routines.managed.get"][1]>;
|
||||
@@ -368,6 +373,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||
"executionWorkspaces.get": "execution.workspaces.read",
|
||||
"projects.managed.get": "projects.managed",
|
||||
"projects.managed.reconcile": "projects.managed",
|
||||
"projects.managed.reset": "projects.managed",
|
||||
@@ -608,6 +614,9 @@ export function createHostClientHandlers(
|
||||
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
||||
return services.projects.getWorkspaceForIssue(params);
|
||||
}),
|
||||
"executionWorkspaces.get": gated("executionWorkspaces.get", async (params) => {
|
||||
return services.executionWorkspaces.get(params);
|
||||
}),
|
||||
"projects.managed.get": gated("projects.managed.get", async (params) => {
|
||||
return services.projects.getManaged(params);
|
||||
}),
|
||||
|
||||
@@ -197,6 +197,7 @@ export type {
|
||||
PluginStateClient,
|
||||
PluginEntitiesClient,
|
||||
PluginProjectsClient,
|
||||
PluginExecutionWorkspacesClient,
|
||||
PluginSkillsClient,
|
||||
PluginCompaniesClient,
|
||||
PluginIssuesClient,
|
||||
@@ -244,6 +245,7 @@ export type {
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginWorkspace,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
PluginIssueWakeupBatchResult,
|
||||
PluginIssueWakeupResult,
|
||||
PluginJobContext,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
PluginWorkspace,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
@@ -777,6 +778,13 @@ export interface WorkerToHostMethods {
|
||||
params: { issueId: string; companyId: string },
|
||||
result: PluginWorkspace | null,
|
||||
];
|
||||
"executionWorkspaces.get": [
|
||||
params: {
|
||||
workspaceId: string;
|
||||
companyId: string;
|
||||
},
|
||||
result: PluginExecutionWorkspaceMetadata | null,
|
||||
];
|
||||
"projects.managed.get": [
|
||||
params: { projectKey: string; companyId: string },
|
||||
result: PluginManagedProjectResolution,
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
ToolResult,
|
||||
ToolRunContext,
|
||||
PluginWorkspace,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
PluginLocalFolderEntry,
|
||||
@@ -80,6 +81,8 @@ export interface TestHarness {
|
||||
issueComments?: IssueComment[];
|
||||
agents?: Agent[];
|
||||
goals?: Goal[];
|
||||
projectWorkspaces?: PluginWorkspace[];
|
||||
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
|
||||
}): void;
|
||||
setConfig(config: Record<string, unknown>): void;
|
||||
/** Dispatch a host or plugin event to registered handlers. */
|
||||
@@ -438,6 +441,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
|
||||
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
|
||||
const localFolderFiles = new Map<string, string>();
|
||||
|
||||
@@ -975,6 +979,13 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
},
|
||||
},
|
||||
},
|
||||
executionWorkspaces: {
|
||||
async get(workspaceId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "execution.workspaces.read");
|
||||
const workspace = executionWorkspaces.get(workspaceId);
|
||||
return workspace?.companyId === companyId ? workspace : null;
|
||||
},
|
||||
},
|
||||
routines: {
|
||||
managed: {
|
||||
async get(routineKey, companyId) {
|
||||
@@ -2048,6 +2059,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
}
|
||||
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||
for (const row of input.projectWorkspaces ?? []) {
|
||||
const list = projectWorkspaces.get(row.projectId) ?? [];
|
||||
list.push(row);
|
||||
projectWorkspaces.set(row.projectId, list);
|
||||
}
|
||||
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
|
||||
},
|
||||
setConfig(config) {
|
||||
currentConfig = { ...config };
|
||||
|
||||
@@ -344,6 +344,12 @@ export interface PluginWorkspace {
|
||||
name: string;
|
||||
/** Absolute filesystem path to the workspace directory. */
|
||||
path: string;
|
||||
/** Repository URL, when known. */
|
||||
repoUrl: string | null;
|
||||
/** Checkout/ref requested for the workspace, when known. */
|
||||
repoRef: string | null;
|
||||
/** Default comparison ref for workspace tooling, when known. */
|
||||
defaultRef: string | null;
|
||||
/** Whether this is the project's primary workspace. */
|
||||
isPrimary: boolean;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
@@ -352,6 +358,40 @@ export interface PluginWorkspace {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution workspace metadata (read-only via ctx.executionWorkspaces)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Plugin-safe execution workspace metadata provided by the host. This exposes
|
||||
* the local/repository coordinates plugins need for workspace tooling without
|
||||
* giving the SDK a host-owned diff engine.
|
||||
*/
|
||||
export interface PluginExecutionWorkspaceMetadata {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** UUID of the owning company. */
|
||||
companyId: string;
|
||||
/** UUID of the parent project. */
|
||||
projectId: string;
|
||||
/** UUID of the backing project workspace, when present. */
|
||||
projectWorkspaceId: string | null;
|
||||
/** Absolute filesystem path to the workspace when locally realized. */
|
||||
path: string | null;
|
||||
/** Current working directory for local workspace tooling. */
|
||||
cwd: string | null;
|
||||
/** Repository URL, when known. */
|
||||
repoUrl: string | null;
|
||||
/** Base ref configured for the workspace, when known. */
|
||||
baseRef: string | null;
|
||||
/** Branch name configured for the workspace, when known. */
|
||||
branchName: string | null;
|
||||
/** Host provider type for the realized workspace. */
|
||||
providerType: string | null;
|
||||
/** Provider metadata already safe for plugin consumption. */
|
||||
providerMetadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host API surfaces exposed via PluginContext
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -818,6 +858,19 @@ export interface PluginProjectsClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.executionWorkspaces` — read execution workspace metadata.
|
||||
*
|
||||
* Requires `execution.workspaces.read`.
|
||||
*/
|
||||
export interface PluginExecutionWorkspacesClient {
|
||||
/**
|
||||
* Return plugin-safe metadata for an execution workspace. The host enforces
|
||||
* company access before returning any workspace coordinates.
|
||||
*/
|
||||
get(workspaceId: string, companyId: string): Promise<PluginExecutionWorkspaceMetadata | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines.
|
||||
*
|
||||
@@ -1642,6 +1695,9 @@ export interface PluginContext {
|
||||
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
|
||||
projects: PluginProjectsClient;
|
||||
|
||||
/** Read execution workspace metadata. Requires `execution.workspaces.read`. */
|
||||
executionWorkspaces: PluginExecutionWorkspacesClient;
|
||||
|
||||
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
|
||||
routines: PluginRoutinesClient;
|
||||
|
||||
|
||||
@@ -657,6 +657,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
},
|
||||
},
|
||||
|
||||
executionWorkspaces: {
|
||||
async get(workspaceId: string, companyId: string) {
|
||||
return callHost("executionWorkspaces.get", { workspaceId, companyId });
|
||||
},
|
||||
},
|
||||
|
||||
routines: {
|
||||
managed: {
|
||||
async get(routineKey: string, companyId: string) {
|
||||
|
||||
@@ -726,6 +726,7 @@ export const PLUGIN_CAPABILITIES = [
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"execution.workspaces.read",
|
||||
"issues.read",
|
||||
"issue.relations.read",
|
||||
"issue.subtree.read",
|
||||
@@ -961,6 +962,8 @@ export const PLUGIN_UI_SLOT_ENTITY_TYPES = [
|
||||
"goal",
|
||||
"run",
|
||||
"comment",
|
||||
"execution_workspace",
|
||||
"project_workspace",
|
||||
] as const;
|
||||
export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number];
|
||||
|
||||
|
||||
@@ -104,4 +104,28 @@ describe("plugin UI slot validators", () => {
|
||||
if (parsed.success) return;
|
||||
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts workspace entity types as detailTab targets", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.parse({
|
||||
type: "detailTab",
|
||||
id: "workspace-diff-viewer",
|
||||
displayName: "Diff",
|
||||
exportName: "WorkspaceDiffViewer",
|
||||
entityTypes: ["execution_workspace", "project_workspace"],
|
||||
});
|
||||
|
||||
expect(parsed.entityTypes).toEqual(["execution_workspace", "project_workspace"]);
|
||||
});
|
||||
|
||||
it("accepts execution_workspace as a toolbarButton entityType", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.parse({
|
||||
type: "toolbarButton",
|
||||
id: "workspace-open-diff",
|
||||
displayName: "Open diff",
|
||||
exportName: "OpenWorkspaceDiffButton",
|
||||
entityTypes: ["execution_workspace"],
|
||||
});
|
||||
|
||||
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user