import type { PluginProjectSidebarItemProps, PluginDetailTabProps, PluginCommentAnnotationProps, PluginCommentContextMenuItemProps, } from "@paperclipai/plugin-sdk/ui"; import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui"; import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react"; import { EditorView } from "@codemirror/view"; import { basicSetup } from "codemirror"; import { javascript } from "@codemirror/lang-javascript"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { tags } from "@lezer/highlight"; const PLUGIN_KEY = "paperclip-file-browser-example"; const FILES_TAB_SLOT_ID = "files-tab"; const editorBaseTheme = { "&": { height: "100%", }, ".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace", fontSize: "13px", lineHeight: "1.6", }, ".cm-content": { padding: "12px 14px 18px", }, }; const editorDarkTheme = EditorView.theme({ ...editorBaseTheme, "&": { ...editorBaseTheme["&"], backgroundColor: "oklch(0.23 0.02 255)", color: "oklch(0.93 0.01 255)", }, ".cm-gutters": { backgroundColor: "oklch(0.25 0.015 255)", color: "oklch(0.74 0.015 255)", borderRight: "1px solid oklch(0.34 0.01 255)", }, ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "oklch(0.30 0.012 255 / 0.55)", }, ".cm-selectionBackground, .cm-content ::selection": { backgroundColor: "oklch(0.42 0.02 255 / 0.45)", }, "&.cm-focused .cm-selectionBackground": { backgroundColor: "oklch(0.47 0.025 255 / 0.5)", }, ".cm-cursor, .cm-dropCursor": { borderLeftColor: "oklch(0.93 0.01 255)", }, ".cm-matchingBracket": { backgroundColor: "oklch(0.37 0.015 255 / 0.5)", color: "oklch(0.95 0.01 255)", outline: "none", }, ".cm-nonmatchingBracket": { color: "oklch(0.70 0.08 24)", }, }, { dark: true }); const editorLightTheme = EditorView.theme({ ...editorBaseTheme, "&": { ...editorBaseTheme["&"], backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))", color: "var(--foreground)", }, ".cm-content": { ...editorBaseTheme[".cm-content"], caretColor: "var(--foreground)", }, ".cm-gutters": { backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))", color: "var(--muted-foreground)", borderRight: "1px solid var(--border)", }, ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)", }, ".cm-selectionBackground, .cm-content ::selection": { backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)", }, "&.cm-focused .cm-selectionBackground": { backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)", }, ".cm-cursor, .cm-dropCursor": { borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)", }, ".cm-matchingBracket": { backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)", color: "var(--foreground)", outline: "none", }, ".cm-nonmatchingBracket": { color: "var(--destructive)", }, }); const editorDarkHighlightStyle = HighlightStyle.define([ { tag: tags.keyword, color: "oklch(0.78 0.025 265)" }, { tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" }, { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" }, { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" }, { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" }, { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" }, { tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" }, { tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" }, { tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" }, ]); const editorLightHighlightStyle = HighlightStyle.define([ { tag: tags.keyword, color: "oklch(0.45 0.07 270)" }, { tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" }, { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" }, { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" }, { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" }, { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" }, { tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" }, { tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" }, { tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" }, ]); type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean }; type FileEntry = { name: string; path: string; isDirectory: boolean }; type FileTreeNodeProps = { entry: FileEntry; companyId: string | null; projectId: string; workspaceId: string; selectedPath: string | null; onSelect: (path: string) => void; depth?: number; }; const PathLikePattern = /[\\/]/; const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/; const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function isLikelyPath(pathValue: string): boolean { const trimmed = pathValue.trim(); return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed); } function workspaceLabel(workspace: Workspace): string { const pathLabel = workspace.path.trim(); const nameLabel = workspace.name.trim(); const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel); const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel); const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : ""; if (!baseLabel) { return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)"; } return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel; } function useIsMobile(breakpointPx = 768): boolean { const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" ? window.innerWidth < breakpointPx : false, ); useEffect(() => { if (typeof window === "undefined") return; const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`); const update = () => setIsMobile(mediaQuery.matches); update(); mediaQuery.addEventListener("change", update); return () => mediaQuery.removeEventListener("change", update); }, [breakpointPx]); return isMobile; } function useIsDarkMode(): boolean { const [isDarkMode, setIsDarkMode] = useState(() => typeof document !== "undefined" && document.documentElement.classList.contains("dark"), ); useEffect(() => { if (typeof document === "undefined") return; const root = document.documentElement; const update = () => setIsDarkMode(root.classList.contains("dark")); update(); const observer = new MutationObserver(update); observer.observe(root, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect(); }, []); return isDarkMode; } function useAvailableHeight( ref: RefObject, options?: { bottomPadding?: number; minHeight?: number }, ): number | null { const bottomPadding = options?.bottomPadding ?? 24; const minHeight = options?.minHeight ?? 384; const [height, setHeight] = useState(null); useEffect(() => { if (typeof window === "undefined") return; const update = () => { const element = ref.current; if (!element) return; const rect = element.getBoundingClientRect(); const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding)); setHeight(nextHeight); }; update(); window.addEventListener("resize", update); window.addEventListener("orientationchange", update); const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => update()) : null; if (observer && ref.current) observer.observe(ref.current); return () => { window.removeEventListener("resize", update); window.removeEventListener("orientationchange", update); observer?.disconnect(); }; }, [bottomPadding, minHeight, ref]); return height; } function FileTreeNode({ entry, companyId, projectId, workspaceId, selectedPath, onSelect, depth = 0, }: FileTreeNodeProps) { const [isExpanded, setIsExpanded] = useState(false); const isSelected = selectedPath === entry.path; if (entry.isDirectory) { return (
  • {isExpanded ? ( ) : null}
  • ); } return (
  • ); } function ExpandedDirectoryChildren({ directoryPath, companyId, projectId, workspaceId, selectedPath, onSelect, depth, }: { directoryPath: string; companyId: string | null; projectId: string; workspaceId: string; selectedPath: string | null; onSelect: (path: string) => void; depth: number; }) { const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", { companyId, projectId, workspaceId, directoryPath, }); const children = childData?.entries ?? []; if (children.length === 0) { return null; } return ( ); } /** * Project sidebar item: link "Files" that opens the project detail with the Files plugin tab. */ export function FilesLink({ context }: PluginProjectSidebarItemProps) { const { data: config, loading: configLoading } = usePluginData("plugin-config", {}); const showFilesInSidebar = config?.showFilesInSidebar ?? false; if (configLoading || !showFilesInSidebar) { return null; } const projectId = context.entityId; const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null }) .projectRef ?? projectId; const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`; const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`; const isActive = typeof window !== "undefined" && (() => { const pathname = window.location.pathname.replace(/\/+$/, ""); const segments = pathname.split("/").filter(Boolean); const projectsIndex = segments.indexOf("projects"); const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null; const activeTab = new URLSearchParams(window.location.search).get("tab"); if (activeTab !== tabValue) return false; if (!activeProjectRef) return false; return activeProjectRef === projectId || activeProjectRef === projectRef; })(); const handleClick = (event: MouseEvent) => { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey ) { return; } event.preventDefault(); window.history.pushState({}, "", href); window.dispatchEvent(new PopStateEvent("popstate")); }; return ( Files ); } /** * Project detail tab: workspace selector, file tree, and CodeMirror editor. */ export function FilesTab({ context }: PluginDetailTabProps) { const companyId = context.companyId; const projectId = context.entityId; const isMobile = useIsMobile(); const isDarkMode = useIsDarkMode(); const panesRef = useRef(null); const availableHeight = useAvailableHeight(panesRef, { bottomPadding: isMobile ? 16 : 24, minHeight: isMobile ? 320 : 420, }); const { data: workspacesData } = usePluginData("workspaces", { projectId, companyId, }); const workspaces = workspacesData ?? []; const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|"); const [workspaceId, setWorkspaceId] = useState(null); const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null; const selectedWorkspace = useMemo( () => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null, [workspaces, resolvedWorkspaceId], ); const fileListParams = useMemo( () => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}), [companyId, projectId, selectedWorkspace], ); const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>( "fileList", fileListParams, ); const entries = fileListData?.entries ?? []; // Track the `?file=` query parameter across navigations (popstate). const [urlFilePath, setUrlFilePath] = useState(() => { if (typeof window === "undefined") return null; return new URLSearchParams(window.location.search).get("file") || null; }); const lastConsumedFileRef = useRef(null); useEffect(() => { if (typeof window === "undefined") return; const onNav = () => { const next = new URLSearchParams(window.location.search).get("file") || null; setUrlFilePath(next); }; window.addEventListener("popstate", onNav); return () => window.removeEventListener("popstate", onNav); }, []); const [selectedPath, setSelectedPath] = useState(null); useEffect(() => { setSelectedPath(null); setMobileView("browser"); lastConsumedFileRef.current = null; }, [selectedWorkspace?.id]); // When a file path appears (or changes) in the URL and workspace is ready, select it. useEffect(() => { if (!urlFilePath || !selectedWorkspace) return; if (lastConsumedFileRef.current === urlFilePath) return; lastConsumedFileRef.current = urlFilePath; setSelectedPath(urlFilePath); setMobileView("editor"); }, [urlFilePath, selectedWorkspace]); const fileContentParams = useMemo( () => selectedPath && selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath } : null, [companyId, projectId, selectedWorkspace, selectedPath], ); const fileContentResult = usePluginData<{ content: string | null; error?: string }>( "fileContent", fileContentParams ?? {}, ); const { data: fileContentData, refresh: refreshFileContent } = fileContentResult; const writeFile = usePluginAction("writeFile"); const editorRef = useRef(null); const viewRef = useRef(null); const loadedContentRef = useRef(""); const [isDirty, setIsDirty] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveMessage, setSaveMessage] = useState(null); const [saveError, setSaveError] = useState(null); const [mobileView, setMobileView] = useState<"browser" | "editor">("browser"); useEffect(() => { if (!editorRef.current) return; const content = fileContentData?.content ?? ""; loadedContentRef.current = content; setIsDirty(false); setSaveMessage(null); setSaveError(null); if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null; } const view = new EditorView({ doc: content, extensions: [ basicSetup, javascript(), isDarkMode ? editorDarkTheme : editorLightTheme, syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle), EditorView.updateListener.of((update) => { if (!update.docChanged) return; const nextValue = update.state.doc.toString(); setIsDirty(nextValue !== loadedContentRef.current); setSaveMessage(null); setSaveError(null); }), ], parent: editorRef.current, }); viewRef.current = view; return () => { view.destroy(); viewRef.current = null; }; }, [fileContentData?.content, selectedPath, isDarkMode]); useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") { return; } if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) { return; } event.preventDefault(); void handleSave(); }; window.addEventListener("keydown", handleKeydown); return () => window.removeEventListener("keydown", handleKeydown); }, [selectedWorkspace, selectedPath, isDirty, isSaving]); async function handleSave() { if (!selectedWorkspace || !selectedPath || !viewRef.current) { return; } const content = viewRef.current.state.doc.toString(); setIsSaving(true); setSaveError(null); setSaveMessage(null); try { await writeFile({ projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath, content, }); loadedContentRef.current = content; setIsDirty(false); setSaveMessage("Saved"); refreshFileContent(); } catch (error) { setSaveError(error instanceof Error ? error.message : String(error)); } finally { setIsSaving(false); } } return (
    File Tree
    {selectedWorkspace ? ( fileListLoading ? (

    Loading files...

    ) : entries.length > 0 ? (
      {entries.map((entry) => ( { setSelectedPath(path); setMobileView("editor"); }} /> ))}
    ) : (

    No files found in this workspace.

    ) ) : (

    Select a workspace to browse files.

    )}
    Editor
    {selectedPath ?? "No file selected"}
    {isDirty || saveMessage || saveError ? (
    {saveError ? ( {saveError} ) : saveMessage ? ( {saveMessage} ) : ( Unsaved changes )}
    ) : null} {selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? (
    {fileContentData.error}
    ) : null}
    ); } // --------------------------------------------------------------------------- // Comment Annotation: renders detected file links below each comment // --------------------------------------------------------------------------- type PluginConfig = { showFilesInSidebar?: boolean; commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none"; }; /** * Per-comment annotation showing file-path-like links extracted from the * comment body. Each link navigates to the project Files tab with the * matching path pre-selected. * * Respects the `commentAnnotationMode` instance config — hidden when mode * is `"contextMenu"` or `"none"`. */ function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string { if (!projectId) return "#"; const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`; return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`; } function navigateToFileBrowser(href: string, event: MouseEvent) { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey ) { return; } event.preventDefault(); window.history.pushState({}, "", href); window.dispatchEvent(new PopStateEvent("popstate")); } export function CommentFileLinks({ context }: PluginCommentAnnotationProps) { const { data: config } = usePluginData("plugin-config", {}); const mode = config?.commentAnnotationMode ?? "both"; const { data } = usePluginData<{ links: string[] }>("comment-file-links", { commentId: context.entityId, issueId: context.parentEntityId, companyId: context.companyId, }); if (mode === "contextMenu" || mode === "none") return null; if (!data?.links?.length) return null; const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; const projectId = context.projectId; return ( ); } // --------------------------------------------------------------------------- // Comment Context Menu Item: "Open in Files" action per comment // --------------------------------------------------------------------------- /** * Per-comment context menu item that appears in the comment "more" (⋮) menu. * Extracts file paths from the comment body and, if any are found, renders * a button to open the first file in the project Files tab. * * Respects the `commentAnnotationMode` instance config — hidden when mode * is `"annotation"` or `"none"`. */ export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) { const { data: config } = usePluginData("plugin-config", {}); const mode = config?.commentAnnotationMode ?? "both"; const { data } = usePluginData<{ links: string[] }>("comment-file-links", { commentId: context.entityId, issueId: context.parentEntityId, companyId: context.companyId, }); if (mode === "annotation" || mode === "none") return null; if (!data?.links?.length) return null; const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; const projectId = context.projectId; return (
    Files
    {data.links.map((link) => { const href = buildFileBrowserHref(prefix, projectId, link); const fileName = link.split("/").pop() ?? link; return ( navigateToFileBrowser(href, e)} className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs text-foreground hover:bg-accent transition-colors" title={`Open ${link} in file browser`} > {fileName} ); })}
    ); }