diff --git a/gadget-code/frontend/src/components/FileTree.tsx b/gadget-code/frontend/src/components/FileTree.tsx new file mode 100644 index 0000000..c099291 --- /dev/null +++ b/gadget-code/frontend/src/components/FileTree.tsx @@ -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; + expandedPaths: Set; + loadingPaths: Set; + errors: Map; +} + +export default function FileTree({ workspaceMode, onFileSelect }: FileTreeProps) { + const [state, setState] = useState({ + 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 ( + + ); + }); + }, [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 ( +
+ {rootLoading && ( +
+
+
+ )} + + {rootError && ( +
+

{rootError}

+ +
+ )} + + {!rootLoading && !rootError && rootEntries.length === 0 && ( +
+ Empty directory +
+ )} + +
+ {buildTree(rootEntries)} +
+
+ ); +} diff --git a/gadget-code/frontend/src/components/FileTreeNode.tsx b/gadget-code/frontend/src/components/FileTreeNode.tsx new file mode 100644 index 0000000..60855c3 --- /dev/null +++ b/gadget-code/frontend/src/components/FileTreeNode.tsx @@ -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 ? ( + + + + ) : ( + + + + ); + } else if (entry.type === 'symlink') { + return ( + + + + ); + } else { + // File icon + return ( + + + + ); + } + }; + + const getExpandIcon = () => { + if (!hasChildren) return null; + return isExpanded ? ( + + + + ) : ( + + + + ); + }; + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} + > + + {getExpandIcon()} + + + + {getIcon()} + + + {isLoading && ( + +
+
+ )} + + + {entry.name} + +
+ + {isExpanded && hasChildren && ( +
+ {/* Children will be rendered by parent FileTree component */} +
+ )} +
+ ); +} diff --git a/gadget-code/frontend/src/components/FilesPanel.tsx b/gadget-code/frontend/src/components/FilesPanel.tsx index 63d1bce..8c46c2b 100644 --- a/gadget-code/frontend/src/components/FilesPanel.tsx +++ b/gadget-code/frontend/src/components/FilesPanel.tsx @@ -1,4 +1,5 @@ import { WorkspaceMode } from "../lib/types"; +import FileTree from "./FileTree"; interface FilesPanelProps { workspaceMode: WorkspaceMode; @@ -9,7 +10,7 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) { const isReadWrite = workspaceMode === WorkspaceMode.User; return ( -
+

Files @@ -37,13 +38,21 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) {

-
-

File browser coming soon

-

+

+ { + console.log('File selected:', path); + // TODO: Open file in editor (next session) + }} + /> +
+
+

{isReadOnly - ? "Files are read-only while Agent is working" + ? "Read-only: Agent is working" : isReadWrite - ? "Files are read/write enabled" + ? "Read-write mode enabled" : "Select User or Agent mode to access files"}

diff --git a/gadget-code/frontend/src/lib/socket.ts b/gadget-code/frontend/src/lib/socket.ts index 444b7d3..1c449e8 100644 --- a/gadget-code/frontend/src/lib/socket.ts +++ b/gadget-code/frontend/src/lib/socket.ts @@ -63,6 +63,10 @@ export interface ClientToServerEvents { cb: (success: 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 { @@ -119,6 +123,7 @@ export interface SocketEvents { workspaceModeChanged: (mode: string) => void; sessionUpdated: (updates: Partial) => void; tabLockDenied: (data: { message: string }) => void; + fileTreeResponse: (path: string, entries: any[], error?: string) => void; status: (content: string) => void; reconnect_attempt: (attempt: number) => void; reconnect_failed: () => void; @@ -215,6 +220,10 @@ class SocketClient { this.emit("sessionUpdated", updates as Partial); }); + this.socket.on("fileTreeResponse", (path: string, entries: unknown[], error?: string) => { + this.emit("fileTreeResponse", path, entries as any[], error); + }); + this.socket.on( "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( registration: any, project: any, diff --git a/gadget-code/src/lib/code-session.ts b/gadget-code/src/lib/code-session.ts index fc00921..e767722 100644 --- a/gadget-code/src/lib/code-session.ts +++ b/gadget-code/src/lib/code-session.ts @@ -8,6 +8,8 @@ import { SocketSessionType, } from "./socket-session.js"; import { + FileTreeEntry, + FileTreeRequestArgs, GadgetComponent, GadgetLogLevel, IChatSession, @@ -59,6 +61,7 @@ export class CodeSession extends SocketSession { this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this)); this.socket.on("releaseSessionLock", this.onReleaseSessionLock.bind(this)); this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this)); + this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this)); // Check for active session on connect 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 * previously-acquired session lock on a gadget-drone instance. diff --git a/gadget-drone/src/gadget-drone.ts b/gadget-drone/src/gadget-drone.ts index 9c59590..911a035 100644 --- a/gadget-drone/src/gadget-drone.ts +++ b/gadget-drone/src/gadget-drone.ts @@ -4,6 +4,8 @@ import env from "./config/env.ts"; 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 { input as inqInput, password as inqPassword } from "@inquirer/prompts"; @@ -22,6 +24,8 @@ import { import { GadgetProcess } from "./lib/process.ts"; import { ClientToServerEvents, + FileTreeEntry, + FileTreeRequestArgs, IChatSession, IChatTurn, IDroneRegistration, @@ -256,6 +260,10 @@ class GadgetDrone extends GadgetProcess { "requestTermination", this.onRequestTermination.bind(this), ); + this.socket.on( + "fileTreeRequest", + this.onFileTreeRequest.bind(this), + ); /* * 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 { + 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 { + 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 { this.log.info("abortWorkOrder received from platform", { registrationId: this.registration?._id, diff --git a/packages/api/src/messages/ide.ts b/packages/api/src/messages/ide.ts index 07abe80..e03e274 100644 --- a/packages/api/src/messages/ide.ts +++ b/packages/api/src/messages/ide.ts @@ -98,3 +98,38 @@ export type AbortWorkOrderMessage = (cb: AbortWorkOrderCallback) => void; export type SessionHeartbeatCallback = (ack: boolean) => 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; diff --git a/packages/api/src/messages/socket.ts b/packages/api/src/messages/socket.ts index ab45a90..515d78a 100644 --- a/packages/api/src/messages/socket.ts +++ b/packages/api/src/messages/socket.ts @@ -23,6 +23,8 @@ import { RequestWorkspaceModeMessage, SessionHeartbeatMessage, SubmitPromptMessage, + FileTreeRequestMessage, + FileTreeResponseMessage, } from "./ide.ts"; /* @@ -54,6 +56,7 @@ export interface ClientToServerEvents { abortWorkOrder: AbortWorkOrderMessage; releaseSessionLock: ReleaseSessionLockMessage; sessionHeartbeat: SessionHeartbeatMessage; + fileTreeRequest: FileTreeRequestMessage; /* * gadget-drone => gadget-code:web @@ -131,6 +134,7 @@ export interface ServerToClientEvents { workspaceModeChanged: WorkspaceModeChangedMessage; sessionUpdated: SessionUpdatedMessage; tabLockDenied: (data: { message: string }) => void; + fileTreeResponse: FileTreeResponseMessage; "agent:thinking": AgentThinkingMessage; "agent:response": AgentResponseMessage; "agent:tool-call": AgentToolCallMessage; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fda4af6..1319e78 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,7 @@ packages: - 'packages/*' - 'gadget-code' - - 'gadget-drone' \ No newline at end of file + - 'gadget-drone' +allowBuilds: + esbuild: true + msgpackr-extract: true