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:
parent
b090b5308b
commit
c05c7f5a61
175
gadget-code/frontend/src/components/FileTree.tsx
Normal file
175
gadget-code/frontend/src/components/FileTree.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
gadget-code/frontend/src/components/FileTreeNode.tsx
Normal file
119
gadget-code/frontend/src/components/FileTreeNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -2,3 +2,6 @@ packages:
|
|||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
- 'gadget-code'
|
- 'gadget-code'
|
||||||
- 'gadget-drone'
|
- 'gadget-drone'
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
msgpackr-extract: true
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user