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

View File

@ -1,5 +1,7 @@
import { useRef, useState, useCallback, useEffect } from "react";
import { WorkspaceMode } from "../lib/types";
import FileTree from "./FileTree";
import { socketClient } from "../lib/socket";
import FileTree, { FileTreeHandle } from "./FileTree";
interface FilesPanelProps {
workspaceMode: WorkspaceMode;
@ -10,13 +12,125 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
const isReadOnly = workspaceMode === WorkspaceMode.Agent;
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 (
<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">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Files
</h3>
<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
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
isReadWrite
@ -27,6 +141,7 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
>
RW
</span>
{/* RO indicator */}
<span
className={`w-6 h-6 flex items-center justify-center font-mono font-bold text-xs rounded border ${
isReadOnly
@ -39,12 +154,64 @@ export default function FilesPanel({ workspaceMode, onFileSelect }: FilesPanelPr
</span>
</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">
<FileTree
ref={fileTreeRef}
workspaceMode={workspaceMode}
onFileSelect={onFileSelect}
/>
</div>
{/* Footer */}
<div className="px-4 py-2 bg-bg-tertiary border-t border-border-subtle">
<p className="text-xs text-text-muted">
{isReadOnly