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 FileTree from "./FileTree";
|
||||
|
||||
interface FilesPanelProps {
|
||||
workspaceMode: WorkspaceMode;
|
||||
@ -9,7 +10,7 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) {
|
||||
const isReadWrite = workspaceMode === WorkspaceMode.User;
|
||||
|
||||
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">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Files
|
||||
@ -37,13 +38,21 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 text-center text-text-muted text-sm">
|
||||
<p>File browser coming soon</p>
|
||||
<p className="text-xs mt-1">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FileTree
|
||||
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
|
||||
? "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"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -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<ChatSession>) => 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<ChatSession>);
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<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> {
|
||||
this.log.info("abortWorkOrder received from platform", {
|
||||
registrationId: this.registration?._id,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
- 'gadget-code'
|
||||
- 'gadget-drone'
|
||||
- 'gadget-drone'
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
msgpackr-extract: true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user