From dd911350a9ec8efbae1d68fe691b57df2465f456 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 19 May 2026 12:55:07 -0500 Subject: [PATCH] Improve workspace diff pane controls Co-Authored-By: Paperclip --- .../plugin-workspace-diff/src/ui/index.tsx | 197 +++++++++++++++++- 1 file changed, 190 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index c18765b0a6e..84023e533df 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 ( @@ -85,6 +101,49 @@ 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(false); + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + + const query = window.matchMedia("(min-width: 1024px)"); + const update = () => setIsDesktop(query.matches); + update(); + 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"; @@ -265,9 +324,11 @@ export function ErrorState({ function FileDiffPanel({ file, mode, + lineWrap, }: { file: DiffFileViewModel; mode: DiffRenderMode; + lineWrap: boolean; }) { const warning = warningText(file); if (warning) { @@ -300,7 +361,7 @@ function FileDiffPanel({ patch={patch.patch} options={{ diffStyle: mode, - overflow: "scroll", + overflow: lineWrap ? "wrap" : "scroll", disableLineNumbers: false, themeType: "system", }} @@ -348,6 +409,7 @@ 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())); @@ -355,11 +417,20 @@ export function ChangesTab({ context }: PluginDetailTabProps) { 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, @@ -422,6 +493,60 @@ 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) return; @@ -451,6 +576,23 @@ export function ChangesTab({ context }: PluginDetailTabProps) { }; }, []); + useEffect(() => { + fileSidebarWidthRef.current = fileSidebarWidth; + }, [fileSidebarWidth]); + + 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); @@ -488,6 +630,16 @@ export function ChangesTab({ context }: PluginDetailTabProps) { Unified +
{expandedFiles.has(file.path) ? ( - + ) : (