refactor: Componentize MCP Resource Browser

This commit is contained in:
Aleksander Grygier 2026-02-03 16:35:21 +01:00
parent 983b279724
commit fcae4b3a1b
8 changed files with 433 additions and 388 deletions

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);
});
}

View File

@ -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