refactor: Componentize MCP Resource Browser
This commit is contained in:
parent
983b279724
commit
fcae4b3a1b
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import type { DatabaseMessageExtraMcpPrompt, MCPServerSettingsEntry } from '$lib/types';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { McpPromptVariant } from '$lib/enums';
|
||||
|
|
|
|||
|
|
@ -1,386 +0,0 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Database,
|
||||
File,
|
||||
Image,
|
||||
Code
|
||||
} from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
|
||||
import { getFaviconUrl } from '$lib/utils';
|
||||
import type { MCPResource, MCPResourceInfo } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
selectedUris?: Set<string>;
|
||||
expandToUri?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
onToggle,
|
||||
onAttach,
|
||||
selectedUris = new Set(),
|
||||
expandToUri,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let expandedServers = new SvelteSet<string>();
|
||||
let expandedFolders = new SvelteSet<string>();
|
||||
let lastExpandedUri = $state<string | undefined>(undefined);
|
||||
|
||||
const resources = $derived(mcpResources());
|
||||
const isLoading = $derived(mcpResourcesLoading());
|
||||
|
||||
$effect(() => {
|
||||
if (expandToUri && resources.size > 0 && expandToUri !== lastExpandedUri) {
|
||||
autoExpandToResource(expandToUri);
|
||||
lastExpandedUri = expandToUri;
|
||||
}
|
||||
});
|
||||
|
||||
function autoExpandToResource(uri: string) {
|
||||
for (const [serverName, serverRes] of resources.entries()) {
|
||||
const resource = serverRes.resources.find((r) => r.uri === uri);
|
||||
if (resource) {
|
||||
expandedServers.add(serverName);
|
||||
|
||||
const pathParts = parseResourcePath(uri);
|
||||
if (pathParts.length > 1) {
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
currentPath = `${currentPath}/${pathParts[i]}`;
|
||||
const folderId = `${serverName}:${currentPath}`;
|
||||
expandedFolders.add(folderId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleServer(serverName: string) {
|
||||
if (expandedServers.has(serverName)) {
|
||||
expandedServers.delete(serverName);
|
||||
} else {
|
||||
expandedServers.add(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(folderId: string) {
|
||||
if (expandedFolders.has(folderId)) {
|
||||
expandedFolders.delete(folderId);
|
||||
} else {
|
||||
expandedFolders.add(folderId);
|
||||
}
|
||||
}
|
||||
|
||||
interface ResourceTreeNode {
|
||||
name: string;
|
||||
resource?: MCPResourceInfo;
|
||||
children: Map<string, ResourceTreeNode>;
|
||||
}
|
||||
|
||||
function parseResourcePath(uri: string): string[] {
|
||||
// Parse URI like "svelte://cli/overview.md" -> ["cli", "overview.md"]
|
||||
try {
|
||||
// Remove protocol (e.g., "svelte://")
|
||||
const withoutProtocol = uri.replace(/^[a-z]+:\/\//, '');
|
||||
// Split by / and filter empty parts
|
||||
return withoutProtocol.split('/').filter((p) => p.length > 0);
|
||||
} catch {
|
||||
return [uri];
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayName(pathPart: string): string {
|
||||
// Convert filename to display name: "overview.md" -> "Overview"
|
||||
const withoutExt = pathPart.replace(/\.[^.]+$/, '');
|
||||
// Convert kebab-case or snake_case to Title Case
|
||||
return withoutExt
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildResourceTree(resourceList: MCPResource[], serverName: string): ResourceTreeNode {
|
||||
const root: ResourceTreeNode = { name: 'root', children: new Map() };
|
||||
|
||||
for (const resource of resourceList) {
|
||||
const pathParts = parseResourcePath(resource.uri);
|
||||
let current = root;
|
||||
|
||||
// Navigate/create folders for all but the last part
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, { name: part, children: new Map() });
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
||||
// Add the resource at the leaf
|
||||
const fileName = pathParts[pathParts.length - 1] || resource.name;
|
||||
current.children.set(resource.uri, {
|
||||
name: fileName,
|
||||
resource: { ...resource, serverName },
|
||||
children: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function countTreeResources(node: ResourceTreeNode): number {
|
||||
if (node.resource) return 1;
|
||||
let count = 0;
|
||||
for (const child of node.children.values()) {
|
||||
count += countTreeResources(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
mcpStore.fetchAllResources();
|
||||
}
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
|
||||
onSelect?.(resource, event.shiftKey);
|
||||
}
|
||||
|
||||
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
|
||||
onToggle?.(resource, checked);
|
||||
}
|
||||
|
||||
function handleAttachClick(e: Event, resource: MCPResourceInfo) {
|
||||
e.stopPropagation();
|
||||
onAttach?.(resource);
|
||||
}
|
||||
|
||||
function getResourceIcon(resource: MCPResourceInfo) {
|
||||
const mimeType = resource.mimeType?.toLowerCase() || '';
|
||||
const uri = resource.uri.toLowerCase();
|
||||
|
||||
if (mimeType.startsWith('image/') || /\.(png|jpg|jpeg|gif|svg|webp)$/.test(uri)) {
|
||||
return Image;
|
||||
}
|
||||
if (
|
||||
mimeType.includes('json') ||
|
||||
mimeType.includes('javascript') ||
|
||||
mimeType.includes('typescript') ||
|
||||
/\.(js|ts|json|yaml|yml|xml|html|css)$/.test(uri)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
if (mimeType.includes('text') || /\.(txt|md|log)$/.test(uri)) {
|
||||
return FileText;
|
||||
}
|
||||
if (uri.includes('database') || uri.includes('db://')) {
|
||||
return Database;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
|
||||
function isResourceSelected(resource: MCPResourceInfo): boolean {
|
||||
return selectedUris.has(resource.uri);
|
||||
}
|
||||
|
||||
function getServerDisplayName(serverId: string): string {
|
||||
const server = mcpStore.getServerById(serverId);
|
||||
return server ? mcpStore.getServerLabel(server) : serverId;
|
||||
}
|
||||
|
||||
function getServerFavicon(serverId: string): string | null {
|
||||
const server = mcpStore.getServerById(serverId);
|
||||
return server ? getFaviconUrl(server.url) : null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderTreeNode(
|
||||
node: ResourceTreeNode,
|
||||
serverName: string,
|
||||
depth: number,
|
||||
parentPath: string
|
||||
)}
|
||||
{@const isFolder = !node.resource && node.children.size > 0}
|
||||
{@const folderId = `${serverName}:${parentPath}/${node.name}`}
|
||||
{@const isFolderExpanded = expandedFolders.has(folderId)}
|
||||
|
||||
{#if isFolder}
|
||||
{@const folderCount = countTreeResources(node)}
|
||||
<Collapsible.Root open={isFolderExpanded} onOpenChange={() => toggleFolder(folderId)}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isFolderExpanded}
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
{/if}
|
||||
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="font-medium">{node.name}</span>
|
||||
<span class="text-xs text-muted-foreground">({folderCount})</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#each [...node.children.values()].sort((a, b) => {
|
||||
// Folders first, then files
|
||||
const aIsFolder = !a.resource && a.children.size > 0;
|
||||
const bIsFolder = !b.resource && b.children.size > 0;
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)}
|
||||
{@render renderTreeNode(child, serverName, depth + 1, `${parentPath}/${node.name}`)}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{:else if node.resource}
|
||||
{@const resource = node.resource}
|
||||
{@const ResourceIcon = getResourceIcon(resource)}
|
||||
{@const isSelected = isResourceSelected(resource)}
|
||||
{@const displayName = resource.title || getDisplayName(node.name)}
|
||||
<div class="group flex w-full items-center gap-2">
|
||||
{#if onToggle}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked: boolean | 'indeterminate') =>
|
||||
handleCheckboxChange(resource, checked === true)}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class={cn(
|
||||
'flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
|
||||
'hover:bg-muted/50',
|
||||
isSelected && 'bg-muted'
|
||||
)}
|
||||
onclick={(e: MouseEvent) => handleResourceClick(resource, e)}
|
||||
title={displayName}
|
||||
>
|
||||
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-left">
|
||||
{displayName}
|
||||
</span>
|
||||
{#if onAttach}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 px-1.5 text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100"
|
||||
onclick={(e: MouseEvent) => handleAttachClick(e, resource)}
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class={cn('flex flex-col gap-2', className)}>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">Available resources</h3>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
title="Refresh resources"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if resources.size === 0}
|
||||
<div class="py-4 text-center text-sm text-muted-foreground">
|
||||
{#if isLoading}
|
||||
Loading resources...
|
||||
{:else}
|
||||
No resources available
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#each [...resources.entries()] as [serverName, serverRes] (serverName)}
|
||||
{@const isExpanded = expandedServers.has(serverName)}
|
||||
{@const hasResources = serverRes.resources.length > 0}
|
||||
{@const displayName = getServerDisplayName(serverName)}
|
||||
{@const favicon = getServerFavicon(serverName)}
|
||||
{@const resourceTree = buildResourceTree(serverRes.resources, serverName)}
|
||||
<Collapsible.Root open={isExpanded} onOpenChange={() => toggleServer(serverName)}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<span class="font-medium">{displayName}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({serverRes.resources.length})
|
||||
</span>
|
||||
{#if serverRes.loading}
|
||||
<Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#if serverRes.error}
|
||||
<div class="py-1 text-xs text-red-500">
|
||||
Error: {serverRes.error}
|
||||
</div>
|
||||
{:else if !hasResources}
|
||||
<div class="py-1 text-xs text-muted-foreground">No resources</div>
|
||||
{:else}
|
||||
{#each [...resourceTree.children.values()].sort((a, b) => {
|
||||
const aIsFolder = !a.resource && a.children.size > 0;
|
||||
const bIsFolder = !b.resource && b.children.size > 0;
|
||||
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
}) as child (child.resource?.uri || `${serverName}:${child.name}`)}
|
||||
{@render renderTreeNode(child, serverName, 1, '')}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
|
||||
import type { MCPResourceInfo } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { parseResourcePath } from './mcp-resource-browser';
|
||||
import McpResourceBrowserHeader from './McpResourceBrowserHeader.svelte';
|
||||
import McpResourceBrowserEmptyState from './McpResourceBrowserEmptyState.svelte';
|
||||
import McpResourceBrowserServerItem from './McpResourceBrowserServerItem.svelte';
|
||||
|
||||
interface Props {
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
selectedUris?: Set<string>;
|
||||
expandToUri?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
onToggle,
|
||||
onAttach,
|
||||
selectedUris = new Set(),
|
||||
expandToUri,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let expandedServers = new SvelteSet<string>();
|
||||
let expandedFolders = new SvelteSet<string>();
|
||||
let lastExpandedUri = $state<string | undefined>(undefined);
|
||||
|
||||
const resources = $derived(mcpResources());
|
||||
const isLoading = $derived(mcpResourcesLoading());
|
||||
|
||||
$effect(() => {
|
||||
if (expandToUri && resources.size > 0 && expandToUri !== lastExpandedUri) {
|
||||
autoExpandToResource(expandToUri);
|
||||
lastExpandedUri = expandToUri;
|
||||
}
|
||||
});
|
||||
|
||||
function autoExpandToResource(uri: string) {
|
||||
for (const [serverName, serverRes] of resources.entries()) {
|
||||
const resource = serverRes.resources.find((r) => r.uri === uri);
|
||||
if (resource) {
|
||||
expandedServers.add(serverName);
|
||||
|
||||
const pathParts = parseResourcePath(uri);
|
||||
if (pathParts.length > 1) {
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
currentPath = `${currentPath}/${pathParts[i]}`;
|
||||
const folderId = `${serverName}:${currentPath}`;
|
||||
expandedFolders.add(folderId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleServer(serverName: string) {
|
||||
if (expandedServers.has(serverName)) {
|
||||
expandedServers.delete(serverName);
|
||||
} else {
|
||||
expandedServers.add(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(folderId: string) {
|
||||
if (expandedFolders.has(folderId)) {
|
||||
expandedFolders.delete(folderId);
|
||||
} else {
|
||||
expandedFolders.add(folderId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
mcpStore.fetchAllResources();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-2', className)}>
|
||||
<McpResourceBrowserHeader {isLoading} onRefresh={handleRefresh} />
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if resources.size === 0}
|
||||
<McpResourceBrowserEmptyState {isLoading} />
|
||||
{:else}
|
||||
{#each [...resources.entries()] as [serverName, serverRes] (serverName)}
|
||||
<McpResourceBrowserServerItem
|
||||
{serverName}
|
||||
{serverRes}
|
||||
isExpanded={expandedServers.has(serverName)}
|
||||
{selectedUris}
|
||||
{expandedFolders}
|
||||
onToggleServer={() => toggleServer(serverName)}
|
||||
onToggleFolder={toggleFolder}
|
||||
{onSelect}
|
||||
{onToggle}
|
||||
{onAttach}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
let { isLoading }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="py-4 text-center text-sm text-muted-foreground">
|
||||
{#if isLoading}
|
||||
Loading resources...
|
||||
{:else}
|
||||
No resources available
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import { RefreshCw, Loader2 } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
let { isLoading, onRefresh }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">Available resources</h3>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={onRefresh}
|
||||
disabled={isLoading}
|
||||
title="Refresh resources"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
<script lang="ts">
|
||||
import { FolderOpen, ChevronDown, ChevronRight, Loader2 } from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { MCPResourceInfo, MCPServerResources } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import {
|
||||
type ResourceTreeNode,
|
||||
buildResourceTree,
|
||||
countTreeResources,
|
||||
getDisplayName,
|
||||
getResourceIcon,
|
||||
sortTreeChildren
|
||||
} from './mcp-resource-browser';
|
||||
|
||||
interface Props {
|
||||
serverName: string;
|
||||
serverRes: MCPServerResources;
|
||||
isExpanded: boolean;
|
||||
selectedUris: Set<string>;
|
||||
expandedFolders: SvelteSet<string>;
|
||||
onToggleServer: () => void;
|
||||
onToggleFolder: (folderId: string) => void;
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
serverName,
|
||||
serverRes,
|
||||
isExpanded,
|
||||
selectedUris,
|
||||
expandedFolders,
|
||||
onToggleServer,
|
||||
onToggleFolder,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onAttach
|
||||
}: Props = $props();
|
||||
|
||||
const hasResources = $derived(serverRes.resources.length > 0);
|
||||
const displayName = $derived(mcpStore.getServerDisplayName(serverName));
|
||||
const favicon = $derived(mcpStore.getServerFavicon(serverName));
|
||||
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName));
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
|
||||
onSelect?.(resource, event.shiftKey);
|
||||
}
|
||||
|
||||
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
|
||||
onToggle?.(resource, checked);
|
||||
}
|
||||
|
||||
function handleAttachClick(e: Event, resource: MCPResourceInfo) {
|
||||
e.stopPropagation();
|
||||
onAttach?.(resource);
|
||||
}
|
||||
|
||||
function isResourceSelected(resource: MCPResourceInfo): boolean {
|
||||
return selectedUris.has(resource.uri);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)}
|
||||
{@const isFolder = !node.resource && node.children.size > 0}
|
||||
{@const folderId = `${serverName}:${parentPath}/${node.name}`}
|
||||
{@const isFolderExpanded = expandedFolders.has(folderId)}
|
||||
|
||||
{#if isFolder}
|
||||
{@const folderCount = countTreeResources(node)}
|
||||
<Collapsible.Root open={isFolderExpanded} onOpenChange={() => onToggleFolder(folderId)}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isFolderExpanded}
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
{/if}
|
||||
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="font-medium">{node.name}</span>
|
||||
<span class="text-xs text-muted-foreground">({folderCount})</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)}
|
||||
{@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{:else if node.resource}
|
||||
{@const resource = node.resource}
|
||||
{@const ResourceIcon = getResourceIcon(resource)}
|
||||
{@const isSelected = isResourceSelected(resource)}
|
||||
{@const resourceDisplayName = resource.title || getDisplayName(node.name)}
|
||||
<div class="group flex w-full items-center gap-2">
|
||||
{#if onToggle}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked: boolean | 'indeterminate') =>
|
||||
handleCheckboxChange(resource, checked === true)}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class={cn(
|
||||
'flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
|
||||
'hover:bg-muted/50',
|
||||
isSelected && 'bg-muted'
|
||||
)}
|
||||
onclick={(e: MouseEvent) => handleResourceClick(resource, e)}
|
||||
title={resourceDisplayName}
|
||||
>
|
||||
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-left">
|
||||
{resourceDisplayName}
|
||||
</span>
|
||||
{#if onAttach}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 px-1.5 text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100"
|
||||
onclick={(e: MouseEvent) => handleAttachClick(e, resource)}
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Collapsible.Root open={isExpanded} onOpenChange={onToggleServer}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<span class="font-medium">{displayName}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({serverRes.resources.length})
|
||||
</span>
|
||||
{#if serverRes.loading}
|
||||
<Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#if serverRes.error}
|
||||
<div class="py-1 text-xs text-red-500">
|
||||
Error: {serverRes.error}
|
||||
</div>
|
||||
{:else if !hasResources}
|
||||
<div class="py-1 text-xs text-muted-foreground">No resources</div>
|
||||
{:else}
|
||||
{#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)}
|
||||
{@render renderTreeNode(child, 1, '')}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { Database, File, FileText, Image, Code } from '@lucide/svelte';
|
||||
import type { MCPResource, MCPResourceInfo } from '$lib/types';
|
||||
|
||||
export interface ResourceTreeNode {
|
||||
name: string;
|
||||
resource?: MCPResourceInfo;
|
||||
children: Map<string, ResourceTreeNode>;
|
||||
}
|
||||
|
||||
export function parseResourcePath(uri: string): string[] {
|
||||
try {
|
||||
const withoutProtocol = uri.replace(/^[a-z]+:\/\//, '');
|
||||
return withoutProtocol.split('/').filter((p) => p.length > 0);
|
||||
} catch {
|
||||
return [uri];
|
||||
}
|
||||
}
|
||||
|
||||
export function getDisplayName(pathPart: string): string {
|
||||
const withoutExt = pathPart.replace(/\.[^.]+$/, '');
|
||||
return withoutExt
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildResourceTree(
|
||||
resourceList: MCPResource[],
|
||||
serverName: string
|
||||
): ResourceTreeNode {
|
||||
const root: ResourceTreeNode = { name: 'root', children: new Map() };
|
||||
|
||||
for (const resource of resourceList) {
|
||||
const pathParts = parseResourcePath(resource.uri);
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, { name: part, children: new Map() });
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
||||
const fileName = pathParts[pathParts.length - 1] || resource.name;
|
||||
current.children.set(resource.uri, {
|
||||
name: fileName,
|
||||
resource: { ...resource, serverName },
|
||||
children: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
export function countTreeResources(node: ResourceTreeNode): number {
|
||||
if (node.resource) return 1;
|
||||
let count = 0;
|
||||
for (const child of node.children.values()) {
|
||||
count += countTreeResources(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function getResourceIcon(resource: MCPResourceInfo) {
|
||||
const mimeType = resource.mimeType?.toLowerCase() || '';
|
||||
const uri = resource.uri.toLowerCase();
|
||||
|
||||
if (mimeType.startsWith('image/') || /\.(png|jpg|jpeg|gif|svg|webp)$/.test(uri)) {
|
||||
return Image;
|
||||
}
|
||||
if (
|
||||
mimeType.includes('json') ||
|
||||
mimeType.includes('javascript') ||
|
||||
mimeType.includes('typescript') ||
|
||||
/\.(js|ts|json|yaml|yml|xml|html|css)$/.test(uri)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
if (mimeType.includes('text') || /\.(txt|md|log)$/.test(uri)) {
|
||||
return FileText;
|
||||
}
|
||||
if (uri.includes('database') || uri.includes('db://')) {
|
||||
return Database;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
|
||||
export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] {
|
||||
return children.sort((a, b) => {
|
||||
const aIsFolder = !a.resource && a.children.size > 0;
|
||||
const bIsFolder = !b.resource && b.children.size > 0;
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
|
@ -229,7 +229,7 @@ export { default as McpServerInfo } from './McpServerInfo.svelte';
|
|||
* - Refresh all resources action
|
||||
* - Loading states per server
|
||||
*/
|
||||
export { default as McpResourceBrowser } from './McpResourceBrowser.svelte';
|
||||
export { default as McpResourceBrowser } from './McpResourceBrowser/McpResourceBrowser.svelte';
|
||||
|
||||
/**
|
||||
* **McpResourcePreview** - MCP resource content preview
|
||||
|
|
|
|||
Loading…
Reference in New Issue