Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 142 additions & 172 deletions src/components/EntityDetailPanel.tsx

Large diffs are not rendered by default.

32 changes: 28 additions & 4 deletions src/components/EntityTreeNode.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import { useShallow } from 'zustand/shallow';
import { ChevronRight, Loader2, Server, Folder, FileJson, Box } from 'lucide-react';
import { ChevronRight, Loader2, Server, Folder, FileJson, Box, MessageSquare, ArrowUp, ArrowDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useAppStore } from '@/lib/store';
import type { EntityTreeNode as EntityTreeNodeType } from '@/lib/types';
import type { EntityTreeNode as EntityTreeNodeType, TopicNodeData } from '@/lib/types';

interface EntityTreeNodeProps {
node: EntityTreeNodeType;
Expand All @@ -25,11 +25,20 @@ function getEntityIcon(type: string) {
case 'folder':
case 'area':
return Folder;
case 'topic':
return MessageSquare;
default:
return FileJson;
}
}

/**
* Check if node data is TopicNodeData (from topicsInfo)
*/
function isTopicNodeData(data: unknown): data is TopicNodeData {
return !!data && typeof data === 'object' && 'isPublisher' in data && 'isSubscriber' in data;
}

export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) {
const {
expandedPaths,
Expand All @@ -55,6 +64,9 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) {
const hasChildren = node.hasChildren !== false; // Default to true if not specified
const Icon = getEntityIcon(node.type);

// Get topic direction info if available
const topicData = isTopicNodeData(node.data) ? node.data : null;

// Load children when expanded and no children loaded yet
useEffect(() => {
if (isExpanded && !node.children && !isLoading && hasChildren) {
Expand Down Expand Up @@ -109,10 +121,22 @@ export function EntityTreeNode({ node, depth }: EntityTreeNodeProps) {
isSelected ? "text-primary" : "text-muted-foreground"
)} />

<span className="text-sm truncate">{node.name}</span>
<span className="text-sm truncate flex-1">{node.name}</span>

{/* Topic direction indicators */}
{topicData && (
<div className="flex items-center gap-0.5 mr-1" title={`${topicData.isPublisher ? 'Publishes' : ''}${topicData.isPublisher && topicData.isSubscriber ? ' & ' : ''}${topicData.isSubscriber ? 'Subscribes' : ''}`}>
{topicData.isPublisher && (
<ArrowUp className="w-3 h-3 text-green-500" />
)}
{topicData.isSubscriber && (
<ArrowDown className="w-3 h-3 text-blue-500" />
)}
</div>
)}

<span className={cn(
"text-xs ml-auto shrink-0",
"text-xs shrink-0",
isSelected ? "text-primary/70" : "text-muted-foreground"
)}>
{node.type}
Expand Down
79 changes: 77 additions & 2 deletions src/components/EntityTreeSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,45 @@
import { useState, useMemo } from 'react';
import { useShallow } from 'zustand/shallow';
import { Server, Settings, RefreshCw } from 'lucide-react';
import { Server, Settings, RefreshCw, Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EntityTreeNode } from '@/components/EntityTreeNode';
import { EmptyState } from '@/components/EmptyState';
import { useAppStore } from '@/lib/store';
import type { EntityTreeNode as EntityTreeNodeType } from '@/lib/types';

interface EntityTreeSidebarProps {
onSettingsClick: () => void;
}

/**
* Recursively filter tree nodes by search query
* Returns nodes that match or have matching descendants
*/
function filterTree(nodes: EntityTreeNodeType[], query: string): EntityTreeNodeType[] {
const lowerQuery = query.toLowerCase();
const result: EntityTreeNodeType[] = [];

for (const node of nodes) {
const nameMatches = node.name.toLowerCase().includes(lowerQuery);
const typeMatches = node.type.toLowerCase().includes(lowerQuery);
const filteredChildren = node.children ? filterTree(node.children, query) : undefined;

// Include node if it matches or has matching children
if (nameMatches || typeMatches || (filteredChildren && filteredChildren.length > 0)) {
result.push({
...node,
children: filteredChildren && filteredChildren.length > 0 ? filteredChildren : node.children,
});
}
}

return result;
}

export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) {
const [searchQuery, setSearchQuery] = useState('');

const {
isConnected,
serverUrl,
Expand All @@ -24,10 +54,21 @@ export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) {
}))
);

const filteredEntities = useMemo(() => {
if (!searchQuery.trim()) {
return rootEntities;
}
return filterTree(rootEntities, searchQuery.trim());
}, [rootEntities, searchQuery]);

const handleRefresh = () => {
loadRootEntities();
};

const handleClearSearch = () => {
setSearchQuery('');
};

return (
<aside className="w-80 border-r bg-card flex flex-col h-full">
{/* Header */}
Expand Down Expand Up @@ -82,13 +123,47 @@ export function EntityTreeSidebar({ onSettingsClick }: EntityTreeSidebarProps) {
</div>
)}

{/* Search bar */}
{isConnected && (
<div className="px-3 py-2 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search entities..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
)}

{/* Tree content */}
<div className="flex-1 overflow-y-auto p-2">
{!isConnected ? (
<EmptyState type="no-connection" />
) : filteredEntities.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground text-sm">
<Search className="w-8 h-8 mb-2 opacity-50" />
<p>No entities found</p>
{searchQuery && (
<p className="text-xs mt-1">Try a different search term</p>
)}
</div>
) : (
<div className="space-y-0.5">
{rootEntities.map((entity) => (
{filteredEntities.map((entity) => (
<EntityTreeNode key={entity.path} node={entity} depth={0} />
))}
</div>
Expand Down
Loading