feat: Implement resource search in MCP Resource browser
This commit is contained in:
parent
e5cbb815aa
commit
5508ad0bee
|
|
@ -2,8 +2,8 @@
|
|||
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 type { MCPServerResources, MCPResourceInfo } from '$lib/types';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { parseResourcePath } from './mcp-resource-browser';
|
||||
import McpResourceBrowserHeader from './McpResourceBrowserHeader.svelte';
|
||||
import McpResourceBrowserEmptyState from './McpResourceBrowserEmptyState.svelte';
|
||||
|
|
@ -29,15 +29,42 @@
|
|||
|
||||
let expandedServers = new SvelteSet<string>();
|
||||
let expandedFolders = new SvelteSet<string>();
|
||||
let lastExpandedUri = $state<string | undefined>(undefined);
|
||||
let searchQuery = $state('');
|
||||
|
||||
const resources = $derived(mcpResources());
|
||||
const isLoading = $derived(mcpResourcesLoading());
|
||||
|
||||
const filteredResources = $derived.by(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return resources;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = new SvelteMap();
|
||||
|
||||
for (const [serverName, serverRes] of resources.entries()) {
|
||||
const filteredResources = serverRes.resources.filter((r) => {
|
||||
return (
|
||||
r.title?.toLowerCase().includes(query) ||
|
||||
r.uri.toLowerCase().includes(query) ||
|
||||
serverName.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
if (filteredResources.length > 0 || query.trim()) {
|
||||
filtered.set(serverName, {
|
||||
...serverRes,
|
||||
resources: filteredResources
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (expandToUri && resources.size > 0 && expandToUri !== lastExpandedUri) {
|
||||
if (expandToUri && resources.size > 0) {
|
||||
autoExpandToResource(expandToUri);
|
||||
lastExpandedUri = expandToUri;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -83,24 +110,24 @@
|
|||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-2', className)}>
|
||||
<McpResourceBrowserHeader {isLoading} onRefresh={handleRefresh} />
|
||||
<McpResourceBrowserHeader {isLoading} onRefresh={handleRefresh} onSearch={(q) => searchQuery = q} searchQuery={searchQuery} />
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if resources.size === 0}
|
||||
{#if filteredResources.size === 0}
|
||||
<McpResourceBrowserEmptyState {isLoading} />
|
||||
{:else}
|
||||
{#each [...resources.entries()] as [serverName, serverRes] (serverName)}
|
||||
{#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)}
|
||||
<McpResourceBrowserServerItem
|
||||
{serverName}
|
||||
{serverRes}
|
||||
isExpanded={expandedServers.has(serverName)}
|
||||
serverName={serverName as string}
|
||||
serverRes={serverRes as MCPServerResources}
|
||||
isExpanded={expandedServers.has(serverName as string)}
|
||||
{selectedUris}
|
||||
{expandedFolders}
|
||||
onToggleServer={() => toggleServer(serverName)}
|
||||
onToggleServer={() => toggleServer(serverName as string)}
|
||||
onToggleFolder={toggleFolder}
|
||||
{onSelect}
|
||||
{onToggle}
|
||||
{onAttach}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,42 @@
|
|||
<script lang="ts">
|
||||
import { RefreshCw, Loader2 } from '@lucide/svelte';
|
||||
import { RefreshCw, Loader2, Search } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import SearchInput from '../../forms/SearchInput.svelte';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
let { isLoading, onRefresh }: Props = $props();
|
||||
let { isLoading, onRefresh, onSearch, searchQuery = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">Available resources</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<SearchInput
|
||||
placeholder="Search resources..."
|
||||
value={searchQuery}
|
||||
onInput={(value) => onSearch?.(value)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
onclick={onRefresh}
|
||||
disabled={isLoading}
|
||||
title="Refresh resources"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-medium">Available resources</h3>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,13 +39,13 @@
|
|||
onToggleFolder,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onAttach
|
||||
searchQuery = ''
|
||||
}: 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));
|
||||
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
|
||||
onSelect?.(resource, event.shiftKey);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface ResourceTreeNode {
|
|||
name: string;
|
||||
resource?: MCPResourceInfo;
|
||||
children: Map<string, ResourceTreeNode>;
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
export function parseResourcePath(uri: string): string[] {
|
||||
|
|
@ -24,20 +25,56 @@ export function getDisplayName(pathPart: string): string {
|
|||
.join(' ');
|
||||
}
|
||||
|
||||
function resourceMatchesSearch(resource: MCPResource, query: string): boolean {
|
||||
return (
|
||||
resource.title?.toLowerCase().includes(query) ||
|
||||
resource.uri.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildResourceTree(
|
||||
resourceList: MCPResource[],
|
||||
serverName: string
|
||||
serverName: string,
|
||||
searchQuery?: string
|
||||
): ResourceTreeNode {
|
||||
const root: ResourceTreeNode = { name: 'root', children: new Map() };
|
||||
|
||||
if (!searchQuery || !searchQuery.trim()) {
|
||||
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;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
// Build tree with filtering
|
||||
for (const resource of resourceList) {
|
||||
if (!resourceMatchesSearch(resource, query)) continue;
|
||||
|
||||
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.children.set(part, { name: part, children: new Map(), isFiltered: true });
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
|
@ -46,10 +83,31 @@ export function buildResourceTree(
|
|||
current.children.set(resource.uri, {
|
||||
name: fileName,
|
||||
resource: { ...resource, serverName },
|
||||
children: new Map()
|
||||
children: new Map(),
|
||||
isFiltered: true
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Clean up empty folders that don't match
|
||||
function cleanupEmptyFolders(node: ResourceTreeNode): boolean {
|
||||
if (node.resource) return true;
|
||||
|
||||
const toDelete: string[] = [];
|
||||
for (const [name, child] of node.children.entries()) {
|
||||
if (!cleanupEmptyFolders(child)) {
|
||||
toDelete.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of toDelete) {
|
||||
node.children.delete(name);
|
||||
}
|
||||
|
||||
return node.children.size > 0;
|
||||
}
|
||||
|
||||
cleanupEmptyFolders(root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue