feat(web): suppress browser ctrl+s and add file tree CRUD

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 17:17:55 +08:00
co-authored by Claude
parent ac87454711
commit fcb678773c
3 changed files with 292 additions and 18 deletions
+14
View File
@@ -42,6 +42,20 @@ export function EditorPanel({ workspaceId, path, language }: EditorPanelProps) {
workspaceIdRef.current = 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.
const handleMount: OnMount = (editor, monaco) => {
editor.addCommand(
@@ -1,9 +1,25 @@
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 { 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 { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import { cn } from "@/lib/utils";
@@ -12,31 +28,42 @@ interface FileExplorerPanelProps {
workspaceId: string | null;
}
interface FileTreeDirectoryProps {
workspaceId: string;
entry: FileEntry;
depth: number;
onSelectFile: (path: string) => void;
onRename: (entry: FileEntry) => void;
onDelete: (entry: FileEntry) => void;
}
interface FileTreeFileProps {
entry: FileEntry;
depth: number;
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 isSelected = selectedPath === entry.path;
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
return (
<li>
<li className="group relative">
<button
type="button"
onClick={() => onSelectFile(entry.path)}
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",
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" />
<span className="truncate">{entry.name}</span>
</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>
);
}
@@ -54,6 +105,8 @@ function FileTreeDirectory({
entry,
depth,
onSelectFile,
onRename,
onDelete,
}: FileTreeDirectoryProps) {
const expanded = useFileTreeStore((s) => s.expandedPaths.has(entry.path));
const toggleExpanded = useFileTreeStore((s) => s.toggleExpanded);
@@ -65,12 +118,12 @@ function FileTreeDirectory({
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
return (
<li>
<li className="group relative">
<button
type="button"
onClick={() => toggleExpanded(entry.path)}
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",
)}
style={{ paddingLeft }}
@@ -85,6 +138,30 @@ function FileTreeDirectory({
<Folder className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{entry.name}</span>
</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 && (
<ul>
{isLoading && (
@@ -105,6 +182,8 @@ function FileTreeDirectory({
entry={child}
depth={depth + 1}
onSelectFile={onSelectFile}
onRename={onRename}
onDelete={onDelete}
/>
) : (
<FileTreeFile
@@ -112,6 +191,8 @@ function FileTreeDirectory({
entry={child}
depth={depth + 1}
onSelectFile={onSelectFile}
onRename={onRename}
onDelete={onDelete}
/>
),
)}
@@ -137,6 +218,11 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
const isFetchingFiles = useIsFetching({ queryKey: filesQueryKey }) > 0;
const { data, isLoading, error } = useFileList(workspaceId, ".");
const writeMutation = useFileWrite();
const mkdirMutation = useFileMkdir();
const removeMutation = useFileRemove();
const renameMutation = useFileRename();
const onSelectFile = (path: string) => {
setSelectedPath(path);
setActiveFilePath(path);
@@ -146,21 +232,90 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
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 = (
<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>
{workspaceId && (
<button
type="button"
onClick={refreshAll}
aria-label="refresh file tree"
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<RefreshCw
className={cn("size-3", isFetchingFiles && "animate-spin")}
aria-hidden="true"
/>
</button>
<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
type="button"
onClick={refreshAll}
aria-label="refresh file tree"
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<RefreshCw
className={cn("size-3", isFetchingFiles && "animate-spin")}
aria-hidden="true"
/>
</button>
{lastError && (
<span
className="ml-1 max-w-[6rem] truncate text-xs normal-case text-destructive"
title={lastError.message}
>
{lastError.message}
</span>
)}
</div>
)}
</div>
);
@@ -200,6 +355,8 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
entry={entry}
depth={0}
onSelectFile={onSelectFile}
onRename={handleRename}
onDelete={handleDelete}
/>
) : (
<FileTreeFile
@@ -207,6 +364,8 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
entry={entry}
depth={0}
onSelectFile={onSelectFile}
onRename={handleRename}
onDelete={handleDelete}
/>
),
)}
+101
View File
@@ -127,6 +127,107 @@ export function useFileWrite(): UseMutationResult<
queryClient.invalidateQueries({
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),
});
},
});
}