816 lines
28 KiB
TypeScript
816 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<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 ?? [];
|
|
|
|
// Track the `?file=` query parameter across navigations (popstate).
|
|
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]);
|
|
|
|
// 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<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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<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>
|
|
);
|
|
}
|