mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
Address diff viewer review feedback
This commit is contained in:
1 parent
53f3425cd4
commit
b70abf7112
11 files changed
+345
-48
No files matched your search
@@ -26,7 +26,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
id: CHANGES_TAB_SLOT_ID,
|
||||
displayName: "Changes",
|
||||
exportName: "ChangesTab",
|
||||
entityTypes: ["execution_workspace"],
|
||||
entityTypes: ["execution_workspace", "project_workspace"],
|
||||
order: 5,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
|
||||
type WorkspaceDiffData = WorkspaceDiffResponse;
|
||||
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["options"];
|
||||
type DiffViewMode = "working-tree" | "head";
|
||||
|
||||
function buttonClass(active = false) {
|
||||
return [
|
||||
@@ -272,17 +273,22 @@ function CollapsedFilePanel({
|
||||
export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
const toast = usePluginToast();
|
||||
const [mode, setMode] = useState<DiffRenderMode>("split");
|
||||
const [view, setView] = useState<DiffViewMode>("working-tree");
|
||||
const [baseRef, setBaseRef] = useState("");
|
||||
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 ?? "",
|
||||
view: "working-tree",
|
||||
view,
|
||||
baseRef: baseRef.trim() || null,
|
||||
includeUntracked,
|
||||
}), [context.companyId, context.entityId, includeUntracked]);
|
||||
}), [baseRef, context.companyId, context.entityId, includeUntracked, view]);
|
||||
|
||||
const { data, loading, error, refresh } = usePluginData<WorkspaceDiffData>("workspace-diff", params);
|
||||
const files = useMemo(() => toFileViewModels(data), [data]);
|
||||
@@ -305,6 +311,36 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
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(() => {
|
||||
if (files.length === 0) {
|
||||
setExpandedFiles(new Set());
|
||||
@@ -315,6 +351,14 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
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);
|
||||
@@ -352,14 +396,34 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
key="untracked"
|
||||
type="button"
|
||||
className={buttonClass(includeUntracked)}
|
||||
onClick={() => setIncludeUntracked((value) => !value)}
|
||||
>
|
||||
{includeUntracked ? "Untracked shown" : "Show untracked"}
|
||||
</button>
|
||||
<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) => 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={buttonClass(false)} onClick={() => refresh()}>
|
||||
Refresh
|
||||
</button>
|
||||
@@ -393,7 +457,12 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main key="diffs" className="max-h-[70vh] min-w-0 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1">
|
||||
<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) => (
|
||||
<section
|
||||
@@ -402,19 +471,31 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
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">
|
||||
<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 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"
|
||||
@@ -426,16 +507,6 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
key="collapse"
|
||||
type="button"
|
||||
className={iconButtonClass(false)}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
{expandedFiles.has(file.path) ? (
|
||||
|
||||
@@ -5,13 +5,13 @@ 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", () => {
|
||||
it("declares workspace Changes tabs 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"],
|
||||
entityTypes: ["execution_workspace", "project_workspace"],
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -963,6 +963,7 @@ export const PLUGIN_UI_SLOT_ENTITY_TYPES = [
|
||||
"run",
|
||||
"comment",
|
||||
"execution_workspace",
|
||||
"project_workspace",
|
||||
] as const;
|
||||
export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number];
|
||||
|
||||
|
||||
@@ -105,16 +105,16 @@ describe("plugin UI slot validators", () => {
|
||||
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts execution_workspace as a detailTab entityType", () => {
|
||||
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"],
|
||||
entityTypes: ["execution_workspace", "project_workspace"],
|
||||
});
|
||||
|
||||
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
|
||||
expect(parsed.entityTypes).toEqual(["execution_workspace", "project_workspace"]);
|
||||
});
|
||||
|
||||
it("accepts execution_workspace as a toolbarButton entityType", () => {
|
||||
|
||||
@@ -21,6 +21,9 @@ const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockWorkspaceDiffService = vi.hoisted(() => ({
|
||||
getDiff: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -44,6 +47,10 @@ vi.mock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-diff.js", () => ({
|
||||
workspaceDiffService: () => mockWorkspaceDiffService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
@@ -70,6 +77,10 @@ function registerModuleMocks() {
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/workspace-diff.js", () => ({
|
||||
workspaceDiffService: () => mockWorkspaceDiffService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
@@ -150,6 +161,37 @@ describe("project env routes", () => {
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.createWorkspace.mockResolvedValue(null);
|
||||
mockProjectService.listWorkspaces.mockResolvedValue([]);
|
||||
mockWorkspaceDiffService.getDiff.mockResolvedValue({
|
||||
workspaceId: "workspace-1",
|
||||
companyId: "company-1",
|
||||
repoRoot: "/tmp/project",
|
||||
cwd: "/tmp/project",
|
||||
view: "head",
|
||||
baseRef: "origin/master",
|
||||
headSha: "abc123",
|
||||
includeUntracked: false,
|
||||
paths: [],
|
||||
files: [],
|
||||
stats: {
|
||||
fileCount: 0,
|
||||
stagedFileCount: 0,
|
||||
unstagedFileCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
binaryFileCount: 0,
|
||||
oversizedFileCount: 0,
|
||||
truncatedFileCount: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
warnings: [],
|
||||
caps: {
|
||||
maxFiles: 200,
|
||||
maxFileBytes: 524288,
|
||||
maxPatchBytes: 262144,
|
||||
maxTotalPatchBytes: 1048576,
|
||||
},
|
||||
truncated: false,
|
||||
});
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||
});
|
||||
@@ -219,4 +261,49 @@ describe("project env routes", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns project workspace diffs with explicit upstream refs", async () => {
|
||||
mockProjectService.getById.mockResolvedValue(buildProject());
|
||||
mockProjectService.listWorkspaces.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
name: "Primary",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/project",
|
||||
repoUrl: null,
|
||||
repoRef: "master",
|
||||
defaultRef: "origin/master",
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.get("/api/projects/project-1/workspaces/workspace-1/diff?view=head&baseRef=origin/master&includeUntracked=false");
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockWorkspaceDiffService.getDiff).toHaveBeenCalledWith({
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
cwd: "/tmp/project",
|
||||
baseRef: "origin/master",
|
||||
}, {
|
||||
view: "head",
|
||||
baseRef: "origin/master",
|
||||
includeUntracked: false,
|
||||
paths: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
updateProjectSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
workspaceDiffQuerySchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { workspaceDiffService } from "../services/workspace-diff.js";
|
||||
import { conflict, forbidden } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ export function projectRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = projectService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
const diffSvc = workspaceDiffService();
|
||||
const workspaceOperations = workspaceOperationService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
const environmentsSvc = environmentService(db);
|
||||
@@ -339,6 +342,29 @@ export function projectRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/projects/:id/workspaces/:workspaceId/diff", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const project = await svc.getById(id);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, project.companyId);
|
||||
const workspace = (await svc.listWorkspaces(id)).find((entry) => entry.id === workspaceId);
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Project workspace not found" });
|
||||
return;
|
||||
}
|
||||
const query = workspaceDiffQuerySchema.parse(req.query);
|
||||
res.json(await diffSvc.getDiff({
|
||||
id: workspace.id,
|
||||
companyId: workspace.companyId,
|
||||
cwd: workspace.cwd,
|
||||
baseRef: workspace.defaultRef ?? workspace.repoRef ?? null,
|
||||
}, query));
|
||||
});
|
||||
|
||||
async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) {
|
||||
const id = req.params.id as string;
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
heartbeatRuns,
|
||||
issues as issuesTable,
|
||||
pluginLogs,
|
||||
projectWorkspaces as projectWorkspacesTable,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, and, like, desc, inArray, sql } from "drizzle-orm";
|
||||
import type {
|
||||
@@ -1206,10 +1207,32 @@ export function buildHostServices(
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const workspace = await executionWorkspaces.getById(params.workspaceId);
|
||||
if (!inCompany(workspace, companyId)) {
|
||||
throw new Error("Execution workspace not found");
|
||||
if (inCompany(workspace, companyId)) {
|
||||
return workspaceDiff.getDiff(workspace, {
|
||||
view: params.options?.view ?? "working-tree",
|
||||
baseRef: params.options?.baseRef ?? null,
|
||||
includeUntracked: params.options?.includeUntracked ?? true,
|
||||
paths: params.options?.paths ?? [],
|
||||
});
|
||||
}
|
||||
return workspaceDiff.getDiff(workspace, {
|
||||
|
||||
const projectWorkspace = await db
|
||||
.select()
|
||||
.from(projectWorkspacesTable)
|
||||
.where(and(
|
||||
eq(projectWorkspacesTable.id, params.workspaceId),
|
||||
eq(projectWorkspacesTable.companyId, companyId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!projectWorkspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
return workspaceDiff.getDiff({
|
||||
id: projectWorkspace.id,
|
||||
companyId: projectWorkspace.companyId,
|
||||
cwd: projectWorkspace.cwd,
|
||||
baseRef: projectWorkspace.defaultRef ?? projectWorkspace.repoRef ?? null,
|
||||
}, {
|
||||
view: params.options?.view ?? "working-tree",
|
||||
baseRef: params.options?.baseRef ?? null,
|
||||
includeUntracked: params.options?.includeUntracked ?? true,
|
||||
|
||||
@@ -44,6 +44,8 @@ interface PatchBudget {
|
||||
totalPatchBytes: number;
|
||||
}
|
||||
|
||||
type WorkspaceDiffTarget = Pick<ExecutionWorkspace, "id" | "companyId" | "cwd" | "baseRef">;
|
||||
|
||||
function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
|
||||
return { code, message, path: filePath };
|
||||
}
|
||||
@@ -94,7 +96,7 @@ function isWithinDirectory(childPath: string, parentPath: string) {
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function resolveWorkspacePaths(workspace: ExecutionWorkspace) {
|
||||
async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) {
|
||||
if (!workspace.cwd?.trim()) {
|
||||
throw workspaceDiffError(
|
||||
"missing_cwd",
|
||||
@@ -548,7 +550,7 @@ async function resolveHeadSha(cwd: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: ExecutionWorkspace) {
|
||||
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
|
||||
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
|
||||
if (!resolvedBaseRef) {
|
||||
throw workspaceDiffError(
|
||||
@@ -575,7 +577,7 @@ async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: Ex
|
||||
|
||||
async function collectFiles(input: {
|
||||
cwd: string;
|
||||
workspace: ExecutionWorkspace;
|
||||
workspace: WorkspaceDiffTarget;
|
||||
query: WorkspaceDiffQueryOptions;
|
||||
paths: string[];
|
||||
}) {
|
||||
@@ -606,7 +608,7 @@ async function collectFiles(input: {
|
||||
|
||||
export function workspaceDiffService() {
|
||||
return {
|
||||
async getDiff(workspace: ExecutionWorkspace, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
|
||||
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
|
||||
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
|
||||
const paths = normalizePathFilters(query.paths);
|
||||
const warnings: WorkspaceDiffWarning[] = [];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
Project,
|
||||
ProjectWorkspace,
|
||||
WorkspaceDiffQueryOptions,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceOperation,
|
||||
WorkspaceRuntimeControlTarget,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -33,6 +35,24 @@ export const projectsApi = {
|
||||
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`),
|
||||
data,
|
||||
),
|
||||
getWorkspaceDiff: (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
options: Partial<WorkspaceDiffQueryOptions> = {},
|
||||
companyId?: string,
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.view) params.set("view", options.view);
|
||||
if (options.baseRef) params.set("baseRef", options.baseRef);
|
||||
if (typeof options.includeUntracked === "boolean") {
|
||||
params.set("includeUntracked", String(options.includeUntracked));
|
||||
}
|
||||
for (const filePath of options.paths ?? []) {
|
||||
params.append("path", filePath);
|
||||
}
|
||||
const suffix = `/workspaces/${encodeURIComponent(workspaceId)}/diff${params.toString() ? `?${params}` : ""}`;
|
||||
return api.get<WorkspaceDiffResponse>(projectPath(projectId, companyId, suffix));
|
||||
},
|
||||
controlWorkspaceRuntimeServices: (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { ChoosePathButton } from "../components/PathInstructionsModal";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
import {
|
||||
buildWorkspaceRuntimeControlSections,
|
||||
WorkspaceRuntimeControls,
|
||||
@@ -35,6 +38,13 @@ type WorkspaceFormState = {
|
||||
|
||||
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
|
||||
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
|
||||
type ProjectWorkspaceBaseTab = "configuration";
|
||||
type ProjectWorkspacePluginTab = `plugin:${string}`;
|
||||
type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab;
|
||||
|
||||
function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab {
|
||||
return typeof value === "string" && value.startsWith("plugin:");
|
||||
}
|
||||
|
||||
const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [
|
||||
{ value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." },
|
||||
@@ -217,6 +227,7 @@ export function ProjectWorkspaceDetail() {
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<WorkspaceFormState | null>(null);
|
||||
@@ -224,6 +235,11 @@ export function ProjectWorkspaceDetail() {
|
||||
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
|
||||
const routeProjectRef = projectId ?? "";
|
||||
const routeWorkspaceId = workspaceId ?? "";
|
||||
const pluginTabFromSearch = useMemo(() => {
|
||||
const tab = new URLSearchParams(location.search).get("tab");
|
||||
return isProjectWorkspacePluginTab(tab) ? tab : null;
|
||||
}, [location.search]);
|
||||
const activeTab: ProjectWorkspaceTab = pluginTabFromSearch ?? "configuration";
|
||||
|
||||
const routeCompanyId = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -247,6 +263,21 @@ export function ProjectWorkspaceDetail() {
|
||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
|
||||
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
|
||||
const { slots: pluginDetailSlots } = usePluginSlots({
|
||||
slotTypes: ["detailTab"],
|
||||
entityType: "project_workspace",
|
||||
companyId: project?.companyId ?? null,
|
||||
enabled: Boolean(project?.companyId),
|
||||
});
|
||||
const pluginTabItems = useMemo(
|
||||
() => pluginDetailSlots.map((slot) => ({
|
||||
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab,
|
||||
label: slot.displayName,
|
||||
slot,
|
||||
})),
|
||||
[pluginDetailSlots],
|
||||
);
|
||||
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
@@ -363,6 +394,14 @@ export function ProjectWorkspaceDetail() {
|
||||
};
|
||||
|
||||
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
|
||||
const handleTabChange = (tab: ProjectWorkspaceTab) => {
|
||||
const workspacePath = projectWorkspaceUrl(project, routeWorkspaceId);
|
||||
if (isProjectWorkspacePluginTab(tab)) {
|
||||
navigate(`${workspacePath}?tab=${encodeURIComponent(tab)}`);
|
||||
return;
|
||||
}
|
||||
navigate(workspacePath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
@@ -378,6 +417,19 @@ export function ProjectWorkspaceDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
...pluginTabItems.map((item) => ({ value: item.value, label: item.label })),
|
||||
]}
|
||||
align="start"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{activeTab === "configuration" ? (
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
@@ -643,6 +695,21 @@ export function ProjectWorkspaceDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activePluginTab ? (
|
||||
<PluginSlotMount
|
||||
slot={activePluginTab.slot}
|
||||
context={{
|
||||
companyId: project.companyId,
|
||||
companyPrefix: companyPrefix ?? null,
|
||||
projectId: project.id,
|
||||
entityId: workspace.id,
|
||||
entityType: "project_workspace",
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user