Phase 2: ACE Editor integration and file operations
- Added react-ace and ace-builds dependencies - Created EditorPanel component with ACE editor integration - Implemented file read/write socket protocol - Added backend handlers for fileReadRequest and fileWriteRequest - Implemented file loading from tree click - Implemented file saving with Ctrl+S shortcut - Added dirty state tracking and unsaved changes indicator - Enforced workspace mode (read-only in Agent mode) - Added security: path traversal prevention, binary file detection, file size limits - Updated FilesPanel with split view (tree + editor) Enables Users to edit files for the first time in Gadget Code.
This commit is contained in:
parent
ec7b83d610
commit
1e13f95808
@ -11,7 +11,9 @@
|
||||
"author": "Robert Colbert <rob.colbert@openplatform.us>",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ace-builds": "^1.36.0",
|
||||
"marked": "^16.0.0",
|
||||
"react-ace": "^12.0.0",
|
||||
"slug": "^11.0.1"
|
||||
}
|
||||
}
|
||||
330
gadget-code/frontend/src/components/EditorPanel.tsx
Normal file
330
gadget-code/frontend/src/components/EditorPanel.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import AceEditor from 'react-ace';
|
||||
import 'ace-builds/src-noconflict/mode-text';
|
||||
import 'ace-builds/src-noconflict/theme-tomorrow';
|
||||
import 'ace-builds/src-noconflict/ext-language_tools';
|
||||
import { WorkspaceMode } from '../lib/types';
|
||||
import { socketClient } from '../lib/socket';
|
||||
|
||||
interface EditorPanelProps {
|
||||
workspaceMode: WorkspaceMode;
|
||||
filePath?: string;
|
||||
onCloseFile?: () => void;
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error?: string;
|
||||
language: string;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
// Map file extensions to ACE language modes
|
||||
function detectLanguage(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
const languageMap: Record<string, string> = {
|
||||
'js': 'javascript',
|
||||
'jsx': 'javascript',
|
||||
'ts': 'typescript',
|
||||
'tsx': 'typescript',
|
||||
'py': 'python',
|
||||
'rb': 'ruby',
|
||||
'java': 'java',
|
||||
'c': 'c_cpp',
|
||||
'cpp': 'c_cpp',
|
||||
'h': 'c_cpp',
|
||||
'hpp': 'c_cpp',
|
||||
'cs': 'csharp',
|
||||
'go': 'golang',
|
||||
'rs': 'rust',
|
||||
'php': 'php',
|
||||
'html': 'html',
|
||||
'htm': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'json': 'json',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml',
|
||||
'md': 'markdown',
|
||||
'sql': 'sql',
|
||||
'sh': 'sh',
|
||||
'bash': 'sh',
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
};
|
||||
return languageMap[ext || ''] || 'text';
|
||||
}
|
||||
|
||||
export default function EditorPanel({ workspaceMode, filePath, onCloseFile }: EditorPanelProps) {
|
||||
const [state, setState] = useState<EditorState>({
|
||||
content: '',
|
||||
originalContent: '',
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
language: 'text',
|
||||
});
|
||||
|
||||
const isReadOnly = workspaceMode === WorkspaceMode.Agent;
|
||||
const isReadWrite = workspaceMode === WorkspaceMode.User;
|
||||
|
||||
// Load file when filePath changes
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
loadFile(filePath);
|
||||
} else {
|
||||
// Clear editor when no file is selected
|
||||
setState({
|
||||
content: '',
|
||||
originalContent: '',
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
language: 'text',
|
||||
});
|
||||
}
|
||||
}, [filePath]);
|
||||
|
||||
const loadFile = useCallback(async (path: string) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: undefined }));
|
||||
|
||||
try {
|
||||
const result = await socketClient.requestFileRead({ path });
|
||||
|
||||
if (result.success && result.content !== undefined) {
|
||||
const language = detectLanguage(path);
|
||||
|
||||
// Dynamically import the language mode
|
||||
try {
|
||||
await import(`ace-builds/src-noconflict/mode-${language}`);
|
||||
} catch (e) {
|
||||
console.warn(`Could not load ACE mode for ${language}, falling back to text`);
|
||||
}
|
||||
|
||||
setState({
|
||||
content: result.content,
|
||||
originalContent: result.content,
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
language,
|
||||
});
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: result.error || 'Failed to load file',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load file',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveFile = useCallback(async () => {
|
||||
if (!filePath || isReadOnly) return;
|
||||
|
||||
setState(prev => ({ ...prev, isSaving: true, error: undefined }));
|
||||
|
||||
try {
|
||||
const result = await socketClient.requestFileWrite({
|
||||
path: filePath,
|
||||
content: state.content,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
originalContent: prev.content,
|
||||
isDirty: false,
|
||||
successMessage: 'File saved successfully',
|
||||
}));
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setState(prev => ({ ...prev, successMessage: undefined }));
|
||||
}, 3000);
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
error: result.error || 'Failed to save file',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save file',
|
||||
}));
|
||||
}
|
||||
}, [filePath, state.content, isReadOnly]);
|
||||
|
||||
const handleContentChange = useCallback((newValue: string) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
content: newValue,
|
||||
isDirty: newValue !== prev.originalContent,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// Ctrl+S or Cmd+S to save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (!isReadOnly && state.isDirty) {
|
||||
saveFile();
|
||||
}
|
||||
}
|
||||
}, [saveFile, isReadOnly, state.isDirty]);
|
||||
|
||||
// Add keyboard listener
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// If no file is selected, show placeholder
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-bg-secondary">
|
||||
<div className="text-center text-text-muted">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
<p className="text-lg font-medium">No file open</p>
|
||||
<p className="text-sm mt-2">Select a file from the tree to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-bg-secondary min-h-0">
|
||||
{/* Editor Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary border-b border-border-subtle">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-sm font-medium text-text-primary truncate">
|
||||
{filePath}
|
||||
</span>
|
||||
{state.isDirty && (
|
||||
<span className="flex items-center gap-1 text-xs text-yellow-500">
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500"></span>
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
{state.successMessage && (
|
||||
<span className="text-xs text-green-500">
|
||||
{state.successMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isReadOnly && (
|
||||
<span className="text-xs text-text-muted px-2 py-1 bg-bg-secondary rounded border border-border-default">
|
||||
Read-only (Agent mode)
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={saveFile}
|
||||
disabled={isReadOnly || !state.isDirty || state.isSaving}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
isReadOnly || !state.isDirty || state.isSaving
|
||||
? 'bg-bg-secondary text-text-muted cursor-not-allowed'
|
||||
: 'bg-brand text-white hover:bg-brand/80'
|
||||
}`}
|
||||
>
|
||||
{state.isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
|
||||
{onCloseFile && (
|
||||
<button
|
||||
onClick={onCloseFile}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-secondary rounded transition-colors"
|
||||
title="Close file"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{state.error && (
|
||||
<div className="px-4 py-3 bg-red-500/10 border-b border-red-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-500">{state.error}</p>
|
||||
<button
|
||||
onClick={() => loadFile(filePath)}
|
||||
className="mt-2 text-xs text-red-400 hover:text-red-300 underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{state.isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand mx-auto"></div>
|
||||
<p className="text-sm text-text-muted mt-4">Loading file...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ACE Editor */}
|
||||
{!state.isLoading && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<AceEditor
|
||||
mode={state.language}
|
||||
theme="tomorrow"
|
||||
name="editor"
|
||||
value={state.content}
|
||||
onChange={handleContentChange}
|
||||
width="100%"
|
||||
height="100%"
|
||||
fontSize={14}
|
||||
showPrintMargin={false}
|
||||
showGutter={true}
|
||||
highlightActiveLine={true}
|
||||
readOnly={isReadOnly}
|
||||
setOptions={{
|
||||
useWorker: false,
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: false,
|
||||
enableSnippets: false,
|
||||
tabSize: 2,
|
||||
indentedAutoWrap: true,
|
||||
showLineNumbers: true,
|
||||
wrap: false,
|
||||
}}
|
||||
editorProps={{ $blockScrolling: true }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { WorkspaceMode } from "../lib/types";
|
||||
import FileTree from "./FileTree";
|
||||
import EditorPanel from "./EditorPanel";
|
||||
|
||||
interface FilesPanelProps {
|
||||
workspaceMode: WorkspaceMode;
|
||||
}
|
||||
|
||||
export default function FilesPanel({ workspaceMode }: FilesPanelProps) {
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string | undefined>(undefined);
|
||||
const isReadOnly = workspaceMode === WorkspaceMode.Agent;
|
||||
const isReadWrite = workspaceMode === WorkspaceMode.User;
|
||||
|
||||
const handleFileSelect = (path: string) => {
|
||||
setSelectedFilePath(path);
|
||||
};
|
||||
|
||||
const handleCloseFile = () => {
|
||||
setSelectedFilePath(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border-subtle flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
||||
@ -38,15 +49,27 @@ export default function FilesPanel({ workspaceMode }: FilesPanelProps) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<FileTree
|
||||
workspaceMode={workspaceMode}
|
||||
onFileSelect={(path) => {
|
||||
console.log('File selected:', path);
|
||||
// TODO: Open file in editor (next session)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Split view: File tree on left, editor on right */}
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
{/* File Tree - 30% width, resizable in future */}
|
||||
<div className="w-1/3 min-w-[200px] border-r border-border-subtle overflow-auto flex-shrink-0">
|
||||
<FileTree
|
||||
workspaceMode={workspaceMode}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor Panel - remaining space */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<EditorPanel
|
||||
workspaceMode={workspaceMode}
|
||||
filePath={selectedFilePath}
|
||||
onCloseFile={handleCloseFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 bg-bg-tertiary border-t border-border-subtle">
|
||||
<p className="text-xs text-text-muted">
|
||||
{isReadOnly
|
||||
|
||||
@ -67,6 +67,14 @@ export interface ClientToServerEvents {
|
||||
args: { path?: string; recursive?: boolean; maxDepth?: number },
|
||||
cb: (success: boolean, data: { entries?: any[]; error?: string }) => void,
|
||||
) => void;
|
||||
fileReadRequest: (
|
||||
args: { path: string },
|
||||
cb: (success: boolean, data: { content?: string; error?: string }) => void,
|
||||
) => void;
|
||||
fileWriteRequest: (
|
||||
args: { path: string; content: string },
|
||||
cb: (success: boolean, data: { success?: boolean; error?: string }) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface SocketEvents {
|
||||
@ -124,6 +132,8 @@ export interface SocketEvents {
|
||||
sessionUpdated: (updates: Partial<ChatSession>) => void;
|
||||
tabLockDenied: (data: { message: string }) => void;
|
||||
fileTreeResponse: (path: string, entries: any[], error?: string) => void;
|
||||
fileReadResponse: (path: string, content: string | null, error?: string) => void;
|
||||
fileWriteResponse: (path: string, success: boolean, error?: string) => void;
|
||||
status: (content: string) => void;
|
||||
reconnect_attempt: (attempt: number) => void;
|
||||
reconnect_failed: () => void;
|
||||
@ -224,6 +234,14 @@ class SocketClient {
|
||||
this.emit("fileTreeResponse", path, entries as any[], error);
|
||||
});
|
||||
|
||||
this.socket.on("fileReadResponse", (path: string, content: string | null, error?: string) => {
|
||||
this.emit("fileReadResponse", path, content, error);
|
||||
});
|
||||
|
||||
this.socket.on("fileWriteResponse", (path: string, success: boolean, error?: string) => {
|
||||
this.emit("fileWriteResponse", path, success, error);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"log",
|
||||
(
|
||||
@ -433,6 +451,40 @@ class SocketClient {
|
||||
});
|
||||
}
|
||||
|
||||
requestFileRead(
|
||||
args: { path: string },
|
||||
): Promise<{ success: boolean; content?: string; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(
|
||||
"fileReadRequest",
|
||||
args,
|
||||
(success: boolean, data: { content?: string; error?: string }) =>
|
||||
resolve({ success, ...data }),
|
||||
);
|
||||
} else {
|
||||
resolve({ success: false, error: "Socket not connected" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
requestFileWrite(
|
||||
args: { path: string; content: string },
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(
|
||||
"fileWriteRequest",
|
||||
args,
|
||||
(success: boolean, data: { success?: boolean; error?: string }) =>
|
||||
resolve({ success: success, ...data }),
|
||||
);
|
||||
} else {
|
||||
resolve({ success: false, error: "Socket not connected" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
releaseSessionLock(
|
||||
registration: any,
|
||||
project: any,
|
||||
|
||||
@ -27,6 +27,8 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@gadget/ai": "workspace:*",
|
||||
"ace-builds": "^1.36.0",
|
||||
"react-ace": "^12.0.0",
|
||||
"@gadget/api": "workspace:*",
|
||||
"@gadget/config": "workspace:*",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
|
||||
@ -62,6 +62,8 @@ export class CodeSession extends SocketSession {
|
||||
this.socket.on("releaseSessionLock", this.onReleaseSessionLock.bind(this));
|
||||
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
|
||||
this.socket.on("fileTreeRequest", this.onFileTreeRequest.bind(this));
|
||||
this.socket.on("fileReadRequest", this.onFileReadRequest.bind(this));
|
||||
this.socket.on("fileWriteRequest", this.onFileWriteRequest.bind(this));
|
||||
|
||||
// Check for active session on connect
|
||||
this.checkAndReestablishActiveSession();
|
||||
@ -451,6 +453,54 @@ export class CodeSession extends SocketSession {
|
||||
}
|
||||
}
|
||||
|
||||
onFileReadRequest(
|
||||
args: { path: string },
|
||||
cb: (success: boolean, data: { content?: string; error?: string }) => void,
|
||||
): void {
|
||||
if (!this.selectedDrone) {
|
||||
return cb(false, { error: "No drone selected" });
|
||||
}
|
||||
try {
|
||||
const droneSession = SocketService.getDroneSession(this.selectedDrone);
|
||||
droneSession.socket.emit("fileReadRequest", args, (success: boolean, data: { content?: string; error?: string }) => {
|
||||
// Forward response to IDE
|
||||
if (success && data?.content !== undefined) {
|
||||
this.socket.emit("fileReadResponse", args.path, data.content);
|
||||
} else {
|
||||
this.socket.emit("fileReadResponse", args.path, null, data?.error);
|
||||
}
|
||||
cb(success, data);
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.error("failed to forward fileReadRequest to drone", { error });
|
||||
cb(false, { error: "Failed to reach drone" });
|
||||
}
|
||||
}
|
||||
|
||||
onFileWriteRequest(
|
||||
args: { path: string; content: string },
|
||||
cb: (success: boolean, data: { success?: boolean; error?: string }) => void,
|
||||
): void {
|
||||
if (!this.selectedDrone) {
|
||||
return cb(false, { error: "No drone selected" });
|
||||
}
|
||||
try {
|
||||
const droneSession = SocketService.getDroneSession(this.selectedDrone);
|
||||
droneSession.socket.emit("fileWriteRequest", args, (success: boolean, data: { success?: boolean; error?: string }) => {
|
||||
// Forward response to IDE
|
||||
if (success) {
|
||||
this.socket.emit("fileWriteResponse", args.path, true);
|
||||
} else {
|
||||
this.socket.emit("fileWriteResponse", args.path, false, data?.error);
|
||||
}
|
||||
cb(success, data);
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.error("failed to forward fileWriteRequest 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.
|
||||
|
||||
@ -264,6 +264,14 @@ class GadgetDrone extends GadgetProcess {
|
||||
"fileTreeRequest",
|
||||
this.onFileTreeRequest.bind(this),
|
||||
);
|
||||
this.socket.on(
|
||||
"fileReadRequest",
|
||||
this.onFileReadRequest.bind(this),
|
||||
);
|
||||
this.socket.on(
|
||||
"fileWriteRequest",
|
||||
this.onFileWriteRequest.bind(this),
|
||||
);
|
||||
|
||||
/*
|
||||
* Handle socket disconnect: clear the heartbeat timer to prevent
|
||||
@ -792,6 +800,158 @@ class GadgetDrone extends GadgetProcess {
|
||||
}
|
||||
}
|
||||
|
||||
async onFileReadRequest(
|
||||
args: { path: string },
|
||||
cb: (success: boolean, data: { content?: string; error?: string }) => void,
|
||||
): Promise<void> {
|
||||
if (!this.sessionLock) {
|
||||
return cb(false, { error: "No session lock active" });
|
||||
}
|
||||
|
||||
// Get the project directory for this session
|
||||
const projectSlug = this.sessionLock.project.slug;
|
||||
const projectRoot = WorkspaceService.getProjectDirectory(projectSlug);
|
||||
|
||||
this.log.debug("fileReadRequest received", {
|
||||
projectSlug,
|
||||
projectRoot,
|
||||
requestedPath: args.path,
|
||||
});
|
||||
|
||||
if (!projectRoot) {
|
||||
return cb(false, { error: `Project directory not found for slug: ${projectSlug}` });
|
||||
}
|
||||
|
||||
// Resolve path relative to project root
|
||||
const targetPath = path.resolve(projectRoot, args.path);
|
||||
|
||||
// Security: Ensure resolved path is within project root
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedRoot = path.normalize(projectRoot);
|
||||
if (!normalizedTarget.startsWith(normalizedRoot + path.sep) && normalizedTarget !== normalizedRoot) {
|
||||
this.log.warn("fileReadRequest path traversal attempt", {
|
||||
targetPath: normalizedTarget,
|
||||
projectRoot: normalizedRoot,
|
||||
});
|
||||
return cb(false, { error: "Access denied: path outside project root" });
|
||||
}
|
||||
|
||||
this.log.debug("fileReadRequest resolved paths", {
|
||||
projectRoot,
|
||||
targetPath,
|
||||
});
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(targetPath);
|
||||
if (!stat.isFile()) {
|
||||
return cb(false, { error: "Path is not a file" });
|
||||
}
|
||||
|
||||
// Check file size (limit to 1MB)
|
||||
const maxSize = 1 * 1024 * 1024; // 1MB
|
||||
if (stat.size > maxSize) {
|
||||
return cb(false, { error: `File too large (${(stat.size / 1024 / 1024).toFixed(2)}MB). Maximum size is 1MB.` });
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(targetPath, "utf-8");
|
||||
|
||||
// Check for binary file (null bytes)
|
||||
if (content.includes('\0')) {
|
||||
return cb(false, { error: "Cannot edit binary files" });
|
||||
}
|
||||
|
||||
this.log.debug("fileReadRequest completed", {
|
||||
path: args.path,
|
||||
size: stat.size,
|
||||
});
|
||||
|
||||
cb(true, { content });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.log.error("failed to read file", {
|
||||
path: args.path,
|
||||
targetPath,
|
||||
error: errorMessage,
|
||||
});
|
||||
cb(false, { error: `Failed to read file: ${errorMessage}` });
|
||||
}
|
||||
}
|
||||
|
||||
async onFileWriteRequest(
|
||||
args: { path: string; content: string },
|
||||
cb: (success: boolean, data: { success?: boolean; error?: string }) => void,
|
||||
): Promise<void> {
|
||||
if (!this.sessionLock) {
|
||||
return cb(false, { error: "No session lock active" });
|
||||
}
|
||||
|
||||
// Validate workspace mode - only allow writes in User mode
|
||||
if (this.workspaceMode !== WorkspaceMode.User) {
|
||||
this.log.warn("fileWriteRequest rejected: not in User mode", {
|
||||
workspaceMode: this.workspaceMode,
|
||||
});
|
||||
return cb(false, { error: "File writing is only allowed in User mode" });
|
||||
}
|
||||
|
||||
// Get the project directory for this session
|
||||
const projectSlug = this.sessionLock.project.slug;
|
||||
const projectRoot = WorkspaceService.getProjectDirectory(projectSlug);
|
||||
|
||||
this.log.debug("fileWriteRequest received", {
|
||||
projectSlug,
|
||||
projectRoot,
|
||||
requestedPath: args.path,
|
||||
});
|
||||
|
||||
if (!projectRoot) {
|
||||
return cb(false, { error: `Project directory not found for slug: ${projectSlug}` });
|
||||
}
|
||||
|
||||
// Resolve path relative to project root
|
||||
const targetPath = path.resolve(projectRoot, args.path);
|
||||
|
||||
// Security: Ensure resolved path is within project root
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedRoot = path.normalize(projectRoot);
|
||||
if (!normalizedTarget.startsWith(normalizedRoot + path.sep) && normalizedTarget !== normalizedRoot) {
|
||||
this.log.warn("fileWriteRequest path traversal attempt", {
|
||||
targetPath: normalizedTarget,
|
||||
projectRoot: normalizedRoot,
|
||||
});
|
||||
return cb(false, { error: "Access denied: path outside project root" });
|
||||
}
|
||||
|
||||
this.log.debug("fileWriteRequest resolved paths", {
|
||||
projectRoot,
|
||||
targetPath,
|
||||
});
|
||||
|
||||
try {
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(targetPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
|
||||
// Write file content
|
||||
await fs.writeFile(targetPath, args.content, "utf-8");
|
||||
|
||||
this.log.info("fileWriteRequest completed", {
|
||||
path: args.path,
|
||||
size: args.content.length,
|
||||
});
|
||||
|
||||
cb(true, { success: true });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.log.error("failed to write file", {
|
||||
path: args.path,
|
||||
targetPath,
|
||||
error: errorMessage,
|
||||
});
|
||||
cb(false, { error: `Failed to write file: ${errorMessage}` });
|
||||
}
|
||||
}
|
||||
|
||||
private async listDirectoryForTree(
|
||||
dir: string,
|
||||
showHidden: boolean,
|
||||
|
||||
@ -133,3 +133,43 @@ export type FileTreeResponseMessage = (
|
||||
entries: FileTreeEntry[],
|
||||
error?: string,
|
||||
) => void;
|
||||
|
||||
/*
|
||||
* fileReadRequest / fileReadResponse
|
||||
*/
|
||||
|
||||
export type FileReadRequestCallback = (
|
||||
success: boolean,
|
||||
data: { content?: string; error?: string },
|
||||
) => void;
|
||||
|
||||
export type FileReadRequestMessage = (
|
||||
args: { path: string },
|
||||
cb: FileReadRequestCallback,
|
||||
) => void;
|
||||
|
||||
export type FileReadResponseMessage = (
|
||||
path: string,
|
||||
content: string | null,
|
||||
error?: string,
|
||||
) => void;
|
||||
|
||||
/*
|
||||
* fileWriteRequest / fileWriteResponse
|
||||
*/
|
||||
|
||||
export type FileWriteRequestCallback = (
|
||||
success: boolean,
|
||||
data: { success?: boolean; error?: string },
|
||||
) => void;
|
||||
|
||||
export type FileWriteRequestMessage = (
|
||||
args: { path: string; content: string },
|
||||
cb: FileWriteRequestCallback,
|
||||
) => void;
|
||||
|
||||
export type FileWriteResponseMessage = (
|
||||
path: string,
|
||||
success: boolean,
|
||||
error?: string,
|
||||
) => void;
|
||||
|
||||
@ -25,6 +25,10 @@ import {
|
||||
SubmitPromptMessage,
|
||||
FileTreeRequestMessage,
|
||||
FileTreeResponseMessage,
|
||||
FileReadRequestMessage,
|
||||
FileReadResponseMessage,
|
||||
FileWriteRequestMessage,
|
||||
FileWriteResponseMessage,
|
||||
} from "./ide.ts";
|
||||
|
||||
/*
|
||||
@ -57,6 +61,8 @@ export interface ClientToServerEvents {
|
||||
releaseSessionLock: ReleaseSessionLockMessage;
|
||||
sessionHeartbeat: SessionHeartbeatMessage;
|
||||
fileTreeRequest: FileTreeRequestMessage;
|
||||
fileReadRequest: FileReadRequestMessage;
|
||||
fileWriteRequest: FileWriteRequestMessage;
|
||||
|
||||
/*
|
||||
* gadget-drone => gadget-code:web
|
||||
@ -121,6 +127,8 @@ export interface ServerToClientEvents {
|
||||
crashRecoveryResponse: CrashRecoveryResponseMessage;
|
||||
requestTermination: RequestTerminationMessage;
|
||||
fileTreeRequest: FileTreeRequestMessage;
|
||||
fileReadRequest: FileReadRequestMessage;
|
||||
fileWriteRequest: FileWriteRequestMessage;
|
||||
|
||||
/*
|
||||
* gadget-code:web => gadget-code:ide
|
||||
@ -136,6 +144,8 @@ export interface ServerToClientEvents {
|
||||
sessionUpdated: SessionUpdatedMessage;
|
||||
tabLockDenied: (data: { message: string }) => void;
|
||||
fileTreeResponse: FileTreeResponseMessage;
|
||||
fileReadResponse: FileReadResponseMessage;
|
||||
fileWriteResponse: FileWriteResponseMessage;
|
||||
"agent:thinking": AgentThinkingMessage;
|
||||
"agent:response": AgentResponseMessage;
|
||||
"agent:tool-call": AgentToolCallMessage;
|
||||
|
||||
@ -28,6 +28,9 @@ importers:
|
||||
'@react-three/fiber':
|
||||
specifier: ^9.6.1
|
||||
version: 9.6.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.184.0)
|
||||
ace-builds:
|
||||
specifier: ^1.36.0
|
||||
version: 1.44.0
|
||||
ansicolor:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
@ -109,6 +112,9 @@ importers:
|
||||
react:
|
||||
specifier: ^19.2.5
|
||||
version: 19.2.5
|
||||
react-ace:
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
react-dom:
|
||||
specifier: ^19.2.5
|
||||
version: 19.2.5(react@19.2.5)
|
||||
@ -1555,6 +1561,9 @@ packages:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
ace-builds@1.44.0:
|
||||
resolution: {integrity: sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==}
|
||||
|
||||
acorn@7.4.1:
|
||||
resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@ -1975,6 +1984,9 @@ packages:
|
||||
diacritics@1.3.0:
|
||||
resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==}
|
||||
|
||||
diff-match-patch@1.0.5:
|
||||
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -2617,6 +2629,10 @@ packages:
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.get@4.4.2:
|
||||
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
|
||||
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
@ -2626,6 +2642,10 @@ packages:
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash.isfinite@3.3.2:
|
||||
resolution: {integrity: sha512-7FGG40uhC8Mm633uKW1r58aElFlBlxCrg9JfSi3P6aYiWmfiWF0PgMd86ZUsxE5GwWPdHoS2+48bwTh2VPkIQA==}
|
||||
|
||||
@ -2647,6 +2667,10 @@ packages:
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lru-cache@11.3.5:
|
||||
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||
engines: {node: 20 || >=22}
|
||||
@ -3028,6 +3052,9 @@ packages:
|
||||
promise@7.3.1:
|
||||
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@ -3110,11 +3137,20 @@ packages:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
react-ace@12.0.0:
|
||||
resolution: {integrity: sha512-PstU6CSMfYIJknb4su2Fa0WgLXzq2ufQgR6fjcSWuGT1hGTHkBzuKw+SncV8PuLCdSJBJc1VehPhyeXlWByG/g==}
|
||||
peerDependencies:
|
||||
react: ^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
react-dom@19.2.5:
|
||||
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
|
||||
peerDependencies:
|
||||
react: ^19.2.5
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
@ -4801,6 +4837,8 @@ snapshots:
|
||||
mime-types: 3.0.2
|
||||
negotiator: 1.0.0
|
||||
|
||||
ace-builds@1.44.0: {}
|
||||
|
||||
acorn@7.4.1: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
@ -5224,6 +5262,8 @@ snapshots:
|
||||
|
||||
diacritics@1.3.0: {}
|
||||
|
||||
diff-match-patch@1.0.5: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
@ -6014,12 +6054,16 @@ snapshots:
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.get@4.4.2: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isfinite@3.3.2: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
@ -6034,6 +6078,10 @@ snapshots:
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lru-cache@11.3.5: {}
|
||||
|
||||
luxon@3.7.2: {}
|
||||
@ -6360,6 +6408,12 @@ snapshots:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@ -6475,11 +6529,23 @@ snapshots:
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
react-ace@12.0.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
ace-builds: 1.44.0
|
||||
diff-match-patch: 1.0.5
|
||||
lodash.get: 4.4.2
|
||||
lodash.isequal: 4.5.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
react-dom@19.2.5(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-router-dom@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user