Files
kandev/apps/web/components/task/file-browser.tsx
T

205 lines
6.6 KiB
TypeScript

'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { IconChevronRight, IconChevronDown, IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react';
import { ScrollArea } from '@kandev/ui/scroll-area';
import { getWebSocketClient } from '@/lib/ws/connection';
import { requestFileTree, requestFileContent } from '@/lib/ws/workspace-files';
import type { FileTreeNode, FileContentResponse, OpenFileTab } from '@/lib/types/backend';
type FileBrowserProps = {
sessionId: string;
onOpenFile: (file: OpenFileTab) => void;
};
export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
const [isLoadingTree, setIsLoadingTree] = useState(true);
// Load initial tree - always try to load, don't wait for agentctl ready status
useEffect(() => {
setTree(null);
setExpandedPaths(new Set());
setLoadingPaths(new Set());
setIsLoadingTree(true);
const loadTree = async () => {
try {
const client = getWebSocketClient();
if (!client) return;
const response = await requestFileTree(client, sessionId, '', 1);
setTree(response.root);
} catch (error) {
console.error('Failed to load file tree:', error);
} finally {
setIsLoadingTree(false);
}
};
loadTree();
}, [sessionId]);
// Subscribe to file changes and refresh tree
useEffect(() => {
const client = getWebSocketClient();
if (!client) return;
const unsubscribe = client.on('session.workspace.file.changes', async () => {
// Refresh the root tree when any file changes
try {
const response = await requestFileTree(client, sessionId, '', 1);
setTree(response.root);
} catch (error) {
console.error('Failed to refresh file tree:', error);
}
});
return unsubscribe;
}, [sessionId]);
const toggleExpand = useCallback(async (node: FileTreeNode) => {
if (!node.is_dir) return;
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(node.path)) {
newExpanded.delete(node.path);
setExpandedPaths(newExpanded);
} else {
// If children not loaded yet, fetch them
if (!node.children || node.children.length === 0) {
setLoadingPaths((prev) => new Set(prev).add(node.path));
try {
const client = getWebSocketClient();
if (!client) return;
const response = await requestFileTree(client, sessionId, node.path, 1);
// Update tree with new children
const updateNode = (n: FileTreeNode): FileTreeNode => {
if (n.path === node.path) {
return { ...n, children: response.root.children };
}
if (n.children) {
return { ...n, children: n.children.map(updateNode) };
}
return n;
};
if (tree) {
setTree(updateNode(tree));
}
} catch (error) {
console.error('Failed to load children:', error);
} finally {
setLoadingPaths((prev) => {
const next = new Set(prev);
next.delete(node.path);
return next;
});
}
}
newExpanded.add(node.path);
setExpandedPaths(newExpanded);
}
}, [expandedPaths, sessionId, tree]);
const openFile = useCallback(async (node: FileTreeNode) => {
if (node.is_dir) return;
try {
const client = getWebSocketClient();
if (!client) return;
const response: FileContentResponse = await requestFileContent(client, sessionId, node.path);
// Calculate hash for the file content
const { calculateHash } = await import('@/lib/utils/file-diff');
const hash = await calculateHash(response.content);
onOpenFile({
path: node.path,
name: node.name,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
});
} catch (error) {
console.error('Failed to load file content:', error);
}
}, [sessionId, onOpenFile]);
const renderTreeNode = (node: FileTreeNode, depth: number = 0): React.ReactNode => {
const isExpanded = expandedPaths.has(node.path);
const isLoading = loadingPaths.has(node.path);
return (
<div key={node.path}>
<div
className="flex items-center gap-1 py-1 px-2 hover:bg-accent cursor-pointer rounded text-sm"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => (node.is_dir ? toggleExpand(node) : openFile(node))}
>
{node.is_dir && (
<span className="flex-shrink-0">
{isLoading ? (
<span className="animate-spin">⏳</span>
) : isExpanded ? (
<IconChevronDown className="h-4 w-4" />
) : (
<IconChevronRight className="h-4 w-4" />
)}
</span>
)}
{node.is_dir ? (
isExpanded ? (
<IconFolderOpen className="h-3 w-3 flex-shrink-0 text-blue-500" />
) : (
<IconFolder className="h-3 w-3 flex-shrink-0 text-blue-500" />
)
) : (
<IconFile className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
)}
<span className="truncate">{node.name}</span>
{!node.is_dir && node.size && (
<span className="text-xs text-muted-foreground ml-auto">
{(node.size / 1024).toFixed(1)}KB
</span>
)}
</div>
{node.is_dir && isExpanded && node.children && (
<div>
{[...node.children]
.sort((a, b) => {
// Folders first, then alphabetically
if (a.is_dir && !b.is_dir) return -1;
if (!a.is_dir && b.is_dir) return 1;
return a.name.localeCompare(b.name);
})
.map((child) => renderTreeNode(child, depth + 1))}
</div>
)}
</div>
);
};
return (
<div className="flex flex-col h-full">
<ScrollArea className="flex-1">
{isLoadingTree ? (
<div className="p-4 text-sm text-muted-foreground">Loading files...</div>
) : tree ? (
<div className="pb-2">{renderTreeNode(tree)}</div>
) : (
<div className="p-4 text-sm text-muted-foreground">No files found</div>
)}
</ScrollArea>
</div>
);
}