feat(web): suppress browser ctrl+s and add file tree CRUD
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,20 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
|
|||||||
workspaceIdRef.current = workspaceId;
|
workspaceIdRef.current = workspaceId;
|
||||||
}, [buffer, savedContent, path, workspaceId]);
|
}, [buffer, savedContent, path, workspaceId]);
|
||||||
|
|
||||||
|
// Suppress the browser's Save Page dialog on Cmd/Ctrl+S when a file is open.
|
||||||
|
// Monaco's own keybinding still handles saving.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!path) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.key === "s" || e.key === "S") && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
// Wire Cmd/Ctrl+S to save the current buffer.
|
// Wire Cmd/Ctrl+S to save the current buffer.
|
||||||
const handleMount: OnMount = (editor, monaco) => {
|
const handleMount: OnMount = (editor, monaco) => {
|
||||||
editor.addCommand(
|
editor.addCommand(
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { useQueryClient, useIsFetching } from "@tanstack/react-query";
|
import { useQueryClient, useIsFetching } from "@tanstack/react-query";
|
||||||
import { ChevronRight, FileCode2, Folder, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
FileCode2,
|
||||||
|
FilePlus,
|
||||||
|
Folder,
|
||||||
|
FolderPlus,
|
||||||
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useFileList, type FileEntry } from "@/lib/api/files";
|
import {
|
||||||
|
useFileList,
|
||||||
|
useFileMkdir,
|
||||||
|
useFileRemove,
|
||||||
|
useFileRename,
|
||||||
|
useFileWrite,
|
||||||
|
type FileEntry,
|
||||||
|
} from "@/lib/api/files";
|
||||||
import { useFileTreeStore } from "@/lib/store/file-tree-store";
|
import { useFileTreeStore } from "@/lib/store/file-tree-store";
|
||||||
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -12,31 +28,42 @@ interface FileExplorerPanelProps {
|
|||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface FileTreeDirectoryProps {
|
interface FileTreeDirectoryProps {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
entry: FileEntry;
|
entry: FileEntry;
|
||||||
depth: number;
|
depth: number;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
|
onRename: (entry: FileEntry) => void;
|
||||||
|
onDelete: (entry: FileEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileTreeFileProps {
|
interface FileTreeFileProps {
|
||||||
entry: FileEntry;
|
entry: FileEntry;
|
||||||
depth: number;
|
depth: number;
|
||||||
onSelectFile: (path: string) => void;
|
onSelectFile: (path: string) => void;
|
||||||
|
onRename: (entry: FileEntry) => void;
|
||||||
|
onDelete: (entry: FileEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileTreeFile({ entry, depth, onSelectFile }: FileTreeFileProps) {
|
function FileTreeFile({
|
||||||
|
entry,
|
||||||
|
depth,
|
||||||
|
onSelectFile,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
}: FileTreeFileProps) {
|
||||||
const selectedPath = useFileTreeStore((s) => s.selectedPath);
|
const selectedPath = useFileTreeStore((s) => s.selectedPath);
|
||||||
const isSelected = selectedPath === entry.path;
|
const isSelected = selectedPath === entry.path;
|
||||||
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
|
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li className="group relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectFile(entry.path)}
|
onClick={() => onSelectFile(entry.path)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 w-full items-center gap-1.5 truncate px-2 text-left text-xs transition-colors",
|
"flex h-7 w-full items-center gap-1.5 truncate px-2 pr-14 text-left text-xs transition-colors",
|
||||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
"text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
isSelected && "bg-accent text-accent-foreground",
|
isSelected && "bg-accent text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
@@ -45,6 +72,30 @@ function FileTreeFile({ entry, depth, onSelectFile }: FileTreeFileProps) {
|
|||||||
<FileCode2 className="size-3.5 shrink-0" aria-hidden="true" />
|
<FileCode2 className="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
<span className="truncate">{entry.name}</span>
|
<span className="truncate">{entry.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="absolute right-0 top-0 hidden h-full items-center gap-0.5 px-1 group-hover:flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRename(entry);
|
||||||
|
}}
|
||||||
|
aria-label={`Rename ${entry.name}`}
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(entry);
|
||||||
|
}}
|
||||||
|
aria-label={`Delete ${entry.name}`}
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,6 +105,8 @@ function FileTreeDirectory({
|
|||||||
entry,
|
entry,
|
||||||
depth,
|
depth,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
}: FileTreeDirectoryProps) {
|
}: FileTreeDirectoryProps) {
|
||||||
const expanded = useFileTreeStore((s) => s.expandedPaths.has(entry.path));
|
const expanded = useFileTreeStore((s) => s.expandedPaths.has(entry.path));
|
||||||
const toggleExpanded = useFileTreeStore((s) => s.toggleExpanded);
|
const toggleExpanded = useFileTreeStore((s) => s.toggleExpanded);
|
||||||
@@ -65,12 +118,12 @@ function FileTreeDirectory({
|
|||||||
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
|
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li className="group relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleExpanded(entry.path)}
|
onClick={() => toggleExpanded(entry.path)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 w-full items-center gap-1.5 truncate px-2 text-left text-xs transition-colors",
|
"flex h-7 w-full items-center gap-1.5 truncate px-2 pr-14 text-left text-xs transition-colors",
|
||||||
"text-foreground hover:bg-accent hover:text-accent-foreground",
|
"text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft }}
|
style={{ paddingLeft }}
|
||||||
@@ -85,6 +138,30 @@ function FileTreeDirectory({
|
|||||||
<Folder className="size-3.5 shrink-0" aria-hidden="true" />
|
<Folder className="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
<span className="truncate">{entry.name}</span>
|
<span className="truncate">{entry.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="absolute right-0 top-0 hidden h-full items-center gap-0.5 px-1 group-hover:flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRename(entry);
|
||||||
|
}}
|
||||||
|
aria-label={`Rename ${entry.name}`}
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(entry);
|
||||||
|
}}
|
||||||
|
aria-label={`Delete ${entry.name}`}
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<ul>
|
<ul>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -105,6 +182,8 @@ function FileTreeDirectory({
|
|||||||
entry={child}
|
entry={child}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
|
onRename={onRename}
|
||||||
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileTreeFile
|
<FileTreeFile
|
||||||
@@ -112,6 +191,8 @@ function FileTreeDirectory({
|
|||||||
entry={child}
|
entry={child}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
|
onRename={onRename}
|
||||||
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -137,6 +218,11 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
|
|||||||
const isFetchingFiles = useIsFetching({ queryKey: filesQueryKey }) > 0;
|
const isFetchingFiles = useIsFetching({ queryKey: filesQueryKey }) > 0;
|
||||||
const { data, isLoading, error } = useFileList(workspaceId, ".");
|
const { data, isLoading, error } = useFileList(workspaceId, ".");
|
||||||
|
|
||||||
|
const writeMutation = useFileWrite();
|
||||||
|
const mkdirMutation = useFileMkdir();
|
||||||
|
const removeMutation = useFileRemove();
|
||||||
|
const renameMutation = useFileRename();
|
||||||
|
|
||||||
const onSelectFile = (path: string) => {
|
const onSelectFile = (path: string) => {
|
||||||
setSelectedPath(path);
|
setSelectedPath(path);
|
||||||
setActiveFilePath(path);
|
setActiveFilePath(path);
|
||||||
@@ -146,10 +232,70 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
|
|||||||
queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNewFile = () => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
const raw = window.prompt("New file path (relative to workspace root):");
|
||||||
|
if (!raw) return;
|
||||||
|
const path = raw.trim();
|
||||||
|
if (!path) return;
|
||||||
|
writeMutation.mutate({ workspaceId, path, content: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewFolder = () => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
const raw = window.prompt("New folder path:");
|
||||||
|
if (!raw) return;
|
||||||
|
const path = raw.trim();
|
||||||
|
if (!path) return;
|
||||||
|
mkdirMutation.mutate({ workspaceId, path });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = (entry: FileEntry) => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
const raw = window.prompt("New path:", entry.path);
|
||||||
|
if (!raw) return;
|
||||||
|
const newPath = raw.trim();
|
||||||
|
if (!newPath || newPath === entry.path) return;
|
||||||
|
renameMutation.mutate({ workspaceId, oldPath: entry.path, newPath });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (entry: FileEntry) => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
const ok = window.confirm(`Delete ${entry.name}?`);
|
||||||
|
if (!ok) return;
|
||||||
|
removeMutation.mutate({ workspaceId, path: entry.path });
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastError = [
|
||||||
|
writeMutation,
|
||||||
|
mkdirMutation,
|
||||||
|
removeMutation,
|
||||||
|
renameMutation,
|
||||||
|
].find((m) => m.isError)?.error;
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<div className="flex h-9 shrink-0 items-center justify-between border-b border-sidebar-border px-3 text-[11px] font-semibold uppercase text-muted-foreground">
|
<div className="flex h-9 shrink-0 items-center justify-between border-b border-sidebar-border px-3 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||||
<span>Explorer</span>
|
<span>Explorer</span>
|
||||||
{workspaceId && (
|
{workspaceId && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNewFile}
|
||||||
|
disabled={writeMutation.isPending}
|
||||||
|
aria-label="new file"
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FilePlus className="size-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNewFolder}
|
||||||
|
disabled={mkdirMutation.isPending}
|
||||||
|
aria-label="new folder"
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FolderPlus className="size-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={refreshAll}
|
onClick={refreshAll}
|
||||||
@@ -161,6 +307,15 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
{lastError && (
|
||||||
|
<span
|
||||||
|
className="ml-1 max-w-[6rem] truncate text-xs normal-case text-destructive"
|
||||||
|
title={lastError.message}
|
||||||
|
>
|
||||||
|
{lastError.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -200,6 +355,8 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
depth={0}
|
depth={0}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
|
onRename={handleRename}
|
||||||
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileTreeFile
|
<FileTreeFile
|
||||||
@@ -207,6 +364,8 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
depth={0}
|
depth={0}
|
||||||
onSelectFile={onSelectFile}
|
onSelectFile={onSelectFile}
|
||||||
|
onRename={handleRename}
|
||||||
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -127,6 +127,107 @@ export function useFileWrite(): UseMutationResult<
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: fileReadQueryKey(variables.workspaceId, variables.path),
|
queryKey: fileReadQueryKey(variables.workspaceId, variables.path),
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workspaces", variables.workspaceId, "files"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MkdirVariables {
|
||||||
|
workspaceId: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileMkdir(): UseMutationResult<
|
||||||
|
void,
|
||||||
|
Error,
|
||||||
|
MkdirVariables
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ workspaceId, path }) => {
|
||||||
|
const url = `/api/workspaces/${encodeURIComponent(workspaceId)}/files/mkdir`;
|
||||||
|
const res = await apiClient(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to create folder: ${res.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workspaces", variables.workspaceId, "files"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveVariables {
|
||||||
|
workspaceId: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileRemove(): UseMutationResult<
|
||||||
|
void,
|
||||||
|
Error,
|
||||||
|
RemoveVariables
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ workspaceId, path }) => {
|
||||||
|
const url =
|
||||||
|
`/api/workspaces/${encodeURIComponent(workspaceId)}/files?` +
|
||||||
|
new URLSearchParams({ path }).toString();
|
||||||
|
const res = await apiClient(url, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to delete file: ${res.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workspaces", variables.workspaceId, "files"],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: fileReadQueryKey(variables.workspaceId, variables.path),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameVariables {
|
||||||
|
workspaceId: string;
|
||||||
|
oldPath: string;
|
||||||
|
newPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileRename(): UseMutationResult<
|
||||||
|
void,
|
||||||
|
Error,
|
||||||
|
RenameVariables
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ workspaceId, oldPath, newPath }) => {
|
||||||
|
const url = `/api/workspaces/${encodeURIComponent(workspaceId)}/files/rename`;
|
||||||
|
const res = await apiClient(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ oldPath, newPath }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to rename file: ${res.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workspaces", variables.workspaceId, "files"],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: fileReadQueryKey(variables.workspaceId, variables.oldPath),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user