feat: Implement FILES panel foundation with lazy-loading file tree

- Add fileTreeRequest/fileTreeResponse socket messages
- Implement gadget-drone handler with security validation
- Add web backend message forwarding
- Create FileTree and FileTreeNode React components
- Update FilesPanel to contain file tree browser
- Add requestFileTree method to frontend socket client
- Exclude node_modules, .git, and hidden files by default
- Implement lazy loading with directory caching
- Add loading and error states per node
- Support keyboard navigation (Enter, Space)

Phase 1 of FILES panel implementation complete.
This commit is contained in:
Rob Colbert 2026-05-12 15:12:18 -04:00
parent b090b5308b
commit c05c7f5a61
9 changed files with 525 additions and 7 deletions

View File

@ -0,0 +1,175 @@
import { useState, useEffect, useCallback } from 'react';
import { socketClient } from '../lib/socket';
import { WorkspaceMode } from '../lib/types';
import FileTreeNode from './FileTreeNode';
export interface FileTreeEntry {
name: string;
path: string;
type: 'file' | 'directory' | 'symlink';
size?: number;
modified?: string;
isHidden?: boolean;
}
interface FileTreeProps {
workspaceMode: WorkspaceMode;
onFileSelect?: (path: string) => void;
}
interface FileTreeState {
directoryCache: Map<string, FileTreeEntry[]>;
expandedPaths: Set<string>;
loadingPaths: Set<string>;
errors: Map<string, string>;
}
export default function FileTree({ workspaceMode, onFileSelect }: FileTreeProps) {
const [state, setState] = useState<FileTreeState>({
directoryCache: new Map(),
expandedPaths: new Set(),
loadingPaths: new Set(),
errors: new Map(),
});
const [rootLoaded, setRootLoaded] = useState(false);
// Load root directory on mount
useEffect(() => {
if (!rootLoaded) {
loadDirectory('');
setRootLoaded(true);
}
}, []);
const loadDirectory = useCallback(async (path: string) => {
// Don't reload if already cached
if (state.directoryCache.has(path)) {
return;
}
// Don't reload if already loading
if (state.loadingPaths.has(path)) {
return;
}
setState(prev => ({
...prev,
loadingPaths: new Set(prev.loadingPaths).add(path),
errors: new Map(prev.errors).delete(path),
}));
try {
const result = await socketClient.requestFileTree({
path: path || undefined,
recursive: false,
maxDepth: 1,
});
if (result.success && result.entries) {
setState(prev => ({
...prev,
directoryCache: new Map(prev.directoryCache).set(path, result.entries!),
loadingPaths: new Set(prev.loadingPaths).delete(path),
}));
} else {
setState(prev => ({
...prev,
errors: new Map(prev.errors).set(path, result.error || 'Failed to load directory'),
loadingPaths: new Set(prev.loadingPaths).delete(path),
}));
}
} catch (error) {
setState(prev => ({
...prev,
errors: new Map(prev.errors).set(path, error instanceof Error ? error.message : 'Unknown error'),
loadingPaths: new Set(prev.loadingPaths).delete(path),
}));
}
}, [state.directoryCache, state.loadingPaths]);
const toggleExpand = useCallback((path: string) => {
setState(prev => {
const newExpanded = new Set(prev.expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
// Load children if not already cached
if (!prev.directoryCache.has(path)) {
setTimeout(() => loadDirectory(path), 0);
}
}
return { ...prev, expandedPaths: newExpanded };
});
}, [loadDirectory]);
const handleFileSelect = useCallback((path: string) => {
if (onFileSelect) {
onFileSelect(path);
}
}, [onFileSelect]);
// Build tree structure from flat entries
const buildTree = useCallback((entries: FileTreeEntry[], depth: number = 0) => {
return entries.map(entry => {
const isExpanded = state.expandedPaths.has(entry.path);
const isLoading = state.loadingPaths.has(entry.path);
const error = state.errors.get(entry.path);
const hasChildren = entry.type === 'directory';
const children = hasChildren && isExpanded && state.directoryCache.has(entry.path)
? buildTree(state.directoryCache.get(entry.path)!, depth + 1)
: null;
return (
<FileTreeNode
key={entry.path}
entry={entry}
depth={depth}
isExpanded={isExpanded}
isLoading={isLoading}
hasChildren={hasChildren}
error={error}
onToggle={toggleExpand}
onSelect={handleFileSelect}
/>
);
});
}, [state.expandedPaths, state.loadingPaths, state.errors, state.directoryCache, toggleExpand, handleFileSelect]);
const rootEntries = state.directoryCache.get('') || [];
const rootError = state.errors.get('');
const rootLoading = state.loadingPaths.has('');
return (
<div className="flex-1 overflow-y-auto p-2">
{rootLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-brand"></div>
</div>
)}
{rootError && (
<div className="p-4 text-center text-red-500 text-sm">
<p>{rootError}</p>
<button
onClick={() => loadDirectory('')}
className="mt-2 px-3 py-1 bg-brand text-white rounded text-xs hover:bg-brand/80"
>
Retry
</button>
</div>
)}
{!rootLoading && !rootError && rootEntries.length === 0 && (
<div className="p-4 text-center text-text-muted text-sm">
Empty directory
</div>
)}
<div className="space-y-0.5">
{buildTree(rootEntries)}
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
import { FileTreeEntry } from './FileTree';
interface FileTreeNodeProps {
entry: FileTreeEntry;
depth: number;
isExpanded: boolean;
isLoading: boolean;
hasChildren: boolean;
error?: string;
onToggle: (path: string) => void;
onSelect: (path: string) => void;
}
export default function FileTreeNode({
entry,
depth,
isExpanded,
isLoading,
hasChildren,
error,
onToggle,
onSelect,
}: FileTreeNodeProps) {
const handleClick = () => {
if (hasChildren) {
onToggle(entry.path);
} else {
onSelect(entry.path);
}
};
const getIcon = () => {
if (entry.type === 'directory') {
return isExpanded ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
);
} else if (entry.type === 'symlink') {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
);
} else {
// File icon
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
}
};
const getExpandIcon = () => {
if (!hasChildren) return null;
return isExpanded ? (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
};
return (
<div>
<div
className={`flex items-center gap-1 px-2 py-1 rounded cursor-pointer hover:bg-bg-tertiary transition-colors ${
error ? 'text-red-500' : 'text-text-primary'
}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
<span className="flex items-center justify-center w-4 h-4 text-text-muted">
{getExpandIcon()}
</span>
<span className={`flex items-center justify-center w-5 h-5 ${
entry.type === 'directory' ? 'text-blue-500' :
entry.type === 'symlink' ? 'text-purple-500' :
'text-text-muted'
}`}>
{getIcon()}
</span>
{isLoading && (
<span className="flex items-center justify-center w-4 h-4">
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-brand"></div>
</span>
)}
<span className={`text-sm truncate ${entry.isHidden ? 'text-text-muted italic' : ''}`}>
{entry.name}
</span>
</div>
{isExpanded && hasChildren && (
<div>
{/* Children will be rendered by parent FileTree component */}
</div>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
import { WorkspaceMode } from "../lib/types"; import { WorkspaceMode } from "../lib/types";
import FileTree from "./FileTree";
interface FilesPanelProps { interface FilesPanelProps {
workspaceMode: WorkspaceMode; workspaceMode: WorkspaceMode;
@ -9,7 +10,7 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) {
const isReadWrite = workspaceMode === WorkspaceMode.User; const isReadWrite = workspaceMode === WorkspaceMode.User;
return ( return (
<div className="border-t border-border-subtle"> <div className="border-t border-border-subtle flex flex-col" style={{ minHeight: '200px' }}>
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary"> <div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider"> <h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Files Files
@ -37,13 +38,21 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) {
</span> </span>
</div> </div>
</div> </div>
<div className="p-4 text-center text-text-muted text-sm"> <div className="flex-1 overflow-hidden">
<p>File browser coming soon</p> <FileTree
<p className="text-xs mt-1"> workspaceMode={workspaceMode}
onFileSelect={(path) => {
console.log('File selected:', path);
// TODO: Open file in editor (next session)
}}
/>
</div>
<div className="px-4 py-2 bg-bg-tertiary border-t border-border-subtle">
<p className="text-xs text-text-muted">
{isReadOnly {isReadOnly
? "Files are read-only while Agent is working" ? "Read-only: Agent is working"
: isReadWrite : isReadWrite
? "Files are read/write enabled" ? "Read-write mode enabled"
: "Select User or Agent mode to access files"} : "Select User or Agent mode to access files"}
</p> </p>
</div> </div>

View File

@ -63,6 +63,10 @@ export interface ClientToServerEvents {
cb: (success: boolean) => void, cb: (success: boolean) => void,
) => void; ) => void;
sessionHeartbeat: (cb: (ack: boolean) => void) => void; sessionHeartbeat: (cb: (ack: boolean) => void) => void;
fileTreeRequest: (
args: { path?: string; recursive?: boolean; maxDepth?: number },
cb: (success: boolean, data: { entries?: any[]; error?: string }) => void,
) => void;
} }
export interface SocketEvents { export interface SocketEvents {
@ -119,6 +123,7 @@ export interface SocketEvents {
workspaceModeChanged: (mode: string) => void; workspaceModeChanged: (mode: string) => void;
sessionUpdated: (updates: Partial<ChatSession>) => void; sessionUpdated: (updates: Partial<ChatSession>) => void;
tabLockDenied: (data: { message: string }) => void; tabLockDenied: (data: { message: string }) => void;
fileTreeResponse: (path: string, entries: any[], error?: string) => void;
status: (content: string) => void; status: (content: string) => void;
reconnect_attempt: (attempt: number) => void; reconnect_attempt: (attempt: number) => void;
reconnect_failed: () => void; reconnect_failed: () => void;
@ -215,6 +220,10 @@ class SocketClient {
this.emit("sessionUpdated", updates as Partial<ChatSession>); this.emit("sessionUpdated", updates as Partial<ChatSession>);
}); });
this.socket.on("fileTreeResponse", (path: string, entries: unknown[], error?: string) => {
this.emit("fileTreeResponse", path, entries as any[], error);
});
this.socket.on( this.socket.on(
"log", "log",
( (
@ -407,6 +416,23 @@ class SocketClient {
}); });
} }
requestFileTree(
args: { path?: string; recursive?: boolean; maxDepth?: number } = {},
): Promise<{ success: boolean; entries?: any[]; error?: string }> {
return new Promise((resolve) => {
if (this._socket?.connected) {
this._socket.emit(
"fileTreeRequest",
args,
(success: boolean, data: { entries?: any[]; error?: string }) =>
resolve({ success, ...data }),
);
} else {
resolve({ success: false, error: "Socket not connected" });
}
});
}
releaseSessionLock( releaseSessionLock(
registration: any, registration: any,
project: any, project: any,

View File

@ -8,6 +8,8 @@ import {
SocketSessionType, SocketSessionType,
} from "./socket-session.js"; } from "./socket-session.js";
import { import {
FileTreeEntry,
FileTreeRequestArgs,
GadgetComponent, GadgetComponent,
GadgetLogLevel, GadgetLogLevel,
IChatSession, IChatSession,
@ -59,6 +61,7 @@ export class CodeSession extends SocketSession {
this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this)); this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this));
this.socket.on("releaseSessionLock", this.onReleaseSessionLock.bind(this)); this.socket.on("releaseSessionLock", this.onReleaseSessionLock.bind(this));
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this)); this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
// Check for active session on connect // Check for active session on connect
this.checkAndReestablishActiveSession(); this.checkAndReestablishActiveSession();
@ -420,6 +423,34 @@ export class CodeSession extends SocketSession {
} }
} }
/**
* Called when the IDE sends a fileTreeRequest event to list directory contents.
* Forwards to the drone and relays response back to IDE.
*/
onFileTreeRequest(
args: FileTreeRequestArgs,
cb: (success: boolean, data: { entries?: FileTreeEntry[]; error?: string }) => void,
): void {
if (!this.selectedDrone) {
return cb(false, { error: "No drone selected" });
}
try {
const droneSession = SocketService.getDroneSession(this.selectedDrone);
droneSession.socket.emit("fileTreeRequest", args, (success, data) => {
// Forward response to IDE
if (success && data.entries) {
this.socket.emit("fileTreeResponse", args.path || "", data.entries);
} else {
this.socket.emit("fileTreeResponse", args.path || "", [], data.error);
}
cb(success, data);
});
} catch (error) {
this.log.error("failed to forward fileTreeRequest to drone", { error });
cb(false, { error: "Failed to reach drone" });
}
}
/** /**
* Called when the IDE sends a releaseSessionLock event to release a * Called when the IDE sends a releaseSessionLock event to release a
* previously-acquired session lock on a gadget-drone instance. * previously-acquired session lock on a gadget-drone instance.

View File

@ -4,6 +4,8 @@
import env from "./config/env.ts"; import env from "./config/env.ts";
import assert from "node:assert"; import assert from "node:assert";
import fs from "node:fs/promises";
import path from "node:path";
import { io, ManagerOptions, SocketOptions, Socket } from "socket.io-client"; import { io, ManagerOptions, SocketOptions, Socket } from "socket.io-client";
import { input as inqInput, password as inqPassword } from "@inquirer/prompts"; import { input as inqInput, password as inqPassword } from "@inquirer/prompts";
@ -22,6 +24,8 @@ import {
import { GadgetProcess } from "./lib/process.ts"; import { GadgetProcess } from "./lib/process.ts";
import { import {
ClientToServerEvents, ClientToServerEvents,
FileTreeEntry,
FileTreeRequestArgs,
IChatSession, IChatSession,
IChatTurn, IChatTurn,
IDroneRegistration, IDroneRegistration,
@ -256,6 +260,10 @@ class GadgetDrone extends GadgetProcess {
"requestTermination", "requestTermination",
this.onRequestTermination.bind(this), this.onRequestTermination.bind(this),
); );
this.socket.on(
"fileTreeRequest",
this.onFileTreeRequest.bind(this),
);
/* /*
* Handle socket disconnect: clear the heartbeat timer to prevent * Handle socket disconnect: clear the heartbeat timer to prevent
@ -708,6 +716,114 @@ class GadgetDrone extends GadgetProcess {
}); });
} }
async onFileTreeRequest(
args: FileTreeRequestArgs,
cb: (success: boolean, data: { entries?: FileTreeEntry[]; error?: string }) => void,
): Promise<void> {
if (!this.sessionLock) {
return cb(false, { error: "No session lock active" });
}
const projectRoot = WorkspaceService.getProjectDirectory(this.sessionLock.project.slug);
if (!projectRoot) {
return cb(false, { error: "Project directory not found" });
}
const targetPath = args.path
? path.resolve(projectRoot, args.path)
: projectRoot;
// Security: Ensure path is within project root
const normalizedTarget = path.normalize(targetPath);
const normalizedRoot = path.normalize(projectRoot);
if (!normalizedTarget.startsWith(normalizedRoot)) {
return cb(false, { error: "Access denied: path outside project root" });
}
try {
const stat = await fs.stat(targetPath);
if (!stat.isDirectory()) {
return cb(false, { error: "Path is not a directory" });
}
const entries = await this.listDirectoryForTree(
targetPath,
false, // showHidden
0,
args.maxDepth ?? 1,
projectRoot,
);
cb(true, { entries });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.log.error("failed to list directory for file tree", {
path: args.path,
error: errorMessage,
});
cb(false, { error: `Failed to list directory: ${errorMessage}` });
}
}
private async listDirectoryForTree(
dir: string,
showHidden: boolean,
depth: number,
maxDepth: number,
projectRoot: string,
): Promise<FileTreeEntry[]> {
const results: FileTreeEntry[] = [];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
// Skip hidden files unless explicitly requested
if (!showHidden && entry.name.startsWith(".")) continue;
// Always skip node_modules and .git
if (entry.name === "node_modules" || entry.name === ".git") continue;
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(projectRoot, fullPath);
let stat;
try {
stat = await fs.stat(fullPath);
} catch {
continue;
}
const fileTreeEntry: FileTreeEntry = {
name: entry.name,
path: relativePath,
type: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : "file",
size: stat.size,
modified: stat.mtime.toISOString(),
isHidden: entry.name.startsWith("."),
};
results.push(fileTreeEntry);
// Recurse if directory and within depth limit
if (entry.isDirectory() && depth < maxDepth) {
const subResults = await this.listDirectoryForTree(
fullPath,
showHidden,
depth + 1,
maxDepth,
projectRoot,
);
results.push(...subResults);
}
}
return results;
}
async onAbortWorkOrder(cb: (success: boolean, message?: string) => void): Promise<void> { async onAbortWorkOrder(cb: (success: boolean, message?: string) => void): Promise<void> {
this.log.info("abortWorkOrder received from platform", { this.log.info("abortWorkOrder received from platform", {
registrationId: this.registration?._id, registrationId: this.registration?._id,

View File

@ -98,3 +98,38 @@ export type AbortWorkOrderMessage = (cb: AbortWorkOrderCallback) => void;
export type SessionHeartbeatCallback = (ack: boolean) => void; export type SessionHeartbeatCallback = (ack: boolean) => void;
export type SessionHeartbeatMessage = (cb: SessionHeartbeatCallback) => void; export type SessionHeartbeatMessage = (cb: SessionHeartbeatCallback) => void;
/*
* fileTreeRequest / fileTreeResponse
*/
export interface FileTreeEntry {
name: string; // File/directory name
path: string; // Full relative path from project root
type: 'file' | 'directory' | 'symlink';
size?: number; // File size in bytes (directories: undefined)
modified?: string; // ISO 8601 timestamp
isHidden?: boolean; // Starts with '.'
}
export interface FileTreeRequestArgs {
path: string; // Relative path from project root (e.g., "src/components")
recursive?: boolean; // false for lazy loading (only immediate children)
maxDepth?: number; // Optional depth limit (default: 1 for lazy load)
}
export type FileTreeRequestCallback = (
success: boolean,
data: { entries?: FileTreeEntry[]; error?: string },
) => void;
export type FileTreeRequestMessage = (
args: FileTreeRequestArgs,
cb: FileTreeRequestCallback,
) => void;
export type FileTreeResponseMessage = (
path: string,
entries: FileTreeEntry[],
error?: string,
) => void;

View File

@ -23,6 +23,8 @@ import {
RequestWorkspaceModeMessage, RequestWorkspaceModeMessage,
SessionHeartbeatMessage, SessionHeartbeatMessage,
SubmitPromptMessage, SubmitPromptMessage,
FileTreeRequestMessage,
FileTreeResponseMessage,
} from "./ide.ts"; } from "./ide.ts";
/* /*
@ -54,6 +56,7 @@ export interface ClientToServerEvents {
abortWorkOrder: AbortWorkOrderMessage; abortWorkOrder: AbortWorkOrderMessage;
releaseSessionLock: ReleaseSessionLockMessage; releaseSessionLock: ReleaseSessionLockMessage;
sessionHeartbeat: SessionHeartbeatMessage; sessionHeartbeat: SessionHeartbeatMessage;
fileTreeRequest: FileTreeRequestMessage;
/* /*
* gadget-drone => gadget-code:web * gadget-drone => gadget-code:web
@ -131,6 +134,7 @@ export interface ServerToClientEvents {
workspaceModeChanged: WorkspaceModeChangedMessage; workspaceModeChanged: WorkspaceModeChangedMessage;
sessionUpdated: SessionUpdatedMessage; sessionUpdated: SessionUpdatedMessage;
tabLockDenied: (data: { message: string }) => void; tabLockDenied: (data: { message: string }) => void;
fileTreeResponse: FileTreeResponseMessage;
"agent:thinking": AgentThinkingMessage; "agent:thinking": AgentThinkingMessage;
"agent:response": AgentResponseMessage; "agent:response": AgentResponseMessage;
"agent:tool-call": AgentToolCallMessage; "agent:tool-call": AgentToolCallMessage;

View File

@ -1,4 +1,7 @@
packages: packages:
- 'packages/*' - 'packages/*'
- 'gadget-code' - 'gadget-code'
- 'gadget-drone' - 'gadget-drone'
allowBuilds:
esbuild: true
msgpackr-extract: true