From 986df210a3cd3ad74e0e19fbbd066b1c1410ae8f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 16 May 2026 09:28:26 -0500 Subject: [PATCH] Fix workspace changes toolbar defaults Co-Authored-By: Paperclip --- .../plugin-workspace-diff/src/contracts.ts | 1 + .../plugin-workspace-diff/src/ui/index.tsx | 56 ++++++++++++- .../plugin-workspace-diff/src/worker.ts | 40 ++++++++- .../src/workspace-diff.ts | 1 + .../plugin-workspace-diff/tests/fixtures.ts | 1 + .../tests/plugin.spec.ts | 81 ++++++++++++++++++- 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-workspace-diff/src/contracts.ts b/packages/plugins/plugin-workspace-diff/src/contracts.ts index 9e90fe0f783..14cf2e1f2fb 100644 --- a/packages/plugins/plugin-workspace-diff/src/contracts.ts +++ b/packages/plugins/plugin-workspace-diff/src/contracts.ts @@ -119,6 +119,7 @@ export const workspaceDiffResponseSchema = z.object({ 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()), diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index 78306d6f5c9..eaf70f48927 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -3,7 +3,7 @@ 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, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { diffSummary, fileName, @@ -21,6 +21,37 @@ type WorkspaceDiffData = WorkspaceDiffResponse; type WorkspacePatchDiffOptions = PatchDiffProps["options"]; type DiffViewMode = "working-tree" | "head"; +type LucideIconProps = { size?: number }; + +function makeLucideIcon(paths: ReactNode) { + return function LucideIcon({ size = 16 }: LucideIconProps) { + return ( + + ); + }; +} + +// Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw. +const RefreshCwIcon = makeLucideIcon( + <> + + + + + , +); + function readInitialView(): DiffViewMode { if (typeof window === "undefined") return "working-tree"; return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree"; @@ -314,6 +345,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) { const [mode, setMode] = useState("split"); const [view, setView] = useState(() => readInitialView()); const [baseRef, setBaseRef] = useState(() => readInitialBaseRef()); + const baseRefTouchedRef = useRef(Boolean(baseRef.trim())); const [includeUntracked, setIncludeUntracked] = useState(false); const [expandedFiles, setExpandedFiles] = useState>(() => new Set()); const [selectedPath, setSelectedPath] = useState(null); @@ -382,6 +414,12 @@ export function ChangesTab({ context }: PluginDetailTabProps) { }); }, [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()); @@ -450,7 +488,10 @@ export function ChangesTab({ context }: PluginDetailTabProps) { 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)} + onChange={(event) => { + baseRefTouchedRef.current = true; + setBaseRef(event.target.value); + }} placeholder="origin/master" aria-label="Base ref" /> @@ -465,8 +506,15 @@ export function ChangesTab({ context }: PluginDetailTabProps) { {includeUntracked ? "Untracked shown" : "Show untracked"} ) : null} - diff --git a/packages/plugins/plugin-workspace-diff/src/worker.ts b/packages/plugins/plugin-workspace-diff/src/worker.ts index 520117f61cf..067af4f107d 100644 --- a/packages/plugins/plugin-workspace-diff/src/worker.ts +++ b/packages/plugins/plugin-workspace-diff/src/worker.ts @@ -8,6 +8,21 @@ 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`); @@ -34,7 +49,10 @@ const plugin = definePlugin({ id: workspace.id, companyId, cwd: workspace.path, - baseRef: workspace.defaultRef ?? workspace.repoRef ?? null, + baseRef: resolveDefaultBaseRef({ + projectWorkspaceDefaultRef: workspace.defaultRef, + projectWorkspaceRepoRef: workspace.repoRef, + }), }, workspaceDiffQuerySchema.parse(params)); } @@ -42,7 +60,25 @@ const plugin = definePlugin({ if (!workspace) { throw new Error("Workspace not found"); } - return workspaceDiff.getDiff(workspace, workspaceDiffQuerySchema.parse(params)); + 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)); }); }, diff --git a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts index d4d173256c7..20c86f8dccc 100644 --- a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts +++ b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts @@ -676,6 +676,7 @@ export function workspaceDiffService() { companyId: workspace.companyId, view: query.view, baseRef, + defaultBaseRef: workspace.baseRef, headSha: await resolveHeadSha(cwd), includeUntracked: query.includeUntracked, paths, diff --git a/packages/plugins/plugin-workspace-diff/tests/fixtures.ts b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts index 7ccafa0f843..9c8eb5356df 100644 --- a/packages/plugins/plugin-workspace-diff/tests/fixtures.ts +++ b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts @@ -49,6 +49,7 @@ export function diffResponse(overrides: Partial = {}): Wo companyId: "22222222-2222-4222-8222-222222222222", view: "working-tree", baseRef: null, + defaultBaseRef: null, headSha: null, includeUntracked: true, paths: [], diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts index 4610173fac1..9796c178500 100644 --- a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -6,7 +6,7 @@ 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 from "../src/worker.js"; +import plugin, { resolveDefaultBaseRef } from "../src/worker.js"; const execFileAsync = promisify(execFile); const tempRoots: string[] = []; @@ -143,6 +143,85 @@ describe("workspace diff plugin", () => { 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" })], });