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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user