import React, { useState, useEffect, useRef, useMemo } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Resource, ReadResourceResult, } from "@modelcontextprotocol/sdk/types.js"; import { useSelectableList } from "../hooks/useSelectableList.js"; interface ResourceTemplate { name: string; uriTemplate: string; description?: string; } interface ResourcesTabProps { resources: Resource[]; resourceTemplates?: ResourceTemplate[]; inspectorClient: InspectorClient | null; width: number; height: number; onCountChange?: (count: number) => void; focusedPane?: "list" | "details" | null; onViewDetails?: ( resource: Resource | { content: ReadResourceResult }, ) => void; onFetchResource?: (resource: Resource) => void; onFetchTemplate?: (template: ResourceTemplate) => void; onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; modalOpen?: boolean; } export function ResourcesTab({ resources, resourceTemplates = [], inspectorClient, width, height, onCountChange, focusedPane = null, onViewDetails, onFetchResource, onFetchTemplate, onAuthRecoveryRequired, modalOpen = false, }: ResourcesTabProps) { const [error, setError] = useState(null); const [resourceContent, setResourceContent] = useState(null); const [loading, setLoading] = useState(false); const [shouldFetchResource, setShouldFetchResource] = useState( null, ); const scrollViewRef = useRef(null); // Combined list: resources first, then templates - memoized to prevent unnecessary recalculations const allItems = useMemo( () => [ ...resources.map((r) => ({ type: "resource" as const, data: r })), ...resourceTemplates.map((t) => ({ type: "template" as const, data: t })), ], [resources, resourceTemplates], ); const totalCount = useMemo( () => resources.length + resourceTemplates.length, [resources.length, resourceTemplates.length], ); const visibleCount = Math.max(1, height - 7); const { selectedIndex, firstVisible, setSelection } = useSelectableList( totalCount, visibleCount, { resetWhen: resources }, ); const selectedItem = useMemo( () => allItems[selectedIndex] || null, [allItems, selectedIndex], ); // Handle arrow key navigation when focused useInput( (input: string, key: Key) => { // Handle Enter key to fetch resource (works from both list and details) if ( key.return && selectedItem && inspectorClient && (onFetchResource || onFetchTemplate) ) { if (selectedItem.type === "resource" && selectedItem.data.uri) { // Trigger fetch for regular resource setShouldFetchResource(selectedItem.data.uri); if (onFetchResource) { onFetchResource(selectedItem.data); } } else if (selectedItem.type === "template" && onFetchTemplate) { // Open modal for template onFetchTemplate(selectedItem.data); } return; } if (focusedPane === "list") { if (key.upArrow && selectedIndex > 0) { setSelection(selectedIndex - 1); } else if (key.downArrow && selectedIndex < totalCount - 1) { setSelection(selectedIndex + 1); } return; } if (focusedPane === "details") { // Handle '+' key to view in full screen modal if (input === "+" && resourceContent && onViewDetails) { onViewDetails({ content: resourceContent }); return; } // Scroll the details pane using ink-scroll-view if (key.upArrow) { scrollViewRef.current?.scrollBy(-1); } else if (key.downArrow) { scrollViewRef.current?.scrollBy(1); } else if (key.pageUp) { const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1; scrollViewRef.current?.scrollBy(-viewportHeight); } else if (key.pageDown) { const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1; scrollViewRef.current?.scrollBy(viewportHeight); } } }, { isActive: !modalOpen && (focusedPane === "list" || focusedPane === "details"), }, ); // Reset scroll when selection changes useEffect(() => { scrollViewRef.current?.scrollTo(0); }, [selectedIndex]); // Clear fetched content when resources change const prevResourcesRef = useRef(resources); useEffect(() => { if (prevResourcesRef.current !== resources) { setResourceContent(null); setShouldFetchResource(null); prevResourcesRef.current = resources; } }, [resources]); const isResource = selectedItem?.type === "resource"; const isTemplate = selectedItem?.type === "template"; const selectedResource = isResource ? selectedItem.data : null; const selectedTemplate = isTemplate ? selectedItem.data : null; // Fetch resource content when shouldFetchResource is set useEffect(() => { if (!shouldFetchResource || !inspectorClient) return; const fetchContent = async () => { setLoading(true); setError(null); try { const invocation = await inspectorClient.readResource(shouldFetchResource); setResourceContent(invocation.result); } catch (err) { if (err instanceof AuthRecoveryRequiredError) { onAuthRecoveryRequired?.(err); return; } setError( err instanceof Error ? err.message : "Failed to read resource", ); setResourceContent(null); } finally { setLoading(false); setShouldFetchResource(null); } }; fetchContent(); }, [shouldFetchResource, inspectorClient, onAuthRecoveryRequired]); const listWidth = Math.floor(width * 0.4); const detailWidth = width - listWidth; // Update count when items change - use ref to track previous count and only call when it actually changes const prevCountRef = useRef(totalCount); useEffect(() => { if (prevCountRef.current !== totalCount) { prevCountRef.current = totalCount; onCountChange?.(totalCount); } }, [totalCount, onCountChange]); return ( {/* Resources and Templates List */} Resources ({totalCount}) {error ? ( {error} ) : totalCount === 0 ? ( No resources available ) : ( {allItems .slice(firstVisible, firstVisible + visibleCount) .map((item, i) => { const index = firstVisible + i; const isSelected = index === selectedIndex; const label = item.type === "resource" ? item.data.name || item.data.uri || `Resource ${index + 1}` : item.data.name || `Template ${index - resources.length + 1}`; const key = item.type === "resource" ? item.data.uri || index : item.data.uriTemplate || index; return ( {isSelected ? "▶ " : " "} {label} ); })} )} {/* Resource Details */} {selectedResource ? ( <> {/* Fixed header */} {selectedResource.name || selectedResource.uri} {/* Scrollable content area */} {/* Description */} {selectedResource.description && ( <> {selectedResource.description .split("\n") .map((line: string, idx: number) => ( {line} ))} )} {/* URI */} {selectedResource.uri && ( URI: {selectedResource.uri} )} {/* MIME Type */} {selectedResource.mimeType && ( MIME Type: {selectedResource.mimeType} )} {/* Resource Content */} {loading && ( Loading resource content... )} {!loading && resourceContent && ( <> Content: {JSON.stringify(resourceContent, null, 2)} )} {!loading && !resourceContent && selectedResource.uri && ( [Enter to Fetch Resource] )} {/* Fixed footer - only show when details pane is focused */} {focusedPane === "details" && ( {resourceContent ? "↑/↓ to scroll, + to zoom" : "Enter to fetch, ↑/↓ to scroll"} )} ) : selectedTemplate ? ( <> {/* Fixed header */} {selectedTemplate.name} {/* Scrollable content area */} {/* Description */} {selectedTemplate.description && ( <> {selectedTemplate.description .split("\n") .map((line: string, idx: number) => ( {line} ))} )} {/* URI Template */} {selectedTemplate.uriTemplate && ( URI Template: {selectedTemplate.uriTemplate} )} [Enter to Fetch Resource] {/* Fixed footer - only show when details pane is focused */} {focusedPane === "details" && ( Enter to fetch )} ) : ( Select a resource or template to view details )} ); }