import {
IconChevronRight,
IconChevronDown,
IconFolder,
IconFileText,
IconFileCode,
IconPhoto,
IconFile,
IconPlus,
IconTrash,
IconHierarchy2,
IconPlugConnected,
IconBrowser,
IconDeviceDesktop,
IconBulb,
IconClockHour3,
IconLoader2,
IconHelpCircle,
} from "@tabler/icons-react";
import React, { useState, useRef, useCallback } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../components/ui/tooltip.js";
import { cn } from "../utils.js";
import type { McpServer } from "./use-mcp-servers.js";
import type { TreeNode, ResourceMeta, JobMetadata } from "./use-resources.js";
function StatusDot({
className,
tooltip,
}: {
className: string;
tooltip: string;
}) {
return (
{tooltip}
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function getFileIcon(node: TreeNode): React.ReactNode {
if (node.kind === "agent") {
return (
);
}
if (node.kind === "remote-agent" || node.kind === "mcp-server") {
return (
);
}
if (node.kind === "mcp-builtin") {
return node.mcpBuiltinMeta?.exclusiveGroup === "browser" ? (
) : (
);
}
if (node.kind === "skill") {
return ;
}
if (node.kind === "job") {
return (
);
}
const name = node.name;
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const iconClass = "h-3.5 w-3.5 shrink-0 text-muted-foreground";
if (ext === "md" || ext === "mdx")
return ;
if (
["ts", "tsx", "js", "jsx", "json", "css", "html", "py", "sh"].includes(ext)
)
return ;
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(ext))
return ;
return ;
}
// ─── Types ──────────────────────────────────────────────────────────────────
export interface ResourceTreeProps {
tree: TreeNode[];
/** Presentation mode for the resources: nested tree rows or a flat collection. */
variant?: "tree" | "collection";
selectedId: string | null;
onSelect: (resource: ResourceMeta) => void;
onCreateFile: (parentPath: string, name: string) => void;
onCreateFolder: (parentPath: string, name: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, newPath: string) => void;
onDrop: (files: FileList) => void;
/** Section title displayed as heading */
title?: string;
/** Tooltip for the section heading */
titleTooltip?: string;
/** Whether this section's tree is still loading */
isLoading?: boolean;
/** Resource id currently being deleted (shows spinner + muted row) */
deletingId?: string | null;
/** When true, hide create/delete/rename/upload affordances. Files stay readable. */
readOnly?: boolean;
/** Optional hint shown next to the heading (e.g. "Read only") */
headingHint?: React.ReactNode;
/** Scope-specific action shown beside the section heading. */
sectionAction?: React.ReactNode;
/** Scope-specific action shown beneath an empty collection message. */
emptyStateAction?: React.ReactNode;
}
interface CreatingState {
parentPath: string;
type: "file" | "folder";
}
function McpStatusDot({ server }: { server: McpServer }) {
const status = server.status ?? { state: "unknown" as const };
if (status.state === "connected") {
return (
);
}
if (status.state === "error") {
return (
);
}
return (
);
}
function BuiltinStatusDot({ node }: { node: TreeNode }) {
const meta = node.mcpBuiltinMeta;
if (!meta?.available) {
return (
);
}
if (!meta.scopeEnabled) {
return (
);
}
const status = meta.status?.[meta.scope];
if (status?.state === "connected") {
return (
);
}
if (status?.state === "error") {
return (
);
}
return ;
}
function JobStatusDot({ meta }: { meta: JobMetadata }) {
if (!meta.enabled) {
return (
);
}
if (meta.lastStatus === "running") {
return (
);
}
if (meta.lastStatus === "error") {
return (
);
}
if (meta.lastStatus === "success") {
return (
);
}
return (
);
}
type LeafResourceNode = TreeNode & { resource: ResourceMeta };
function getLeafResources(nodes: TreeNode[]): LeafResourceNode[] {
return nodes.flatMap((node) => {
if (node.type === "folder") {
return getLeafResources(node.children ?? []);
}
return node.resource ? [node as LeafResourceNode] : [];
});
}
function formatResourceSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getCollectionDescriptor(node: LeafResourceNode): string {
if (node.kind === "agent") {
return (
node.agentMeta?.description || node.agentMeta?.model || "Custom agent"
);
}
if (node.kind === "skill") {
return node.skillMeta?.description || "Skill";
}
if (node.kind === "remote-agent") {
return (
node.remoteAgentMeta?.description ||
node.remoteAgentMeta?.url ||
"Remote agent"
);
}
if (node.kind === "job") {
return (
node.jobMeta?.scheduleDescription ||
node.jobMeta?.schedule ||
"Scheduled job"
);
}
if (node.kind === "mcp-server") return "MCP server";
if (node.kind === "mcp-builtin") return "Built-in MCP";
return formatResourceSize(node.resource.size);
}
function CollectionResourceRow({
node,
selectedId,
deletingId,
readOnly,
onSelect,
onDelete,
}: {
node: LeafResourceNode;
selectedId: string | null;
deletingId?: string | null;
readOnly?: boolean;
onSelect: (resource: ResourceMeta) => void;
onDelete: (id: string) => void;
}) {
const isSelected = node.resource.id === selectedId;
const isDeleting = node.resource.id === deletingId;
const [confirmingDelete, setConfirmingDelete] = useState(false);
const descriptor = getCollectionDescriptor(node);
const selectResource = () => {
if (!isDeleting) onSelect(node.resource);
};
return (
setConfirmingDelete(false)}
>
{!readOnly && node.kind !== "mcp-builtin" && (
{isDeleting ? (
Deleting…
) : (
{confirmingDelete ? "Click again to delete" : "Delete"}
)}
)}
);
}
// ─── TreeNodeRow ────────────────────────────────────────────────────────────
function TreeNodeRow({
node,
depth,
expanded,
selectedId,
deletingId,
readOnly,
onToggle,
onSelect,
onDelete,
onStartCreate,
}: {
node: TreeNode;
depth: number;
expanded: Set;
selectedId: string | null;
deletingId?: string | null;
readOnly?: boolean;
onToggle: (path: string) => void;
onSelect: (resource: ResourceMeta) => void;
onDelete: (id: string) => void;
onStartCreate: (parentPath: string, type: "file" | "folder") => void;
}) {
const isFolder = node.type === "folder";
const isExpanded = expanded.has(node.path);
const isSelected = node.resource?.id === selectedId;
const isDeleting = !!node.resource && node.resource.id === deletingId;
const [confirmingDelete, setConfirmingDelete] = useState(false);
return (
{
if (isDeleting) return;
if (isFolder) {
onToggle(node.path);
} else if (node.resource) {
onSelect(node.resource);
}
}}
onMouseLeave={() => setConfirmingDelete(false)}
>
{isFolder ? (
isExpanded ? (
) : (
)
) : (
)}
{isFolder ? (
) : (
getFileIcon(node)
)}
{node.name}
{node.jobMeta &&
}
{node.mcpServerMeta &&
}
{node.mcpBuiltinMeta &&
}
{!readOnly && (
{isFolder && (
New file
)}
{node.resource &&
node.kind !== "mcp-builtin" &&
(isDeleting ? (
Deleting…
) : (
{confirmingDelete ? "Click again to delete" : "Delete"}
))}
)}
{isFolder && isExpanded && node.children && (
{node.children.map((child) => (
))}
)}
);
}
// ─── InlineInput ────────────────────────────────────────────────────────────
function InlineInput({
depth,
onConfirm,
onCancel,
}: {
depth: number;
onConfirm: (name: string) => void;
onCancel: () => void;
}) {
const inputRef = useRef(null);
const [value, setValue] = useState("");
React.useEffect(() => {
inputRef.current?.focus();
}, []);
return (
setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) {
onConfirm(value.trim());
} else if (e.key === "Escape") {
onCancel();
}
}}
onBlur={() => {
if (value.trim()) {
onConfirm(value.trim());
} else {
onCancel();
}
}}
className="min-w-0 flex-1 bg-transparent text-[12px] leading-none text-foreground outline-none placeholder:text-muted-foreground/50"
placeholder="filename.md"
/>
);
}
// ─── ResourceTree ───────────────────────────────────────────────────────────
export function ResourceTree({
tree,
variant = "tree",
selectedId,
onSelect,
onCreateFile,
onCreateFolder,
onDelete,
onDrop,
title = "Files",
titleTooltip,
isLoading = false,
deletingId = null,
readOnly = false,
headingHint,
sectionAction,
emptyStateAction,
}: ResourceTreeProps) {
const [expanded, setExpanded] = useState>(() => new Set());
const [creating, setCreating] = useState(null);
const [dragOver, setDragOver] = useState(false);
const leafResources = variant === "collection" ? getLeafResources(tree) : [];
const isEmpty =
variant === "collection" ? leafResources.length === 0 : tree.length === 0;
const toggleExpand = useCallback((path: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleStartCreate = useCallback(
(parentPath: string, type: "file" | "folder") => {
setCreating({ parentPath, type });
// auto-expand the parent folder
setExpanded((prev) => {
const next = new Set(prev);
next.add(parentPath);
return next;
});
},
[],
);
const handleConfirmCreate = useCallback(
(name: string) => {
if (!creating) return;
if (creating.type === "file") {
onCreateFile(creating.parentPath, name);
} else {
onCreateFolder(creating.parentPath, name);
}
setCreating(null);
},
[creating, onCreateFile, onCreateFolder],
);
const handleCancelCreate = useCallback(() => {
setCreating(null);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
if (readOnly) return;
if (e.dataTransfer.files.length > 0) {
onDrop(e.dataTransfer.files);
}
},
[onDrop, readOnly],
);
return (
{/* Section heading */}
{title}
{headingHint && (
{headingHint}
)}
{titleTooltip && (
{titleTooltip}
)}
{sectionAction ??
(variant === "tree" && !readOnly && (
New file
))}
{variant === "collection"
? leafResources.length > 0 && (
{leafResources.map((node) => (
))}
)
: tree.map((node) => (
))}
{isLoading && isEmpty && (
{Array.from({ length: 3 }).map((_, i) => (
))}
)}
{/* Inline input for root-level creation */}
{creating && creating.parentPath === "" && (
)}
{/* Inline input for folder-level creation */}
{creating && creating.parentPath !== "" && (
)}
{isEmpty && !creating && !isLoading && (
{variant === "collection"
? "No resources in this collection yet"
: "No files yet"}
{emptyStateAction &&
{emptyStateAction}
}
)}
);
}