mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
Add workspace changes plugin
This commit is contained in:
1 parent
133366e216
commit
55bffc123a
41 files changed
+2881
-68
No files matched your search
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-workspace-diff",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party execution workspace Changes tab powered by Paperclip's host diff service",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"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",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"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,103 @@
|
||||
import type {
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
|
||||
export type DiffRenderMode = "unified" | "split";
|
||||
|
||||
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"][];
|
||||
patch: string | null;
|
||||
}
|
||||
|
||||
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 function statusLabel(status: WorkspaceDiffFile["status"]) {
|
||||
return STATUS_LABELS[status] ?? "Changed";
|
||||
}
|
||||
|
||||
export function fileName(filePath: string) {
|
||||
return filePath.split("/").filter(Boolean).pop() ?? filePath;
|
||||
}
|
||||
|
||||
export function buildFilePatch(file: WorkspaceDiffFile): string | null {
|
||||
const patches = file.patches
|
||||
.map((patch) => patch.patch?.trimEnd() ?? "")
|
||||
.filter(Boolean);
|
||||
if (patches.length === 0) return null;
|
||||
return patches.join("\n");
|
||||
}
|
||||
|
||||
export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] {
|
||||
return (diff?.files ?? []).map((file) => {
|
||||
const patchWarnings = file.patches.flatMap((patch) => patch.warnings);
|
||||
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, ...patchWarnings],
|
||||
patchKinds: file.patches.map((patch) => patch.kind),
|
||||
patch: buildFilePatch(file),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
+ (diff?.files ?? []).reduce((count, file) => {
|
||||
return count + file.warnings.length + file.patches.reduce((patchCount, patch) => patchCount + patch.warnings.length, 0);
|
||||
}, 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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,36 @@
|
||||
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 workspaces using Paperclip's host diff API and @pierre/diffs.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
"ui.detailTab.register",
|
||||
"execution.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"],
|
||||
order: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,321 @@
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
|
||||
import type { WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
|
||||
import { PatchDiff } from "@pierre/diffs/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
diffSummary,
|
||||
fileName,
|
||||
nextExpandedFileSet,
|
||||
statusLabel,
|
||||
toFileViewModels,
|
||||
type DiffFileViewModel,
|
||||
type DiffRenderMode,
|
||||
} from "../diff-model.js";
|
||||
|
||||
type WorkspaceDiffData = WorkspaceDiffResponse;
|
||||
|
||||
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.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);
|
||||
|
||||
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 className="flex min-w-0 items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
title={expanded ? "Collapse file" : "Expand file"}
|
||||
aria-label={expanded ? `Collapse ${file.path}` : `Expand ${file.path}`}
|
||||
>
|
||||
{expanded ? "−" : "+"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="truncate text-sm font-medium text-foreground">{fileName(file.path)}</div>
|
||||
<div className="truncate font-mono text-[11px] text-muted-foreground">{file.path}</div>
|
||||
</button>
|
||||
<button
|
||||
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 className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-5 text-[11px] text-muted-foreground">
|
||||
<span>{statusLabel(file.status)}</span>
|
||||
<span className="font-mono text-emerald-700 dark:text-emerald-300">+{file.additions}</span>
|
||||
<span className="font-mono text-red-700 dark:text-red-300">-{file.deletions}</span>
|
||||
{warning ? <span className="text-amber-700 dark:text-amber-300">{warning}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffPanel({
|
||||
file,
|
||||
mode,
|
||||
}: {
|
||||
file: DiffFileViewModel;
|
||||
mode: DiffRenderMode;
|
||||
}) {
|
||||
const warning = warningText(file);
|
||||
if (warning || !file.patch) {
|
||||
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="overflow-hidden border border-border bg-background">
|
||||
<PatchDiff
|
||||
patch={file.patch}
|
||||
options={{
|
||||
diffStyle: mode,
|
||||
overflow: "scroll",
|
||||
disableLineNumbers: false,
|
||||
themeType: "system",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
const toast = usePluginToast();
|
||||
const [mode, setMode] = useState<DiffRenderMode>("split");
|
||||
const [includeUntracked, setIncludeUntracked] = useState(true);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
|
||||
const params = useMemo(() => ({
|
||||
workspaceId: context.entityId,
|
||||
companyId: context.companyId ?? "",
|
||||
view: "working-tree",
|
||||
includeUntracked,
|
||||
}), [context.companyId, context.entityId, includeUntracked]);
|
||||
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
if (files.length === 0) {
|
||||
setExpandedFiles(new Set());
|
||||
setSelectedPath(null);
|
||||
return;
|
||||
}
|
||||
setExpandedFiles((current) => current.size > 0 ? current : new Set(files.map((file) => file.path)));
|
||||
setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
|
||||
}, [files]);
|
||||
|
||||
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 className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">{summary.changedLabel}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{summary.lineLabel}</span>
|
||||
{summary.truncated ? (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">Truncated</span>
|
||||
) : null}
|
||||
{summary.warningCount > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">{summary.warningCount} warnings</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-muted-foreground">
|
||||
{data?.baseRef ? `base ${data.baseRef}` : "working tree"}{data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex gap-1" aria-label="Diff layout">
|
||||
<button type="button" className={buttonClass(mode === "split")} onClick={() => setMode("split")}>
|
||||
Split
|
||||
</button>
|
||||
<button type="button" className={buttonClass(mode === "unified")} onClick={() => setMode("unified")}>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass(includeUntracked)}
|
||||
onClick={() => setIncludeUntracked((value) => !value)}
|
||||
>
|
||||
Untracked
|
||||
</button>
|
||||
<button type="button" className={buttonClass(false)} onClick={() => refresh()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<ErrorState message={error.message} />
|
||||
) : files.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid min-h-[560px] gap-3 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="min-w-0 border border-border bg-background">
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
<div className="max-h-[70vh] overflow-auto">
|
||||
{files.map((file) => (
|
||||
<FileRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
active={file.path === selectedFile?.path}
|
||||
expanded={expandedFiles.has(file.path)}
|
||||
onSelect={() => setSelectedPath(file.path)}
|
||||
onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
onCopy={() => void copyPath(file.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 space-y-3">
|
||||
{files.map((file) => (
|
||||
expandedFiles.has(file.path) ? (
|
||||
<section
|
||||
key={file.path}
|
||||
className={file.path === selectedFile?.path ? "scroll-mt-20" : undefined}
|
||||
>
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-muted/35 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => setSelectedPath(file.path)}
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{file.path}</div>
|
||||
{file.oldPath ? (
|
||||
<div className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
from {file.oldPath}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClass(false)}
|
||||
title="Copy path"
|
||||
aria-label={`Copy ${file.path}`}
|
||||
onClick={() => void copyPath(file.path)}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClass(false)}
|
||||
title="Collapse file"
|
||||
aria-label={`Collapse ${file.path}`}
|
||||
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FileDiffPanel file={file} mode={mode} />
|
||||
</section>
|
||||
) : null
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffQueryOptions } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_NAME = "workspace-diff";
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readPaths(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
const trimmed = entry.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function readDiffOptions(params: Record<string, unknown>): Partial<WorkspaceDiffQueryOptions> {
|
||||
const view = params.view === "head" ? "head" : "working-tree";
|
||||
const baseRef = readString(params.baseRef) || null;
|
||||
const includeUntracked = typeof params.includeUntracked === "boolean"
|
||||
? params.includeUntracked
|
||||
: true;
|
||||
return {
|
||||
view,
|
||||
baseRef,
|
||||
includeUntracked,
|
||||
paths: readPaths(params.paths),
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return ctx.executionWorkspaces.getDiff(
|
||||
workspaceId,
|
||||
companyId,
|
||||
readDiffOptions(params),
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: `${PLUGIN_NAME} ready` };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFilePatch,
|
||||
diffSummary,
|
||||
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"],
|
||||
});
|
||||
});
|
||||
|
||||
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 file = changedFile({
|
||||
truncated: true,
|
||||
warnings: [{ code: "patch_truncated", message: "Patch was truncated.", path: "src/app.ts" }],
|
||||
patches: [],
|
||||
});
|
||||
const diff = diffResponse({ files: [file], truncated: true });
|
||||
|
||||
expect(buildFilePatch(file)).toBeNull();
|
||||
expect(diffSummary(diff)).toMatchObject({
|
||||
warningCount: 1,
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
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,79 @@
|
||||
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
|
||||
|
||||
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",
|
||||
repoRoot: "/tmp/paperclip",
|
||||
cwd: "/tmp/paperclip",
|
||||
view: "working-tree",
|
||||
baseRef: 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,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
import { diffResponse } from "./fixtures.js";
|
||||
|
||||
describe("workspace diff plugin", () => {
|
||||
it("declares the execution workspace Changes tab and host diff capability", () => {
|
||||
expect(manifest.capabilities).toContain("ui.detailTab.register");
|
||||
expect(manifest.capabilities).toContain("execution.workspaces.read");
|
||||
expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({
|
||||
type: "detailTab",
|
||||
displayName: "Changes",
|
||||
entityTypes: ["execution_workspace"],
|
||||
}));
|
||||
});
|
||||
|
||||
it("fetches changed workspace diffs through the host bridge client", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
const getDiff = vi.fn().mockResolvedValue(diffResponse());
|
||||
harness.ctx.executionWorkspaces.getDiff = getDiff;
|
||||
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(getDiff).toHaveBeenCalledWith("workspace-1", "company-1", {
|
||||
view: "working-tree",
|
||||
baseRef: null,
|
||||
includeUntracked: false,
|
||||
paths: ["src/app.ts"],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
stats: { fileCount: 1 },
|
||||
files: [expect.objectContaining({ path: "src/app.ts" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty and truncated host responses without fabricating filesystem data", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
const empty = diffResponse({ files: [] });
|
||||
const truncated = diffResponse({
|
||||
files: [],
|
||||
truncated: true,
|
||||
warnings: [{ code: "file_count_truncated", message: "Too many files.", path: null }],
|
||||
});
|
||||
const getDiff = vi.fn()
|
||||
.mockResolvedValueOnce(empty)
|
||||
.mockResolvedValueOnce(truncated);
|
||||
harness.ctx.executionWorkspaces.getDiff = getDiff;
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
})).resolves.toMatchObject({ files: [], truncated: false });
|
||||
await expect(harness.getData("workspace-diff", {
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
})).resolves.toMatchObject({ files: [], truncated: true });
|
||||
});
|
||||
|
||||
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,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": "."
|
||||
},
|
||||
"include": ["src", "tests"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user