| 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<HTMLElement | null>, |
| options?: { bottomPadding?: number; minHeight?: number }, |
| ): number | null { |
| const bottomPadding = options?.bottomPadding ?? 24; |
| const minHeight = options?.minHeight ?? 384; |
| const [height, setHeight] = useState<number | null>(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 ( |
| <li> |
| <button |
| type="button" |
| className="flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm text-foreground hover:bg-accent/60" |
| style={{ paddingLeft: `${depth * 14 + 8}px` }} |
| onClick={() => setIsExpanded((value) => !value)} |
| aria-expanded={isExpanded} |
| > |
| <span className="w-3 text-xs text-muted-foreground">{isExpanded ? "▾" : "▸"}</span> |
| <span className="truncate font-medium">{entry.name}</span> |
| </button> |
| {isExpanded ? ( |
| <ExpandedDirectoryChildren |
| directoryPath={entry.path} |
| companyId={companyId} |
| projectId={projectId} |
| workspaceId={workspaceId} |
| selectedPath={selectedPath} |
| onSelect={onSelect} |
| depth={depth} |
| /> |
| ) : null} |
| </li> |
| ); |
| } |
|
|
| return ( |
| <li> |
| <button |
| type="button" |
| className={`block w-full rounded-none px-2 py-1.5 text-left text-sm transition-colors ${ |
| isSelected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" |
| }`} |
| style={{ paddingLeft: `${depth * 14 + 23}px` }} |
| onClick={() => onSelect(entry.path)} |
| > |
| <span className="truncate">{entry.name}</span> |
| </button> |
| </li> |
| ); |
| } |
|
|
| 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 ( |
| <ul className="space-y-0.5"> |
| {children.map((child) => ( |
| <FileTreeNode |
| key={child.path} |
| entry={child} |
| companyId={companyId} |
| projectId={projectId} |
| workspaceId={workspaceId} |
| selectedPath={selectedPath} |
| onSelect={onSelect} |
| depth={depth + 1} |
| /> |
| ))} |
| </ul> |
| ); |
| } |
|
|
| |
| |
| |
| export function FilesLink({ context }: PluginProjectSidebarItemProps) { |
| const { data: config, loading: configLoading } = usePluginData<PluginConfig>("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<HTMLAnchorElement>) => { |
| 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 ( |
| <a |
| href={href} |
| onClick={handleClick} |
| aria-current={isActive ? "page" : undefined} |
| className={`block px-3 py-1 text-[12px] truncate transition-colors ${ |
| isActive |
| ? "bg-accent text-foreground font-medium" |
| : "text-muted-foreground hover:text-foreground hover:bg-accent/50" |
| }`} |
| > |
| Files |
| </a> |
| ); |
| } |
|
|
| |
| |
| |
| export function FilesTab({ context }: PluginDetailTabProps) { |
| const companyId = context.companyId; |
| const projectId = context.entityId; |
| const isMobile = useIsMobile(); |
| const isDarkMode = useIsDarkMode(); |
| const panesRef = useRef<HTMLDivElement | null>(null); |
| const availableHeight = useAvailableHeight(panesRef, { |
| bottomPadding: isMobile ? 16 : 24, |
| minHeight: isMobile ? 320 : 420, |
| }); |
| const { data: workspacesData } = usePluginData<Workspace[]>("workspaces", { |
| projectId, |
| companyId, |
| }); |
| const workspaces = workspacesData ?? []; |
| const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|"); |
| const [workspaceId, setWorkspaceId] = useState<string | null>(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 ?? []; |
|
|
| |
| const [urlFilePath, setUrlFilePath] = useState<string | null>(() => { |
| if (typeof window === "undefined") return null; |
| return new URLSearchParams(window.location.search).get("file") || null; |
| }); |
| const lastConsumedFileRef = useRef<string | null>(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<string | null>(null); |
| useEffect(() => { |
| setSelectedPath(null); |
| setMobileView("browser"); |
| lastConsumedFileRef.current = null; |
| }, [selectedWorkspace?.id]); |
|
|
| |
| 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<HTMLDivElement | null>(null); |
| const viewRef = useRef<EditorView | null>(null); |
| const loadedContentRef = useRef(""); |
| const [isDirty, setIsDirty] = useState(false); |
| const [isSaving, setIsSaving] = useState(false); |
| const [saveMessage, setSaveMessage] = useState<string | null>(null); |
| const [saveError, setSaveError] = useState<string | null>(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 ( |
| <div className="space-y-4"> |
| <div className="rounded-lg border border-border bg-card p-4"> |
| <label className="text-sm font-medium text-muted-foreground">Workspace</label> |
| <select |
| key={workspaceSelectKey} |
| className="mt-2 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm" |
| value={resolvedWorkspaceId ?? ""} |
| onChange={(e) => setWorkspaceId(e.target.value || null)} |
| > |
| {workspaces.map((w) => { |
| const label = workspaceLabel(w); |
| return ( |
| <option key={`${w.id}:${label}`} value={w.id} label={label} title={label}> |
| {label} |
| </option> |
| ); |
| })} |
| </select> |
| </div> |
| |
| <div |
| ref={panesRef} |
| className="min-h-0" |
| style={{ |
| display: isMobile ? "block" : "grid", |
| gap: "1rem", |
| gridTemplateColumns: isMobile ? undefined : "320px minmax(0, 1fr)", |
| height: availableHeight ? `${availableHeight}px` : undefined, |
| minHeight: isMobile ? "20rem" : "26rem", |
| }} |
| > |
| <div |
| className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card" |
| style={{ display: isMobile && mobileView === "editor" ? "none" : "flex" }} |
| > |
| <div className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"> |
| File Tree |
| </div> |
| <div className="min-h-0 flex-1 overflow-auto p-2"> |
| {selectedWorkspace ? ( |
| fileListLoading ? ( |
| <p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p> |
| ) : entries.length > 0 ? ( |
| <ul className="space-y-0.5"> |
| {entries.map((entry) => ( |
| <FileTreeNode |
| key={entry.path} |
| entry={entry} |
| companyId={companyId} |
| projectId={projectId} |
| workspaceId={selectedWorkspace.id} |
| selectedPath={selectedPath} |
| onSelect={(path) => { |
| setSelectedPath(path); |
| setMobileView("editor"); |
| }} |
| /> |
| ))} |
| </ul> |
| ) : ( |
| <p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p> |
| ) |
| ) : ( |
| <p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p> |
| )} |
| </div> |
| </div> |
| <div |
| className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card" |
| style={{ display: isMobile && mobileView === "browser" ? "none" : "flex" }} |
| > |
| <div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-card px-4 py-2"> |
| <div className="min-w-0"> |
| <button |
| type="button" |
| className="mb-2 inline-flex rounded-md border border-input bg-background px-2 py-1 text-xs font-medium text-muted-foreground" |
| style={{ display: isMobile ? "inline-flex" : "none" }} |
| onClick={() => setMobileView("browser")} |
| > |
| Back to files |
| </button> |
| <div className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Editor</div> |
| <div className="truncate text-sm text-foreground">{selectedPath ?? "No file selected"}</div> |
| </div> |
| <div className="flex items-center gap-3"> |
| <button |
| type="button" |
| className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" |
| disabled={!selectedWorkspace || !selectedPath || !isDirty || isSaving} |
| onClick={() => void handleSave()} |
| > |
| {isSaving ? "Saving..." : "Save"} |
| </button> |
| </div> |
| </div> |
| {isDirty || saveMessage || saveError ? ( |
| <div className="border-b border-border px-4 py-2 text-xs"> |
| {saveError ? ( |
| <span className="text-destructive">{saveError}</span> |
| ) : saveMessage ? ( |
| <span className="text-emerald-600">{saveMessage}</span> |
| ) : ( |
| <span className="text-muted-foreground">Unsaved changes</span> |
| )} |
| </div> |
| ) : null} |
| {selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? ( |
| <div className="border-b border-border px-4 py-2 text-xs text-destructive">{fileContentData.error}</div> |
| ) : null} |
| <div ref={editorRef} className="min-h-0 flex-1 overflow-auto overscroll-contain" /> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| type PluginConfig = { |
| showFilesInSidebar?: boolean; |
| commentAnnotationMode: "annotation" | "contextMenu" | "both" | "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<HTMLAnchorElement>) { |
| 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<PluginConfig>("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 ( |
| <div className="flex flex-wrap items-center gap-1.5"> |
| <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Files:</span> |
| {data.links.map((link) => { |
| const href = buildFileBrowserHref(prefix, projectId, link); |
| return ( |
| <a |
| key={link} |
| href={href} |
| onClick={(e) => navigateToFileBrowser(href, e)} |
| className="inline-flex items-center rounded-md border border-border bg-accent/30 px-1.5 py-0.5 text-xs font-mono text-primary hover:bg-accent/60 hover:underline transition-colors" |
| title={`Open ${link} in file browser`} |
| > |
| {link} |
| </a> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) { |
| const { data: config } = usePluginData<PluginConfig>("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 ( |
| <div> |
| <div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> |
| Files |
| </div> |
| {data.links.map((link) => { |
| const href = buildFileBrowserHref(prefix, projectId, link); |
| const fileName = link.split("/").pop() ?? link; |
| return ( |
| <a |
| key={link} |
| href={href} |
| onClick={(e) => 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`} |
| > |
| <span className="truncate font-mono">{fileName}</span> |
| </a> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|