new file create

This commit is contained in:
Rob Colbert 2026-05-15 17:50:24 -04:00
parent 633ebf4a78
commit d1e77f52f1
2 changed files with 207 additions and 14 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
import { socketClient } from '../lib/socket'; import { socketClient } from '../lib/socket';
import { WorkspaceMode } from '../lib/types'; import { WorkspaceMode } from '../lib/types';
import FileTreeNode from './FileTreeNode'; import FileTreeNode from './FileTreeNode';
@ -12,6 +12,10 @@ export interface FileTreeEntry {
isHidden?: boolean; isHidden?: boolean;
} }
export interface FileTreeHandle {
refresh: () => void;
}
interface FileTreeProps { interface FileTreeProps {
workspaceMode: WorkspaceMode; workspaceMode: WorkspaceMode;
onFileSelect?: (path: string) => void; onFileSelect?: (path: string) => void;
@ -24,7 +28,7 @@ interface FileTreeState {
errors: Map<string, string>; errors: Map<string, string>;
} }
export default function FileTree({ workspaceMode, onFileSelect }: FileTreeProps) { const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree({ workspaceMode, onFileSelect }, ref) {
const [state, setState] = useState<FileTreeState>({ const [state, setState] = useState<FileTreeState>({
directoryCache: new Map(), directoryCache: new Map(),
expandedPaths: new Set(), expandedPaths: new Set(),
@ -34,22 +38,21 @@ export default function FileTree({ workspaceMode, onFileSelect }: FileTreeProps)
const [rootLoaded, setRootLoaded] = useState(false); const [rootLoaded, setRootLoaded] = useState(false);
// Load root directory on mount // Use refs to hold the latest state so loadDirectory always reads current values
useEffect(() => { // without needing to be in its dependency array (which causes re-creation loops)
if (!rootLoaded) { const stateRef = useRef(state);
loadDirectory(''); stateRef.current = state;
setRootLoaded(true);
}
}, []);
const loadDirectory = useCallback(async (path: string) => { const loadDirectory = useCallback(async (path: string) => {
const currentState = stateRef.current;
// Don't reload if already cached // Don't reload if already cached
if (state.directoryCache.has(path)) { if (currentState.directoryCache.has(path)) {
return; return;
} }
// Don't reload if already loading // Don't reload if already loading
if (state.loadingPaths.has(path)) { if (currentState.loadingPaths.has(path)) {
return; return;
} }
@ -110,7 +113,28 @@ export default function FileTree({ workspaceMode, onFileSelect }: FileTreeProps)
}; };
}); });
} }
}, [state.directoryCache, state.loadingPaths]); }, []);
// Load root directory on mount
useEffect(() => {
if (!rootLoaded) {
loadDirectory('');
setRootLoaded(true);
}
}, [rootLoaded, loadDirectory]);
// Expose refresh method to parent via ref
useImperativeHandle(ref, () => ({
refresh: () => {
setState({
directoryCache: new Map(),
expandedPaths: new Set(),
loadingPaths: new Set(),
errors: new Map(),
});
setRootLoaded(false);
},
}), []);
const toggleExpand = useCallback((path: string) => { const toggleExpand = useCallback((path: string) => {
setState(prev => { setState(prev => {
@ -215,4 +239,6 @@ export default function FileTree({ workspaceMode, onFileSelect }: FileTreeProps)
</div> </div>
</div> </div>
); );
} });
export default FileTree;

View File

@ -1,5 +1,7 @@
import { useRef, useState, useCallback, useEffect } from "react";
import { WorkspaceMode } from "../lib/types"; import { WorkspaceMode } from "../lib/types";
import FileTree from "./FileTree"; import { socketClient } from "../lib/socket";
import FileTree, { FileTreeHandle } from "./FileTree";
interface FilesPanelProps { interface FilesPanelProps {
workspaceMode: WorkspaceMode; workspaceMode: WorkspaceMode;
@ -10,13 +12,125 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
const isReadOnly = workspaceMode === WorkspaceMode.Agent; const isReadOnly = workspaceMode === WorkspaceMode.Agent;
const isReadWrite = workspaceMode === WorkspaceMode.User; const isReadWrite = workspaceMode === WorkspaceMode.User;
const fileTreeRef = useRef<FileTreeHandle>(null);
// New file creation state
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [newFilePath, setNewFilePath] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | undefined>(undefined);
const inputRef = useRef<HTMLInputElement>(null);
// Focus the input when the creation UI appears
useEffect(() => {
if (isCreatingFile && inputRef.current) {
inputRef.current.focus();
}
}, [isCreatingFile]);
const handleRefresh = useCallback(() => {
fileTreeRef.current?.refresh();
}, []);
const handleStartCreate = useCallback(() => {
if (!isReadWrite) return;
setIsCreatingFile(true);
setNewFilePath("");
setCreateError(undefined);
}, [isReadWrite]);
const handleCancelCreate = useCallback(() => {
setIsCreatingFile(false);
setNewFilePath("");
setCreateError(undefined);
}, []);
const handleConfirmCreate = useCallback(async () => {
const trimmed = newFilePath.trim();
if (!trimmed) return;
// Strip leading slash if present (paths are relative to project root)
const normalizedPath = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
setIsCreating(true);
setCreateError(undefined);
try {
const result = await socketClient.requestFileWrite({
path: normalizedPath,
content: "",
});
if (result.success) {
setIsCreatingFile(false);
setNewFilePath("");
setCreateError(undefined);
// Refresh the tree to show the new file, then open it in the editor
fileTreeRef.current?.refresh();
onFileSelect?.(normalizedPath);
} else {
setCreateError(result.error || "Failed to create file");
}
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed to create file");
} finally {
setIsCreating(false);
}
}, [newFilePath, onFileSelect]);
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleConfirmCreate();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancelCreate();
}
},
[handleConfirmCreate, handleCancelCreate],
);
return ( return (
<div className="border-t border-border-subtle flex flex-col flex-1 min-h-0"> <div className="border-t border-border-subtle flex flex-col flex-1 min-h-0">
{/* Header */}
<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
</h3> </h3>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* New File button */}
<button
onClick={handleStartCreate}
disabled={!isReadWrite}
className={`p-1.5 rounded transition-colors ${
isReadWrite
? "text-text-muted hover:text-text-primary hover:bg-bg-secondary"
: "text-text-muted/30 cursor-not-allowed"
}`}
title={
isReadWrite
? "New file"
: "Switch to User mode to create files"
}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{/* Refresh button */}
<button
onClick={handleRefresh}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-secondary rounded transition-colors"
title="Refresh file tree"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
{/* RW indicator */}
<span <span
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${ className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
isReadWrite isReadWrite
@ -27,6 +141,7 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
> >
RW RW
</span> </span>
{/* RO indicator */}
<span <span
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${ className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
isReadOnly isReadOnly
@ -39,12 +154,64 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
</span> </span>
</div> </div>
</div> </div>
{/* New file creation input */}
{isCreatingFile && (
<div className="px-3 py-2 bg-bg-secondary border-b border-border-subtle">
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="text"
value={newFilePath}
onChange={(e) => setNewFilePath(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="src/utils/helper.ts"
disabled={isCreating}
className="flex-1 bg-bg-primary border border-border-default rounded px-2 py-1 text-sm text-text-primary placeholder-text-muted/50 focus:outline-none focus:border-brand"
/>
{/* Confirm button */}
<button
onClick={handleConfirmCreate}
disabled={isCreating || !newFilePath.trim()}
className={`p-1 rounded transition-colors ${
isCreating || !newFilePath.trim()
? "text-text-muted/30 cursor-not-allowed"
: "text-green-500 hover:bg-bg-tertiary"
}`}
title="Create file"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
{/* Cancel button */}
<button
onClick={handleCancelCreate}
disabled={isCreating}
className="p-1 text-text-muted hover:text-red-400 hover:bg-bg-tertiary rounded transition-colors"
title="Cancel"
>
<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>
{createError && (
<p className="mt-1 text-xs text-red-400">{createError}</p>
)}
</div>
)}
{/* File tree */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<FileTree <FileTree
ref={fileTreeRef}
workspaceMode={workspaceMode} workspaceMode={workspaceMode}
onFileSelect={onFileSelect} onFileSelect={onFileSelect}
/> />
</div> </div>
{/* Footer */}
<div className="px-4 py-2 bg-bg-tertiary border-t border-border-subtle"> <div className="px-4 py-2 bg-bg-tertiary border-t border-border-subtle">
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
{isReadOnly {isReadOnly