[codex] Workspace diff polish (#6383)
## Thinking Path > - Paperclip gives operators a workspace diff plugin so they can inspect agent changes before review > - The diff view needs reliable base-ref defaults and controls that stay usable while scrolling large diffs > - The working branch mixed those plugin improvements with unrelated server and cloud work > - Keeping the workspace diff plugin changes isolated makes them easy to test and review > - This pull request polishes the workspace diff plugin controls, base-ref behavior, and sticky headers > - The benefit is a more predictable diff review surface for agent workspaces ## What Changed - Fixed workspace diff default base-ref resolution. - Improved split/unified and working-tree/against-ref pane controls. - Made workspace diff headers stay sticky while scrolling. - Added a review screenshot at `screenshots/PAP-9841-workspace-diff.png`. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm --filter @paperclipai/plugin-sdk build` - `pnpm --filter @paperclipai/plugin-workspace-diff exec vitest run tests/plugin.spec.ts` - Result: 9 tests passed. ## Risks - UI-only plugin branch with low data risk. - The default base-ref inference should be reviewed against unusual worktree/upstream combinations. > 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-based coding agent with local shell/git/tool use. Exact hosted model ID and context-window size are not exposed by the local Paperclip adapter 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:
@@ -3,7 +3,17 @@ import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
|
|||||||
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
|
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
|
||||||
import type { PatchDiffProps } from "@pierre/diffs/react";
|
import type { PatchDiffProps } from "@pierre/diffs/react";
|
||||||
import { useFileDiffInstance } 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 {
|
import {
|
||||||
diffSummary,
|
diffSummary,
|
||||||
fileName,
|
fileName,
|
||||||
@@ -23,6 +33,12 @@ type DiffViewMode = "working-tree" | "head";
|
|||||||
|
|
||||||
type LucideIconProps = { size?: number };
|
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) {
|
function makeLucideIcon(paths: ReactNode) {
|
||||||
return function LucideIcon({ size = 16 }: LucideIconProps) {
|
return function LucideIcon({ size = 16 }: LucideIconProps) {
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +73,11 @@ function readInitialView(): DiffViewMode {
|
|||||||
return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree";
|
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() {
|
function readInitialBaseRef() {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
return new URLSearchParams(window.location.search).get("baseRef") ?? "";
|
return new URLSearchParams(window.location.search).get("baseRef") ?? "";
|
||||||
@@ -80,6 +101,51 @@ function iconButtonClass(active = false) {
|
|||||||
].join(" ");
|
].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) {
|
function warningText(file: DiffFileViewModel) {
|
||||||
if (file.binary) return "Binary file";
|
if (file.binary) return "Binary file";
|
||||||
if (file.oversized) return "Too large to render";
|
if (file.oversized) return "Too large to render";
|
||||||
@@ -260,9 +326,11 @@ export function ErrorState({
|
|||||||
function FileDiffPanel({
|
function FileDiffPanel({
|
||||||
file,
|
file,
|
||||||
mode,
|
mode,
|
||||||
|
lineWrap,
|
||||||
}: {
|
}: {
|
||||||
file: DiffFileViewModel;
|
file: DiffFileViewModel;
|
||||||
mode: DiffRenderMode;
|
mode: DiffRenderMode;
|
||||||
|
lineWrap: boolean;
|
||||||
}) {
|
}) {
|
||||||
const warning = warningText(file);
|
const warning = warningText(file);
|
||||||
if (warning) {
|
if (warning) {
|
||||||
@@ -295,7 +363,7 @@ function FileDiffPanel({
|
|||||||
patch={patch.patch}
|
patch={patch.patch}
|
||||||
options={{
|
options={{
|
||||||
diffStyle: mode,
|
diffStyle: mode,
|
||||||
overflow: "scroll",
|
overflow: lineWrap ? "wrap" : "scroll",
|
||||||
disableLineNumbers: false,
|
disableLineNumbers: false,
|
||||||
themeType: "system",
|
themeType: "system",
|
||||||
}}
|
}}
|
||||||
@@ -343,25 +411,38 @@ function CollapsedFilePanel({
|
|||||||
export function ChangesTab({ context }: PluginDetailTabProps) {
|
export function ChangesTab({ context }: PluginDetailTabProps) {
|
||||||
const toast = usePluginToast();
|
const toast = usePluginToast();
|
||||||
const [mode, setMode] = useState<DiffRenderMode>("split");
|
const [mode, setMode] = useState<DiffRenderMode>("split");
|
||||||
|
const [lineWrap, setLineWrap] = useState(false);
|
||||||
const [view, setView] = useState<DiffViewMode>(() => readInitialView());
|
const [view, setView] = useState<DiffViewMode>(() => readInitialView());
|
||||||
const [baseRef, setBaseRef] = useState(() => readInitialBaseRef());
|
const [baseRef, setBaseRef] = useState(() => readInitialBaseRef());
|
||||||
const baseRefTouchedRef = useRef(Boolean(baseRef.trim()));
|
const baseRefTouchedRef = useRef(Boolean(baseRef.trim()));
|
||||||
|
const viewTouchedRef = useRef(hasInitialViewParam());
|
||||||
const [includeUntracked, setIncludeUntracked] = useState(false);
|
const [includeUntracked, setIncludeUntracked] = useState(false);
|
||||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(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<string, HTMLElement>());
|
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
|
||||||
const diffScrollRef = useRef<HTMLElement | null>(null);
|
const diffScrollRef = useRef<HTMLElement | null>(null);
|
||||||
const scrollSyncFrameRef = useRef<number | null>(null);
|
const scrollSyncFrameRef = useRef<number | null>(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(() => ({
|
const params = useMemo(() => ({
|
||||||
workspaceId: context.entityId,
|
workspaceId: context.entityId,
|
||||||
companyId: context.companyId ?? "",
|
companyId: context.companyId ?? "",
|
||||||
projectId: context.projectId ?? "",
|
projectId: context.projectId ?? "",
|
||||||
entityType: context.entityType,
|
entityType: context.entityType,
|
||||||
view,
|
view: effectiveView,
|
||||||
baseRef: baseRef.trim() || null,
|
baseRef: requestedBaseRef || null,
|
||||||
includeUntracked,
|
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<WorkspaceDiffData>("workspace-diff", params);
|
const { data, loading, error, refresh } = usePluginData<WorkspaceDiffData>("workspace-diff", params);
|
||||||
const files = useMemo(() => toFileViewModels(data), [data]);
|
const files = useMemo(() => toFileViewModels(data), [data]);
|
||||||
@@ -414,11 +495,70 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
|||||||
});
|
});
|
||||||
}, [syncSelectedPathFromScroll]);
|
}, [syncSelectedPathFromScroll]);
|
||||||
|
|
||||||
|
const commitFileSidebarWidth = useCallback((nextWidth: number) => {
|
||||||
|
const clamped = clampFileSidebarWidth(nextWidth);
|
||||||
|
fileSidebarWidthRef.current = clamped;
|
||||||
|
setFileSidebarWidth(clamped);
|
||||||
|
writeStoredFileSidebarWidth(clamped);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileSidebarPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const defaultBaseRef = data?.defaultBaseRef?.trim();
|
const defaultBaseRef = data?.defaultBaseRef?.trim();
|
||||||
if (!defaultBaseRef || baseRef.trim() || baseRefTouchedRef.current) return;
|
if (!defaultBaseRef) return;
|
||||||
setBaseRef(defaultBaseRef);
|
if (!baseRef.trim() && !baseRefTouchedRef.current) {
|
||||||
}, [baseRef, data?.defaultBaseRef]);
|
setBaseRef(defaultBaseRef);
|
||||||
|
}
|
||||||
|
if (view === "working-tree" && !viewTouchedRef.current) {
|
||||||
|
setView("head");
|
||||||
|
}
|
||||||
|
}, [baseRef, data?.defaultBaseRef, view]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (files.length === 0) {
|
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) => {
|
const copyPath = async (filePath: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(filePath);
|
await navigator.clipboard.writeText(filePath);
|
||||||
@@ -475,11 +628,37 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
|||||||
Unified
|
Unified
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
key="line-wrap"
|
||||||
|
type="button"
|
||||||
|
className={buttonClass(lineWrap)}
|
||||||
|
onClick={() => setLineWrap((value) => !value)}
|
||||||
|
title={lineWrap ? "Disable line wrapping" : "Enable line wrapping"}
|
||||||
|
aria-pressed={lineWrap}
|
||||||
|
>
|
||||||
|
{lineWrap ? "Wrap on" : "Wrap lines"}
|
||||||
|
</button>
|
||||||
<div key="view" className="inline-flex gap-1" aria-label="Diff comparison">
|
<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")}>
|
<button
|
||||||
|
key="working-tree"
|
||||||
|
type="button"
|
||||||
|
className={buttonClass(effectiveView === "working-tree")}
|
||||||
|
onClick={() => {
|
||||||
|
viewTouchedRef.current = true;
|
||||||
|
setView("working-tree");
|
||||||
|
}}
|
||||||
|
>
|
||||||
Working tree
|
Working tree
|
||||||
</button>
|
</button>
|
||||||
<button key="head" type="button" className={buttonClass(view === "head")} onClick={() => setView("head")}>
|
<button
|
||||||
|
key="head"
|
||||||
|
type="button"
|
||||||
|
className={buttonClass(effectiveView === "head")}
|
||||||
|
onClick={() => {
|
||||||
|
viewTouchedRef.current = true;
|
||||||
|
setView("head");
|
||||||
|
}}
|
||||||
|
>
|
||||||
Against ref
|
Against ref
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -526,8 +705,12 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
|||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<EmptyState />
|
<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)]">
|
<div key="content" className="flex flex-col gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:flex-row">
|
||||||
<aside key="files" className="flex min-w-0 flex-col border border-border bg-background lg:h-full lg:overflow-hidden">
|
<aside
|
||||||
|
key="files"
|
||||||
|
className="relative flex min-w-0 flex-col border border-border bg-background lg:h-full lg:shrink-0 lg:overflow-hidden"
|
||||||
|
style={fileSidebarStyle}
|
||||||
|
>
|
||||||
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Files
|
Files
|
||||||
</div>
|
</div>
|
||||||
@@ -544,12 +727,33 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-label="Resize file list"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuemin={MIN_FILE_SIDEBAR_WIDTH}
|
||||||
|
aria-valuemax={MAX_FILE_SIDEBAR_WIDTH}
|
||||||
|
aria-valuenow={fileSidebarWidth}
|
||||||
|
tabIndex={0}
|
||||||
|
className={[
|
||||||
|
"absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize touch-none outline-none lg:block",
|
||||||
|
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
|
||||||
|
"hover:before:bg-border focus-visible:before:bg-ring",
|
||||||
|
fileSidebarResizing ? "before:bg-ring" : "",
|
||||||
|
].join(" ")}
|
||||||
|
onPointerDown={handleFileSidebarPointerDown}
|
||||||
|
onPointerMove={handleFileSidebarPointerMove}
|
||||||
|
onPointerUp={endFileSidebarResize}
|
||||||
|
onPointerCancel={endFileSidebarResize}
|
||||||
|
onLostPointerCapture={endFileSidebarResize}
|
||||||
|
onKeyDown={handleFileSidebarKeyDown}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
key="diffs"
|
key="diffs"
|
||||||
ref={diffScrollRef}
|
ref={diffScrollRef}
|
||||||
className="max-h-[70vh] min-w-0 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
|
className="max-h-[70vh] min-w-0 flex-1 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
|
||||||
onScroll={handleDiffScroll}
|
onScroll={handleDiffScroll}
|
||||||
>
|
>
|
||||||
{files
|
{files
|
||||||
@@ -559,7 +763,10 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
|||||||
ref={setFileSectionRef(file.path)}
|
ref={setFileSectionRef(file.path)}
|
||||||
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
|
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="header"
|
||||||
|
className="sticky top-0 z-30 flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-background px-3 py-2 shadow-sm"
|
||||||
|
>
|
||||||
<div key="left" className="flex min-w-0 items-start gap-2">
|
<div key="left" className="flex min-w-0 items-start gap-2">
|
||||||
<button
|
<button
|
||||||
key="collapse"
|
key="collapse"
|
||||||
@@ -599,7 +806,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expandedFiles.has(file.path) ? (
|
{expandedFiles.has(file.path) ? (
|
||||||
<FileDiffPanel key="diff" file={file} mode={mode} />
|
<FileDiffPanel key="diff" file={file} mode={mode} lineWrap={lineWrap} />
|
||||||
) : (
|
) : (
|
||||||
<CollapsedFilePanel
|
<CollapsedFilePanel
|
||||||
key="collapsed"
|
key="collapsed"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
import { definePlugin, runWorker, type PluginContext } from "@paperclipai/plugin-sdk";
|
||||||
import { workspaceDiffQuerySchema } from "./contracts.js";
|
import { workspaceDiffQuerySchema } from "./contracts.js";
|
||||||
import { workspaceDiffService } from "./workspace-diff.js";
|
import { workspaceDiffService } from "./workspace-diff.js";
|
||||||
|
|
||||||
@@ -23,6 +23,25 @@ export function resolveDefaultBaseRef(input: {
|
|||||||
?? readOptionalString(input.projectWorkspaceRepoRef);
|
?? readOptionalString(input.projectWorkspaceRepoRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveProjectWorkspaceDefaultBaseRef(input: {
|
||||||
|
ctx: PluginContext;
|
||||||
|
projectId: string;
|
||||||
|
companyId: string;
|
||||||
|
projectWorkspaceId?: string | null;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
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({
|
const plugin = definePlugin({
|
||||||
async setup(ctx) {
|
async setup(ctx) {
|
||||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||||
@@ -61,15 +80,13 @@ const plugin = definePlugin({
|
|||||||
throw new Error("Workspace not found");
|
throw new Error("Workspace not found");
|
||||||
}
|
}
|
||||||
let projectWorkspaceDefaultBaseRef: string | null = null;
|
let projectWorkspaceDefaultBaseRef: string | null = null;
|
||||||
if (!readOptionalString(workspace.baseRef) && workspace.projectWorkspaceId) {
|
if (!readOptionalString(workspace.baseRef)) {
|
||||||
const workspaces = await ctx.projects.listWorkspaces(workspace.projectId, companyId);
|
projectWorkspaceDefaultBaseRef = await resolveProjectWorkspaceDefaultBaseRef({
|
||||||
const projectWorkspace = workspaces.find((candidate) => candidate.id === workspace.projectWorkspaceId);
|
ctx,
|
||||||
projectWorkspaceDefaultBaseRef = projectWorkspace
|
projectId: workspace.projectId || readString(params.projectId),
|
||||||
? resolveDefaultBaseRef({
|
companyId,
|
||||||
projectWorkspaceDefaultRef: projectWorkspace.defaultRef,
|
projectWorkspaceId: workspace.projectWorkspaceId,
|
||||||
projectWorkspaceRepoRef: projectWorkspace.repoRef,
|
});
|
||||||
})
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return workspaceDiff.getDiff({
|
return workspaceDiff.getDiff({
|
||||||
|
|||||||
@@ -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) {
|
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
|
||||||
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
|
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
|
||||||
if (!resolvedBaseRef) {
|
if (!resolvedBaseRef) {
|
||||||
@@ -705,9 +756,16 @@ export function workspaceDiffService() {
|
|||||||
return {
|
return {
|
||||||
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
|
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
|
||||||
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
|
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
|
||||||
|
const defaultBaseRef = await resolveDefaultDiffBaseRef(cwd, workspace);
|
||||||
|
const workspaceWithDefaultBaseRef = { ...workspace, baseRef: defaultBaseRef };
|
||||||
const paths = normalizePathFilters(query.paths);
|
const paths = normalizePathFilters(query.paths);
|
||||||
const warnings: WorkspaceDiffWarning[] = [];
|
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 allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
|
||||||
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
|
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
|
||||||
if (allFiles.length > cappedFiles.length) {
|
if (allFiles.length > cappedFiles.length) {
|
||||||
@@ -771,7 +829,7 @@ export function workspaceDiffService() {
|
|||||||
companyId: workspace.companyId,
|
companyId: workspace.companyId,
|
||||||
view: query.view,
|
view: query.view,
|
||||||
baseRef,
|
baseRef,
|
||||||
defaultBaseRef: workspace.baseRef,
|
defaultBaseRef,
|
||||||
headSha: await resolveHeadSha(cwd),
|
headSha: await resolveHeadSha(cwd),
|
||||||
includeUntracked: query.includeUntracked,
|
includeUntracked: query.includeUntracked,
|
||||||
paths,
|
paths,
|
||||||
|
|||||||
@@ -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 () => {
|
it("returns a clear bridge error when required context is missing", async () => {
|
||||||
const harness = createTestHarness({ manifest });
|
const harness = createTestHarness({ manifest });
|
||||||
await plugin.definition.setup(harness.ctx);
|
await plugin.definition.setup(harness.ctx);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Reference in New Issue
Block a user