feat(web): replace hover buttons with right-click context menu in file tree

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 17:40:07 +08:00
co-authored by Claude
parent fcb678773c
commit 7a6e7e7ebb
@@ -5,11 +5,16 @@ import {
FilePlus, FilePlus,
Folder, Folder,
FolderPlus, FolderPlus,
Pencil,
RefreshCw, RefreshCw,
Trash2,
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react"; import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
@@ -28,42 +33,48 @@ interface FileExplorerPanelProps {
workspaceId: string | null; workspaceId: string | null;
} }
type OnRowContextMenu = (
entry: FileEntry,
point: { x: number; y: number },
) => void;
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; onContextMenu: OnRowContextMenu;
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; onContextMenu: OnRowContextMenu;
onDelete: (entry: FileEntry) => void;
} }
function FileTreeFile({ function FileTreeFile({
entry, entry,
depth, depth,
onSelectFile, onSelectFile,
onRename, onContextMenu,
onDelete,
}: FileTreeFileProps) { }: 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 className="group relative"> <li>
<button <button
type="button" type="button"
onClick={() => onSelectFile(entry.path)} onClick={() => onSelectFile(entry.path)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(entry, { x: e.clientX, y: e.clientY });
}}
className={cn( className={cn(
"flex h-7 w-full items-center gap-1.5 truncate px-2 pr-14 text-left text-xs transition-colors", "flex h-7 w-full items-center gap-1.5 truncate px-2 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",
)} )}
@@ -72,30 +83,6 @@ function FileTreeFile({
<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>
); );
} }
@@ -105,8 +92,7 @@ function FileTreeDirectory({
entry, entry,
depth, depth,
onSelectFile, onSelectFile,
onRename, onContextMenu,
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);
@@ -118,12 +104,17 @@ function FileTreeDirectory({
const paddingLeft = `${depth * 0.875 + 0.75}rem`; const paddingLeft = `${depth * 0.875 + 0.75}rem`;
return ( return (
<li className="group relative"> <li>
<button <button
type="button" type="button"
onClick={() => toggleExpanded(entry.path)} onClick={() => toggleExpanded(entry.path)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(entry, { x: e.clientX, y: e.clientY });
}}
className={cn( className={cn(
"flex h-7 w-full items-center gap-1.5 truncate px-2 pr-14 text-left text-xs transition-colors", "flex h-7 w-full items-center gap-1.5 truncate px-2 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 }}
@@ -138,30 +129,6 @@ 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 && (
@@ -182,8 +149,7 @@ function FileTreeDirectory({
entry={child} entry={child}
depth={depth + 1} depth={depth + 1}
onSelectFile={onSelectFile} onSelectFile={onSelectFile}
onRename={onRename} onContextMenu={onContextMenu}
onDelete={onDelete}
/> />
) : ( ) : (
<FileTreeFile <FileTreeFile
@@ -191,8 +157,7 @@ function FileTreeDirectory({
entry={child} entry={child}
depth={depth + 1} depth={depth + 1}
onSelectFile={onSelectFile} onSelectFile={onSelectFile}
onRename={onRename} onContextMenu={onContextMenu}
onDelete={onDelete}
/> />
), ),
)} )}
@@ -223,6 +188,23 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
const removeMutation = useFileRemove(); const removeMutation = useFileRemove();
const renameMutation = useFileRename(); const renameMutation = useFileRename();
const [contextMenu, setContextMenu] = useState<{
entry: FileEntry;
x: number;
y: number;
} | null>(null);
const [adjustedPos, setAdjustedPos] = useState<{
x: number;
y: number;
} | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const hasMeasuredRef = useRef(false);
const menuPos = useMemo(
() => adjustedPos ?? (contextMenu ? { x: contextMenu.x, y: contextMenu.y } : null),
[adjustedPos, contextMenu],
);
const onSelectFile = (path: string) => { const onSelectFile = (path: string) => {
setSelectedPath(path); setSelectedPath(path);
setActiveFilePath(path); setActiveFilePath(path);
@@ -266,6 +248,46 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
removeMutation.mutate({ workspaceId, path: entry.path }); removeMutation.mutate({ workspaceId, path: entry.path });
}; };
const handleRowContextMenu: OnRowContextMenu = (entry, point) => {
setContextMenu({ entry, x: point.x, y: point.y });
};
useEffect(() => {
setAdjustedPos(null);
hasMeasuredRef.current = false;
}, [contextMenu]);
useLayoutEffect(() => {
if (!menuRef.current || !menuPos || hasMeasuredRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
const nextX = Math.min(menuPos.x, window.innerWidth - rect.width - 8);
const nextY = Math.min(menuPos.y, window.innerHeight - rect.height - 8);
hasMeasuredRef.current = true;
if (nextX !== menuPos.x || nextY !== menuPos.y) {
setAdjustedPos({ x: nextX, y: nextY });
}
}, [menuPos]);
useEffect(() => {
if (!contextMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node)) {
setContextMenu(null);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setContextMenu(null);
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu]);
const lastError = [ const lastError = [
writeMutation, writeMutation,
mkdirMutation, mkdirMutation,
@@ -355,8 +377,7 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
entry={entry} entry={entry}
depth={0} depth={0}
onSelectFile={onSelectFile} onSelectFile={onSelectFile}
onRename={handleRename} onContextMenu={handleRowContextMenu}
onDelete={handleDelete}
/> />
) : ( ) : (
<FileTreeFile <FileTreeFile
@@ -364,14 +385,82 @@ export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
entry={entry} entry={entry}
depth={0} depth={0}
onSelectFile={onSelectFile} onSelectFile={onSelectFile}
onRename={handleRename} onContextMenu={handleRowContextMenu}
onDelete={handleDelete}
/> />
), ),
)} )}
</ul> </ul>
</nav> </nav>
</ScrollArea> </ScrollArea>
{contextMenu && menuPos && (
<div
ref={menuRef}
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
className="z-50 min-w-[10rem] rounded-md border border-border bg-popover py-1 text-popover-foreground shadow-md"
>
<button
type="button"
onClick={() => {
handleRename(contextMenu.entry);
setContextMenu(null);
}}
className="w-full px-3 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
>
Rename
</button>
<button
type="button"
onClick={() => {
handleDelete(contextMenu.entry);
setContextMenu(null);
}}
className="w-full px-3 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
>
Delete
</button>
{contextMenu.entry.isDir && (
<>
<div className="my-1 h-px bg-border" />
<button
type="button"
onClick={() => {
if (!workspaceId) return;
const raw = window.prompt(
"New file path:",
contextMenu.entry.path + "/",
);
if (!raw) return;
const path = raw.trim();
if (!path) return;
writeMutation.mutate({ workspaceId, path, content: "" });
setContextMenu(null);
}}
className="w-full px-3 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
>
New File in here
</button>
<button
type="button"
onClick={() => {
if (!workspaceId) return;
const raw = window.prompt(
"New folder path:",
contextMenu.entry.path + "/",
);
if (!raw) return;
const path = raw.trim();
if (!path) return;
mkdirMutation.mutate({ workspaceId, path });
setContextMenu(null);
}}
className="w-full px-3 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
>
New Folder in here
</button>
</>
)}
</div>
)}
</aside> </aside>
); );
} }