new file create
This commit is contained in:
parent
633ebf4a78
commit
d1e77f52f1
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user