diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index eaf70f48..40b96249 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -3,7 +3,17 @@ 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 { + createElement, + type KeyboardEvent, + type PointerEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { diffSummary, fileName, @@ -23,6 +33,12 @@ type DiffViewMode = "working-tree" | "head"; type LucideIconProps = { size?: number }; +const DEFAULT_FILE_SIDEBAR_WIDTH = 280; +const MIN_FILE_SIDEBAR_WIDTH = 220; +const MAX_FILE_SIDEBAR_WIDTH = 520; +const FILE_SIDEBAR_WIDTH_STEP = 16; +const FILE_SIDEBAR_WIDTH_STORAGE_KEY = "paperclip.workspace-diff.files-sidebar-width"; + function makeLucideIcon(paths: ReactNode) { return function LucideIcon({ size = 16 }: LucideIconProps) { return ( @@ -57,6 +73,11 @@ function readInitialView(): DiffViewMode { return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree"; } +function hasInitialViewParam() { + if (typeof window === "undefined") return false; + return new URLSearchParams(window.location.search).has("diffView"); +} + function readInitialBaseRef() { if (typeof window === "undefined") return ""; return new URLSearchParams(window.location.search).get("baseRef") ?? ""; @@ -80,6 +101,51 @@ function iconButtonClass(active = false) { ].join(" "); } +function clampFileSidebarWidth(width: number) { + return Math.min(MAX_FILE_SIDEBAR_WIDTH, Math.max(MIN_FILE_SIDEBAR_WIDTH, width)); +} + +function readStoredFileSidebarWidth() { + if (typeof window === "undefined") return DEFAULT_FILE_SIDEBAR_WIDTH; + + try { + const stored = window.localStorage.getItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY); + if (!stored) return DEFAULT_FILE_SIDEBAR_WIDTH; + const parsed = Number.parseInt(stored, 10); + return Number.isFinite(parsed) ? clampFileSidebarWidth(parsed) : DEFAULT_FILE_SIDEBAR_WIDTH; + } catch { + return DEFAULT_FILE_SIDEBAR_WIDTH; + } +} + +function writeStoredFileSidebarWidth(width: number) { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY, String(clampFileSidebarWidth(width))); + } catch { + // Storage can be unavailable; keep resize interactive even when persistence fails. + } +} + +function useIsDesktopDiffLayout() { + const [isDesktop, setIsDesktop] = useState(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + return window.matchMedia("(min-width: 1024px)").matches; + }); + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + + const query = window.matchMedia("(min-width: 1024px)"); + const update = () => setIsDesktop(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + return isDesktop; +} + function warningText(file: DiffFileViewModel) { if (file.binary) return "Binary file"; if (file.oversized) return "Too large to render"; @@ -260,9 +326,11 @@ export function ErrorState({ function FileDiffPanel({ file, mode, + lineWrap, }: { file: DiffFileViewModel; mode: DiffRenderMode; + lineWrap: boolean; }) { const warning = warningText(file); if (warning) { @@ -295,7 +363,7 @@ function FileDiffPanel({ patch={patch.patch} options={{ diffStyle: mode, - overflow: "scroll", + overflow: lineWrap ? "wrap" : "scroll", disableLineNumbers: false, themeType: "system", }} @@ -343,25 +411,38 @@ function CollapsedFilePanel({ export function ChangesTab({ context }: PluginDetailTabProps) { const toast = usePluginToast(); const [mode, setMode] = useState("split"); + const [lineWrap, setLineWrap] = useState(false); const [view, setView] = useState(() => readInitialView()); const [baseRef, setBaseRef] = useState(() => readInitialBaseRef()); const baseRefTouchedRef = useRef(Boolean(baseRef.trim())); + const viewTouchedRef = useRef(hasInitialViewParam()); const [includeUntracked, setIncludeUntracked] = useState(false); const [expandedFiles, setExpandedFiles] = useState>(() => new Set()); const [selectedPath, setSelectedPath] = useState(null); + const [fileSidebarWidth, setFileSidebarWidth] = useState(() => readStoredFileSidebarWidth()); + const [fileSidebarResizing, setFileSidebarResizing] = useState(false); + const fileSidebarWidthRef = useRef(fileSidebarWidth); + const fileSidebarDragRef = useRef<{ startX: number; startWidth: number } | null>(null); const fileSectionRefs = useRef(new Map()); const diffScrollRef = useRef(null); const scrollSyncFrameRef = useRef(null); + const usesDesktopDiffLayout = useIsDesktopDiffLayout(); + const requestedBaseRef = baseRef.trim(); + const effectiveView = view === "head" && !requestedBaseRef ? "working-tree" : view; + const fileSidebarStyle = useMemo( + () => usesDesktopDiffLayout ? { width: `${fileSidebarWidth}px` } : undefined, + [fileSidebarWidth, usesDesktopDiffLayout], + ); const params = useMemo(() => ({ workspaceId: context.entityId, companyId: context.companyId ?? "", projectId: context.projectId ?? "", entityType: context.entityType, - view, - baseRef: baseRef.trim() || null, + view: effectiveView, + baseRef: requestedBaseRef || null, includeUntracked, - }), [baseRef, context.companyId, context.entityId, context.entityType, context.projectId, includeUntracked, view]); + }), [context.companyId, context.entityId, context.entityType, context.projectId, effectiveView, includeUntracked, requestedBaseRef]); const { data, loading, error, refresh } = usePluginData("workspace-diff", params); const files = useMemo(() => toFileViewModels(data), [data]); @@ -414,11 +495,70 @@ export function ChangesTab({ context }: PluginDetailTabProps) { }); }, [syncSelectedPathFromScroll]); + const commitFileSidebarWidth = useCallback((nextWidth: number) => { + const clamped = clampFileSidebarWidth(nextWidth); + fileSidebarWidthRef.current = clamped; + setFileSidebarWidth(clamped); + writeStoredFileSidebarWidth(clamped); + }, []); + + const handleFileSidebarPointerDown = useCallback((event: PointerEvent) => { + if (!usesDesktopDiffLayout) return; + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + fileSidebarDragRef.current = { + startX: event.clientX, + startWidth: fileSidebarWidthRef.current, + }; + setFileSidebarResizing(true); + }, [usesDesktopDiffLayout]); + + const handleFileSidebarPointerMove = useCallback((event: PointerEvent) => { + const drag = fileSidebarDragRef.current; + if (!drag) return; + + const nextWidth = clampFileSidebarWidth(drag.startWidth + event.clientX - drag.startX); + fileSidebarWidthRef.current = nextWidth; + setFileSidebarWidth(nextWidth); + }, []); + + const endFileSidebarResize = useCallback(() => { + if (!fileSidebarDragRef.current) return; + + fileSidebarDragRef.current = null; + setFileSidebarResizing(false); + writeStoredFileSidebarWidth(fileSidebarWidthRef.current); + }, []); + + const handleFileSidebarKeyDown = useCallback((event: KeyboardEvent) => { + if (!usesDesktopDiffLayout) return; + + if (event.key === "ArrowLeft") { + event.preventDefault(); + commitFileSidebarWidth(fileSidebarWidth - FILE_SIDEBAR_WIDTH_STEP); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + commitFileSidebarWidth(fileSidebarWidth + FILE_SIDEBAR_WIDTH_STEP); + } else if (event.key === "Home") { + event.preventDefault(); + commitFileSidebarWidth(MIN_FILE_SIDEBAR_WIDTH); + } else if (event.key === "End") { + event.preventDefault(); + commitFileSidebarWidth(MAX_FILE_SIDEBAR_WIDTH); + } + }, [commitFileSidebarWidth, fileSidebarWidth, usesDesktopDiffLayout]); + useEffect(() => { const defaultBaseRef = data?.defaultBaseRef?.trim(); - if (!defaultBaseRef || baseRef.trim() || baseRefTouchedRef.current) return; - setBaseRef(defaultBaseRef); - }, [baseRef, data?.defaultBaseRef]); + if (!defaultBaseRef) return; + if (!baseRef.trim() && !baseRefTouchedRef.current) { + setBaseRef(defaultBaseRef); + } + if (view === "working-tree" && !viewTouchedRef.current) { + setView("head"); + } + }, [baseRef, data?.defaultBaseRef, view]); useEffect(() => { if (files.length === 0) { @@ -438,6 +578,19 @@ export function ChangesTab({ context }: PluginDetailTabProps) { }; }, []); + useEffect(() => { + if (!fileSidebarResizing || typeof document === "undefined") return; + + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + return () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + }; + }, [fileSidebarResizing]); + const copyPath = async (filePath: string) => { try { await navigator.clipboard.writeText(filePath); @@ -475,11 +628,37 @@ export function ChangesTab({ context }: PluginDetailTabProps) { Unified +
- -
@@ -526,8 +705,12 @@ export function ChangesTab({ context }: PluginDetailTabProps) { ) : files.length === 0 ? ( ) : ( -
-
{files @@ -559,7 +763,10 @@ export function ChangesTab({ context }: PluginDetailTabProps) { ref={setFileSectionRef(file.path)} className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined} > -
+
{expandedFiles.has(file.path) ? ( - + ) : ( { + if (!input.projectId) return null; + const workspaces = await input.ctx.projects.listWorkspaces(input.projectId, input.companyId); + const projectWorkspace = input.projectWorkspaceId + ? workspaces.find((candidate) => candidate.id === input.projectWorkspaceId) + : workspaces.find((candidate) => candidate.isPrimary) ?? workspaces[0] ?? null; + return projectWorkspace + ? resolveDefaultBaseRef({ + projectWorkspaceDefaultRef: projectWorkspace.defaultRef, + projectWorkspaceRepoRef: projectWorkspace.repoRef, + }) + : null; +} + const plugin = definePlugin({ async setup(ctx) { ctx.logger.info(`${PLUGIN_NAME} plugin setup`); @@ -61,15 +80,13 @@ const plugin = definePlugin({ 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; + if (!readOptionalString(workspace.baseRef)) { + projectWorkspaceDefaultBaseRef = await resolveProjectWorkspaceDefaultBaseRef({ + ctx, + projectId: workspace.projectId || readString(params.projectId), + companyId, + projectWorkspaceId: workspace.projectWorkspaceId, + }); } return workspaceDiff.getDiff({ diff --git a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts index 3bc08b15..55dccc7e 100644 --- a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts +++ b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts @@ -645,6 +645,57 @@ async function resolveHeadSha(cwd: string) { } } +async function resolveVerifiedGitRef(cwd: string, refName: string) { + const trimmed = refName.trim(); + if (!trimmed) return null; + try { + await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${trimmed}^{commit}`], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: 128 * 1024, + }); + return trimmed; + } catch { + return null; + } +} + +async function resolveGitUpstreamRef(cwd: string) { + try { + const upstream = (await execFileAsync( + "git", + ["-C", cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: 128 * 1024, + }, + )).stdout.trim(); + return upstream ? await resolveVerifiedGitRef(cwd, upstream) : null; + } catch { + return null; + } +} + +async function resolveInferredDefaultBaseRef(cwd: string) { + const upstream = await resolveGitUpstreamRef(cwd); + if (upstream) return upstream; + + const candidates = ["origin/master", "origin/main", "master", "main"]; + const resolvedCandidates = await Promise.all( + candidates.map((candidate) => resolveVerifiedGitRef(cwd, candidate)), + ); + for (const resolved of resolvedCandidates) { + if (resolved) return resolved; + } + + return null; +} + +async function resolveDefaultDiffBaseRef(cwd: string, workspace: WorkspaceDiffTarget) { + return workspace.baseRef?.trim() || await resolveInferredDefaultBaseRef(cwd); +} + async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) { const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null; if (!resolvedBaseRef) { @@ -705,9 +756,16 @@ export function workspaceDiffService() { return { async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise { const { cwd, repoRoot } = await resolveWorkspacePaths(workspace); + const defaultBaseRef = await resolveDefaultDiffBaseRef(cwd, workspace); + const workspaceWithDefaultBaseRef = { ...workspace, baseRef: defaultBaseRef }; const paths = normalizePathFilters(query.paths); const warnings: WorkspaceDiffWarning[] = []; - const { files: filesByPath, baseRef } = await collectFiles({ cwd, workspace, query, paths }); + const { files: filesByPath, baseRef } = await collectFiles({ + cwd, + workspace: workspaceWithDefaultBaseRef, + 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) { @@ -771,7 +829,7 @@ export function workspaceDiffService() { companyId: workspace.companyId, view: query.view, baseRef, - defaultBaseRef: workspace.baseRef, + defaultBaseRef, headSha: await resolveHeadSha(cwd), includeUntracked: query.includeUntracked, paths, diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts index 9796c178..b9b3dd7b 100644 --- a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -227,6 +227,115 @@ describe("workspace diff plugin", () => { }); }); + it("uses the primary project workspace default ref when execution workspace has no workspace link", async () => { + const root = await createGitWorkspace(); + await git(root, ["checkout", "-b", "feature"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 5;\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: null, + 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", + projectId: "project-1", + view: "head", + baseRef: null, + includeUntracked: false, + }); + + expect(result).toMatchObject({ + baseRef: "main", + defaultBaseRef: "main", + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + + it("infers the default base ref from the execution workspace branch upstream", async () => { + const root = await createGitWorkspace(); + await git(root, ["update-ref", "refs/remotes/origin/master", "HEAD"]); + await git(root, ["checkout", "-b", "feature"]); + await git(root, ["config", "branch.feature.remote", "origin"]); + await git(root, ["config", "branch.feature.merge", "refs/heads/master"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 6;\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: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: null, + branchName: "feature", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + await plugin.definition.setup(harness.ctx); + + await expect(harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + view: "working-tree", + includeUntracked: false, + })).resolves.toMatchObject({ + baseRef: null, + defaultBaseRef: "origin/master", + stats: { fileCount: 0 }, + }); + + await expect(harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + view: "head", + baseRef: null, + includeUntracked: false, + })).resolves.toMatchObject({ + baseRef: "origin/master", + defaultBaseRef: "origin/master", + 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); diff --git a/screenshots/PAP-9841-workspace-diff.png b/screenshots/PAP-9841-workspace-diff.png new file mode 100644 index 00000000..dcd0e7e0 Binary files /dev/null and b/screenshots/PAP-9841-workspace-diff.png differ