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 { 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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user