mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): full IDE-like file management (#147)
## Summary - **API**: POST (create), DELETE (delete), PATCH (rename/move), POST duplicate endpoints with null-byte sanitization - **FileTree**: right-click context menu with New File, New Folder, Rename, Duplicate, Delete - **Drag-and-drop**: move files between folders with visual feedback and subtree guard - **Inline editing**: rename/create inputs with filename validation - **Header actions**: quick New File / New Folder buttons in the FILES header Stacks on top of `feat/studio-code-quality`. ## Test plan - [x] Right-click file → Rename, Delete, Duplicate all work - [x] Right-click folder → New File, New Folder, Delete work - [x] Drag file from one folder to another - [x] Create file with invalid name (`../foo`, `a/b`) → rejected client-side - [x] Delete currently-edited file → editor clears - [x] Studio build succeeds
This commit is contained in:
@@ -1,36 +1,241 @@
|
||||
import type { Hono } from "hono";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
renameSync,
|
||||
readdirSync,
|
||||
} from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
|
||||
export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// Read file content
|
||||
api.get("/projects/:id/files/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
const file = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, file) || !existsSync(file)) {
|
||||
return c.text("not found", 404);
|
||||
// ── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the project and file path from the request, validating safety.
|
||||
* Returns null (and sends an error response) if anything is invalid.
|
||||
*/
|
||||
interface RouteContext {
|
||||
req: { param: (name: string) => string; path: string };
|
||||
json: (data: unknown, status?: number) => Response;
|
||||
}
|
||||
|
||||
async function resolveProjectFile(
|
||||
c: RouteContext,
|
||||
adapter: StudioApiAdapter,
|
||||
opts?: { mustExist?: boolean },
|
||||
) {
|
||||
const id = c.req.param("id");
|
||||
const project = await adapter.resolveProject(id);
|
||||
if (!project) {
|
||||
return { error: c.json({ error: "not found" }, 404) } as const;
|
||||
}
|
||||
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
if (filePath.includes("\0")) {
|
||||
return { error: c.json({ error: "forbidden" }, 403) } as const;
|
||||
}
|
||||
|
||||
const absPath = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, absPath)) {
|
||||
return { error: c.json({ error: "forbidden" }, 403) } as const;
|
||||
}
|
||||
|
||||
if (opts?.mustExist && !existsSync(absPath)) {
|
||||
return { error: c.json({ error: "not found" }, 404) } as const;
|
||||
}
|
||||
|
||||
return { project, filePath, absPath } as const;
|
||||
}
|
||||
|
||||
/** Ensure the parent directory of a path exists. */
|
||||
function ensureDir(filePath: string) {
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a copy name: foo.html → foo (copy).html → foo (copy 2).html
|
||||
*/
|
||||
function generateCopyPath(projectDir: string, originalPath: string): string {
|
||||
const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : "";
|
||||
const base = ext ? originalPath.slice(0, -ext.length) : originalPath;
|
||||
|
||||
// If already a copy, increment the number
|
||||
const copyMatch = base.match(/ \(copy(?: (\d+))?\)$/);
|
||||
const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
|
||||
let num = copyMatch ? (copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2) : 1;
|
||||
|
||||
let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
|
||||
while (existsSync(resolve(projectDir, candidate))) {
|
||||
num++;
|
||||
candidate = `${cleanBase} (copy ${num})${ext}`;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a directory recursively and return all file paths matching a filter.
|
||||
*/
|
||||
function walkFiles(dir: string, filter: (name: string) => boolean): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders")
|
||||
continue;
|
||||
results.push(...walkFiles(full, filter));
|
||||
} else if (filter(entry.name)) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a rename, update all references to the old path in project files.
|
||||
* Scans HTML, CSS, JS, and JSON files for the old filename/path and replaces.
|
||||
*/
|
||||
function updateReferences(projectDir: string, oldPath: string, newPath: string): number {
|
||||
const textFiles = walkFiles(projectDir, (name) =>
|
||||
/\.(html|css|js|jsx|ts|tsx|json|mjs|cjs|md|mdx)$/i.test(name),
|
||||
);
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const file of textFiles) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
return c.json({ filename: filePath, content });
|
||||
|
||||
// Only replace full relative paths — never bare filenames, which can
|
||||
// corrupt unrelated content (e.g. "logo.png" inside "my-logo.png").
|
||||
if (!content.includes(oldPath)) continue;
|
||||
|
||||
const updated = content.split(oldPath).join(newPath);
|
||||
if (updated !== content) {
|
||||
writeFileSync(file, updated, "utf-8");
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
return updatedCount;
|
||||
}
|
||||
|
||||
// ── Route registration ──────────────────────────────────────────────────────
|
||||
|
||||
export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// ── Read ──
|
||||
|
||||
api.get("/projects/:id/files/*", async (c) => {
|
||||
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const content = readFileSync(res.absPath, "utf-8");
|
||||
return c.json({ filename: res.filePath, content });
|
||||
});
|
||||
|
||||
// Write file content
|
||||
// ── Write (overwrite) ──
|
||||
|
||||
api.put("/projects/:id/files/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
const file = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, file)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
const dir = dirname(file);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const res = await resolveProjectFile(c, adapter);
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
ensureDir(res.absPath);
|
||||
const body = await c.req.text();
|
||||
writeFileSync(file, body, "utf-8");
|
||||
writeFileSync(res.absPath, body, "utf-8");
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Create (fail if exists) ──
|
||||
|
||||
api.post("/projects/:id/files/*", async (c) => {
|
||||
const res = await resolveProjectFile(c, adapter);
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
if (existsSync(res.absPath)) {
|
||||
return c.json({ error: "already exists" }, 409);
|
||||
}
|
||||
|
||||
ensureDir(res.absPath);
|
||||
const body = await c.req.text().catch(() => "");
|
||||
writeFileSync(res.absPath, body, "utf-8");
|
||||
|
||||
return c.json({ ok: true, path: res.filePath }, 201);
|
||||
});
|
||||
|
||||
// ── Delete ──
|
||||
|
||||
api.delete("/projects/:id/files/*", async (c) => {
|
||||
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const stat = statSync(res.absPath);
|
||||
if (stat.isDirectory()) {
|
||||
rmSync(res.absPath, { recursive: true });
|
||||
} else {
|
||||
unlinkSync(res.absPath);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Rename / Move ──
|
||||
|
||||
api.patch("/projects/:id/files/*", async (c) => {
|
||||
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const body = (await c.req.json()) as { newPath?: string };
|
||||
if (!body.newPath || body.newPath.includes("\0")) {
|
||||
return c.json({ error: "newPath required" }, 400);
|
||||
}
|
||||
|
||||
const newAbs = resolve(res.project.dir, body.newPath);
|
||||
if (!isSafePath(res.project.dir, newAbs)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
if (existsSync(newAbs)) {
|
||||
return c.json({ error: "already exists" }, 409);
|
||||
}
|
||||
|
||||
ensureDir(newAbs);
|
||||
renameSync(res.absPath, newAbs);
|
||||
|
||||
// Update references to the old path across all project files
|
||||
const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);
|
||||
|
||||
return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
|
||||
});
|
||||
|
||||
// ── Duplicate ──
|
||||
|
||||
api.post("/projects/:id/duplicate-file", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
const body = (await c.req.json()) as { path: string };
|
||||
if (!body.path || body.path.includes("\0")) {
|
||||
return c.json({ error: "path required" }, 400);
|
||||
}
|
||||
|
||||
const srcAbs = resolve(project.dir, body.path);
|
||||
if (!isSafePath(project.dir, srcAbs) || !existsSync(srcAbs)) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
const copyPath = generateCopyPath(project.dir, body.path);
|
||||
const destAbs = resolve(project.dir, copyPath);
|
||||
if (!isSafePath(project.dir, destAbs)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
|
||||
ensureDir(destAbs);
|
||||
writeFileSync(destAbs, readFileSync(srcAbs));
|
||||
|
||||
return c.json({ ok: true, path: copyPath }, 201);
|
||||
});
|
||||
}
|
||||
|
||||
+131
-3
@@ -24,8 +24,7 @@ export function StudioApp() {
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [resolving, setResolving] = useState(true);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
|
||||
if (hashMatch) {
|
||||
setProjectId(hashMatch[1]);
|
||||
@@ -44,7 +43,7 @@ export function StudioApp() {
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setResolving(false));
|
||||
}, []);
|
||||
});
|
||||
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
|
||||
@@ -246,6 +245,129 @@ export function StudioApp() {
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
// ── File Management Handlers ──
|
||||
|
||||
const refreshFileTree = useCallback(async () => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}`);
|
||||
const data = await res.json();
|
||||
if (data.files) setFileTree(data.files);
|
||||
}, []);
|
||||
|
||||
const handleCreateFile = useCallback(
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
let content = "";
|
||||
if (path.endsWith(".html")) {
|
||||
content =
|
||||
'<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n\n</body>\n</html>\n';
|
||||
}
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: content,
|
||||
});
|
||||
if (res.ok) {
|
||||
await refreshFileTree();
|
||||
handleFileSelect(path);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Create file failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
// Create a .gitkeep inside the folder so it appears in the tree
|
||||
const res = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent(path + "/.gitkeep")}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: "",
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
await refreshFileTree();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Create folder failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree],
|
||||
);
|
||||
|
||||
const handleDeleteFile = useCallback(
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
if (editingPathRef.current === path) setEditingFile(null);
|
||||
await refreshFileTree();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Delete failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree],
|
||||
);
|
||||
|
||||
const handleRenameFile = useCallback(
|
||||
async (oldPath: string, newPath: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(oldPath)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newPath }),
|
||||
});
|
||||
if (res.ok) {
|
||||
if (editingPathRef.current === oldPath) {
|
||||
handleFileSelect(newPath);
|
||||
}
|
||||
await refreshFileTree();
|
||||
// Refresh preview — references in compositions may have been updated
|
||||
setRefreshKey((k) => k + 1);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Rename failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect],
|
||||
);
|
||||
|
||||
const handleDuplicateFile = useCallback(
|
||||
async (path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const res = await fetch(`/api/projects/${pid}/duplicate-file`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
await refreshFileTree();
|
||||
if (data.path) handleFileSelect(data.path);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Duplicate failed: ${err.error}`);
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect],
|
||||
);
|
||||
|
||||
const handleMoveFile = handleRenameFile;
|
||||
|
||||
const handleLint = useCallback(async () => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
@@ -433,6 +555,12 @@ export function StudioApp() {
|
||||
fileTree={fileTree}
|
||||
editingFile={editingFile}
|
||||
onSelectFile={handleFileSelect}
|
||||
onCreateFile={handleCreateFile}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onRenameFile={handleRenameFile}
|
||||
onDuplicateFile={handleDuplicateFile}
|
||||
onMoveFile={handleMoveFile}
|
||||
codeChildren={
|
||||
editingFile ? (
|
||||
isMediaFile(editingFile.path) ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState, useCallback, useMemo } from "react";
|
||||
import { memo, useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||
import {
|
||||
FileHtml,
|
||||
FileCss,
|
||||
@@ -17,18 +17,64 @@ import {
|
||||
Waveform,
|
||||
TextAa,
|
||||
Image as PhImage,
|
||||
PencilSimple,
|
||||
Copy,
|
||||
Trash,
|
||||
Plus,
|
||||
FolderSimplePlus,
|
||||
FilePlus,
|
||||
FolderSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
|
||||
|
||||
interface FileTreeProps {
|
||||
// ── Types ──
|
||||
|
||||
export interface FileTreeProps {
|
||||
files: string[];
|
||||
activeFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
onCreateFile?: (path: string) => void;
|
||||
onCreateFolder?: (path: string) => void;
|
||||
onDeleteFile?: (path: string) => void;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => void;
|
||||
onDuplicateFile?: (path: string) => void;
|
||||
onMoveFile?: (oldPath: string, newPath: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
children: Map<string, TreeNode>;
|
||||
isFile: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
targetPath: string;
|
||||
targetIsFolder: boolean;
|
||||
}
|
||||
|
||||
interface InlineInputState {
|
||||
/** Parent folder path (empty string for root) */
|
||||
parentPath: string;
|
||||
/** "file" or "folder" creation, or "rename" */
|
||||
mode: "new-file" | "new-folder" | "rename";
|
||||
/** For rename mode, the original full path */
|
||||
originalPath?: string;
|
||||
/** For rename mode, the original name */
|
||||
originalName?: string;
|
||||
onCommit?: (name: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const SZ = 14;
|
||||
const W = "duotone" as const;
|
||||
|
||||
// ── FileIcon ──
|
||||
|
||||
function FileIcon({ path }: { path: string }) {
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const c = "flex-shrink-0";
|
||||
@@ -59,12 +105,7 @@ function FileIcon({ path }: { path: string }) {
|
||||
return <File size={SZ} weight={W} color="#6B7280" className={c} />;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
children: Map<string, TreeNode>;
|
||||
isFile: boolean;
|
||||
}
|
||||
// ── Tree Helpers ──
|
||||
|
||||
function buildTree(files: string[]): TreeNode {
|
||||
const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
|
||||
@@ -102,83 +143,476 @@ function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
|
||||
});
|
||||
}
|
||||
|
||||
function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
|
||||
if (!activeFile) return false;
|
||||
if (node.fullPath === activeFile) return true;
|
||||
for (const child of node.children.values()) {
|
||||
if (isActiveInSubtree(child, activeFile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Context Menu Component ──
|
||||
|
||||
function ContextMenu({
|
||||
state,
|
||||
onClose,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onRename,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: {
|
||||
state: ContextMenuState;
|
||||
onClose: () => void;
|
||||
onNewFile: (parentPath: string) => void;
|
||||
onNewFolder: (parentPath: string) => void;
|
||||
onRename: (path: string) => void;
|
||||
onDuplicate: (path: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Adjust position so menu doesn't overflow viewport
|
||||
const adjustedX = Math.min(state.x, window.innerWidth - 180);
|
||||
const adjustedY = Math.min(state.y, window.innerHeight - 200);
|
||||
|
||||
const parentPath = state.targetIsFolder
|
||||
? state.targetPath
|
||||
: state.targetPath.includes("/")
|
||||
? state.targetPath.slice(0, state.targetPath.lastIndexOf("/"))
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
>
|
||||
{state.targetIsFolder && (
|
||||
<>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onNewFile(state.targetPath);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
||||
New File
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onNewFolder(state.targetPath);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<FolderSimplePlus size={12} weight="duotone" className="text-neutral-500" />
|
||||
New Folder
|
||||
</button>
|
||||
<div className="border-t border-neutral-700 my-1" />
|
||||
</>
|
||||
)}
|
||||
{!state.targetIsFolder && (
|
||||
<>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onNewFile(parentPath);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<FilePlus size={12} weight="duotone" className="text-neutral-500" />
|
||||
New File
|
||||
</button>
|
||||
<div className="border-t border-neutral-700 my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onRename(state.targetPath);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<PencilSimple size={12} weight="duotone" className="text-neutral-500" />
|
||||
Rename
|
||||
</button>
|
||||
{!state.targetIsFolder && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDuplicate(state.targetPath);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Copy size={12} weight="duotone" className="text-neutral-500" />
|
||||
Duplicate
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-neutral-700 my-1" />
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDelete(state.targetPath);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Trash size={12} weight="duotone" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline Input (for new file/folder/rename) ──
|
||||
|
||||
function InlineInput({
|
||||
defaultValue,
|
||||
depth,
|
||||
isFolder,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
defaultValue: string;
|
||||
depth: number;
|
||||
isFolder: boolean;
|
||||
onCommit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
// Select just the filename (not extension) for rename
|
||||
if (defaultValue && defaultValue.includes(".")) {
|
||||
const dotIdx = defaultValue.lastIndexOf(".");
|
||||
el.setSelectionRange(0, dotIdx);
|
||||
} else {
|
||||
el.select();
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
const commit = (name: string) => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(name);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
|
||||
else onCancel();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
|
||||
commit(trimmed);
|
||||
else onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-0.5 min-h-7"
|
||||
style={{ paddingLeft: `${8 + depth * 12 + (isFolder ? 0 : 14)}px` }}
|
||||
>
|
||||
{isFolder ? (
|
||||
<FolderSimple size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />
|
||||
) : (
|
||||
<FileIcon path={value} />
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className="flex-1 min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-[#3CE6AC]"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Delete Confirmation ──
|
||||
|
||||
function DeleteConfirm({
|
||||
name,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
name: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
|
||||
>
|
||||
<p className="text-neutral-300 mb-2">
|
||||
Delete <span className="font-medium text-neutral-100">{name}</span>?
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="flex-1 px-2 py-1 rounded bg-red-900/60 text-red-300 hover:bg-red-800/60 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TreeFolder ──
|
||||
|
||||
function TreeFolder({
|
||||
node,
|
||||
depth,
|
||||
activeFile,
|
||||
onSelectFile,
|
||||
defaultOpen,
|
||||
onContextMenu,
|
||||
inlineInput,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragLeave,
|
||||
dragOverFolder,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
activeFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
defaultOpen: boolean;
|
||||
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
||||
inlineInput: InlineInputState | null;
|
||||
onDragStart: (e: React.DragEvent, path: string) => void;
|
||||
onDragOver: (e: React.DragEvent, folderPath: string) => void;
|
||||
onDrop: (e: React.DragEvent, folderPath: string) => void;
|
||||
onDragLeave: () => void;
|
||||
dragOverFolder: string | null;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
||||
const children = sortChildren(node.children);
|
||||
const children = useMemo(() => sortChildren(node.children), [node.children]);
|
||||
const Chevron = isOpen ? ChevronDown : ChevronRight;
|
||||
const isDragOver = dragOverFolder === node.fullPath;
|
||||
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<InlineInput
|
||||
defaultValue={inlineInput.originalName ?? node.name}
|
||||
depth={depth}
|
||||
isFolder={true}
|
||||
onCommit={(name) => {
|
||||
inlineInput?.onCommit?.(name);
|
||||
}}
|
||||
onCancel={() => {
|
||||
inlineInput?.onCancel?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="w-full flex items-center gap-1.5 px-2.5 py-1 min-h-7 text-left text-xs text-neutral-400 hover:bg-neutral-800/30 hover:text-neutral-300 transition-colors"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e, node.fullPath, true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragOver(e, node.fullPath);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDrop(e, node.fullPath);
|
||||
}}
|
||||
onDragLeave={onDragLeave}
|
||||
className={`w-full flex items-center gap-1.5 px-2.5 py-1 min-h-7 text-left text-xs text-neutral-400 hover:bg-neutral-800/30 hover:text-neutral-300 transition-colors ${
|
||||
isDragOver ? "bg-[#3CE6AC]/10 outline outline-1 outline-[#3CE6AC]/40" : ""
|
||||
}`}
|
||||
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||
>
|
||||
<Chevron size={10} className="flex-shrink-0 text-neutral-600" />
|
||||
<span className="truncate font-medium">{node.name}</span>
|
||||
</button>
|
||||
{isOpen &&
|
||||
children.map((child) =>
|
||||
child.isFile && child.children.size === 0 ? (
|
||||
<TreeFile
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
/>
|
||||
) : child.children.size > 0 ? (
|
||||
<TreeFolder
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
defaultOpen={isActiveInSubtree(child, activeFile)}
|
||||
/>
|
||||
) : (
|
||||
<TreeFile
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Inline input for new file/folder inside this folder */}
|
||||
{inlineInput &&
|
||||
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
|
||||
inlineInput.parentPath === node.fullPath && (
|
||||
<InlineInput
|
||||
defaultValue=""
|
||||
depth={depth + 1}
|
||||
isFolder={inlineInput.mode === "new-folder"}
|
||||
onCommit={(name) => {
|
||||
// onCommit is handled by the parent FileTree component
|
||||
// via the inlineInputCommit callback
|
||||
inlineInput?.onCommit?.(name);
|
||||
}}
|
||||
onCancel={() => {
|
||||
inlineInput?.onCancel?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children.map((child) =>
|
||||
child.isFile && child.children.size === 0 ? (
|
||||
<TreeFile
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onContextMenu={onContextMenu}
|
||||
inlineInput={inlineInput}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
) : child.children.size > 0 ? (
|
||||
<TreeFolder
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
defaultOpen={isActiveInSubtree(child, activeFile)}
|
||||
onContextMenu={onContextMenu}
|
||||
inlineInput={inlineInput}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragLeave={onDragLeave}
|
||||
dragOverFolder={dragOverFolder}
|
||||
/>
|
||||
) : (
|
||||
<TreeFile
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onContextMenu={onContextMenu}
|
||||
inlineInput={inlineInput}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TreeFile ──
|
||||
|
||||
function TreeFile({
|
||||
node,
|
||||
depth,
|
||||
activeFile,
|
||||
onSelectFile,
|
||||
onContextMenu,
|
||||
inlineInput,
|
||||
onDragStart,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
activeFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, path: string, isFolder: boolean) => void;
|
||||
inlineInput: InlineInputState | null;
|
||||
onDragStart: (e: React.DragEvent, path: string) => void;
|
||||
}) {
|
||||
const isActive = node.fullPath === activeFile;
|
||||
const isRenaming = inlineInput?.mode === "rename" && inlineInput.originalPath === node.fullPath;
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<InlineInput
|
||||
defaultValue={inlineInput.originalName ?? node.name}
|
||||
depth={depth}
|
||||
isFolder={false}
|
||||
onCommit={(name) => {
|
||||
inlineInput?.onCommit?.(name);
|
||||
}}
|
||||
onCancel={() => {
|
||||
inlineInput?.onCancel?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, node.fullPath)}
|
||||
onClick={() => onSelectFile(node.fullPath)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e, node.fullPath, false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
|
||||
isActive
|
||||
? "bg-neutral-800/60 text-neutral-200"
|
||||
@@ -192,22 +626,229 @@ function TreeFile({
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
|
||||
if (!activeFile) return false;
|
||||
if (node.fullPath === activeFile) return true;
|
||||
for (const child of node.children.values()) {
|
||||
if (isActiveInSubtree(child, activeFile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// ── Main FileTree Component ──
|
||||
|
||||
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
||||
export const FileTree = memo(function FileTree({
|
||||
files,
|
||||
activeFile,
|
||||
onSelectFile,
|
||||
onCreateFile,
|
||||
onCreateFolder,
|
||||
onDeleteFile,
|
||||
onRenameFile,
|
||||
onDuplicateFile,
|
||||
onMoveFile,
|
||||
}: FileTreeProps) {
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
const children = useMemo(() => sortChildren(tree.children), [tree]);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [inlineInput, setInlineInput] = useState<InlineInputState | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
const dragSourceRef = useRef<string | null>(null);
|
||||
|
||||
const hasFileOps = !!(
|
||||
onCreateFile ||
|
||||
onCreateFolder ||
|
||||
onDeleteFile ||
|
||||
onRenameFile ||
|
||||
onDuplicateFile
|
||||
);
|
||||
|
||||
// ── Context Menu handlers ──
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, path: string, isFolder: boolean) => {
|
||||
if (!hasFileOps) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, targetPath: path, targetIsFolder: isFolder });
|
||||
},
|
||||
[hasFileOps],
|
||||
);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => setContextMenu(null), []);
|
||||
|
||||
// ── New File ──
|
||||
|
||||
const handleNewFile = useCallback(
|
||||
(parentPath: string) => {
|
||||
setInlineInput({
|
||||
parentPath,
|
||||
mode: "new-file",
|
||||
onCommit: (name: string) => {
|
||||
const fullPath = parentPath ? `${parentPath}/${name}` : name;
|
||||
onCreateFile?.(fullPath);
|
||||
setInlineInput(null);
|
||||
},
|
||||
onCancel: () => setInlineInput(null),
|
||||
});
|
||||
},
|
||||
[onCreateFile],
|
||||
);
|
||||
|
||||
// ── New Folder ──
|
||||
|
||||
const handleNewFolder = useCallback(
|
||||
(parentPath: string) => {
|
||||
setInlineInput({
|
||||
parentPath,
|
||||
mode: "new-folder",
|
||||
onCommit: (name: string) => {
|
||||
const fullPath = parentPath ? `${parentPath}/${name}` : name;
|
||||
onCreateFolder?.(fullPath);
|
||||
setInlineInput(null);
|
||||
},
|
||||
onCancel: () => setInlineInput(null),
|
||||
});
|
||||
},
|
||||
[onCreateFolder],
|
||||
);
|
||||
|
||||
// ── Rename ──
|
||||
|
||||
const handleRename = useCallback(
|
||||
(path: string) => {
|
||||
const name = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path;
|
||||
const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
||||
setInlineInput({
|
||||
parentPath,
|
||||
mode: "rename",
|
||||
originalPath: path,
|
||||
originalName: name,
|
||||
onCommit: (newName: string) => {
|
||||
if (newName !== name) {
|
||||
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
||||
onRenameFile?.(path, newPath);
|
||||
}
|
||||
setInlineInput(null);
|
||||
},
|
||||
onCancel: () => setInlineInput(null),
|
||||
});
|
||||
},
|
||||
[onRenameFile],
|
||||
);
|
||||
|
||||
// ── Duplicate ──
|
||||
|
||||
const handleDuplicate = useCallback(
|
||||
(path: string) => {
|
||||
onDuplicateFile?.(path);
|
||||
},
|
||||
[onDuplicateFile],
|
||||
);
|
||||
|
||||
// ── Delete ──
|
||||
|
||||
const handleDelete = useCallback((path: string) => {
|
||||
setDeleteTarget(path);
|
||||
}, []);
|
||||
|
||||
// Since DeleteConfirm is rendered inside TreeFile, we need callbacks on that component.
|
||||
// Instead, let's use a portal-style approach: render the confirm at the FileTree level.
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (deleteTarget) {
|
||||
onDeleteFile?.(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteTarget, onDeleteFile]);
|
||||
|
||||
const handleDeleteCancel = useCallback(() => {
|
||||
setDeleteTarget(null);
|
||||
}, []);
|
||||
|
||||
// ── Drag and Drop ──
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, path: string) => {
|
||||
dragSourceRef.current = path;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", path);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((_e: React.DragEvent, folderPath: string) => {
|
||||
setDragOverFolder(folderPath);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(_e: React.DragEvent, folderPath: string) => {
|
||||
const sourcePath = dragSourceRef.current;
|
||||
if (!sourcePath || !onMoveFile) {
|
||||
setDragOverFolder(null);
|
||||
return;
|
||||
}
|
||||
// Extract filename from source path
|
||||
const fileName = sourcePath.includes("/")
|
||||
? sourcePath.slice(sourcePath.lastIndexOf("/") + 1)
|
||||
: sourcePath;
|
||||
const newPath = folderPath ? `${folderPath}/${fileName}` : fileName;
|
||||
// Don't move to same location or into own subtree
|
||||
if (newPath !== sourcePath && !folderPath.startsWith(sourcePath + "/")) {
|
||||
onMoveFile(sourcePath, newPath);
|
||||
}
|
||||
setDragOverFolder(null);
|
||||
dragSourceRef.current = null;
|
||||
},
|
||||
[onMoveFile],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverFolder(null);
|
||||
}, []);
|
||||
|
||||
// ── Root-level context menu (right-click on empty space) ──
|
||||
|
||||
const handleRootContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!hasFileOps) return;
|
||||
// Only trigger if clicking directly on the container, not on a file/folder button
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, targetPath: "", targetIsFolder: true });
|
||||
}
|
||||
},
|
||||
[hasFileOps],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{/* FILES header with action buttons */}
|
||||
{hasFileOps && (
|
||||
<div className="flex items-center justify-between px-2.5 py-1.5 border-b border-neutral-800/50 flex-shrink-0">
|
||||
<span className="text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
|
||||
Files
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => handleNewFile("")}
|
||||
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
|
||||
title="New File"
|
||||
>
|
||||
<Plus size={12} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleNewFolder("")}
|
||||
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
|
||||
title="New Folder"
|
||||
>
|
||||
<FolderSimplePlus size={12} weight="duotone" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-1" onContextMenu={handleRootContextMenu}>
|
||||
{/* Root-level inline input for new file/folder */}
|
||||
{inlineInput &&
|
||||
(inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") &&
|
||||
inlineInput.parentPath === "" && (
|
||||
<InlineInput
|
||||
defaultValue=""
|
||||
depth={0}
|
||||
isFolder={inlineInput.mode === "new-folder"}
|
||||
onCommit={(name) => inlineInput.onCommit?.(name)}
|
||||
onCancel={() => inlineInput.onCancel?.()}
|
||||
/>
|
||||
)}
|
||||
{children.map((child) =>
|
||||
child.isFile && child.children.size === 0 ? (
|
||||
<TreeFile
|
||||
@@ -216,6 +857,9 @@ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile
|
||||
depth={0}
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
onContextMenu={handleContextMenu}
|
||||
inlineInput={inlineInput}
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
) : (
|
||||
<TreeFolder
|
||||
@@ -225,10 +869,45 @@ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile
|
||||
activeFile={activeFile}
|
||||
onSelectFile={onSelectFile}
|
||||
defaultOpen={isActiveInSubtree(child, activeFile)}
|
||||
onContextMenu={handleContextMenu}
|
||||
inlineInput={inlineInput}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragLeave={handleDragLeave}
|
||||
dragOverFolder={dragOverFolder}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation overlay */}
|
||||
{deleteTarget && (
|
||||
<div className="border-t border-neutral-800/50 flex-shrink-0">
|
||||
<DeleteConfirm
|
||||
name={
|
||||
deleteTarget.includes("/")
|
||||
? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1)
|
||||
: deleteTarget
|
||||
}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
state={contextMenu}
|
||||
onClose={handleCloseContextMenu}
|
||||
onNewFile={handleNewFile}
|
||||
onNewFolder={handleNewFolder}
|
||||
onRename={handleRename}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ interface AssetsTabProps {
|
||||
projectId: string;
|
||||
assets: string[];
|
||||
onImport?: (files: FileList) => void;
|
||||
onDelete?: (path: string) => void;
|
||||
onRename?: (oldPath: string, newPath: string) => void;
|
||||
}
|
||||
|
||||
/** Inline thumbnail content — rendered inside the container div in AssetCard. */
|
||||
@@ -82,61 +84,199 @@ function AssetCard({
|
||||
asset,
|
||||
onCopy,
|
||||
isCopied,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: {
|
||||
projectId: string;
|
||||
asset: string;
|
||||
onCopy: (path: string) => void;
|
||||
isCopied: boolean;
|
||||
onDelete?: (path: string) => void;
|
||||
onRename?: (oldPath: string, newPath: string) => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const name = asset.split("/").pop() ?? asset;
|
||||
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
||||
const isVideo = VIDEO_EXT.test(asset);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onCopy(asset)}
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
||||
isCopied
|
||||
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<AssetThumbnail
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
isImage={IMAGE_EXT.test(asset)}
|
||||
isVideo={isVideo}
|
||||
isAudio={AUDIO_EXT.test(asset)}
|
||||
/>
|
||||
{/* Inline video autoplay on hover — same pattern as renders */}
|
||||
{isVideo && hovered && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
<>
|
||||
<div
|
||||
onClick={() => onCopy(asset)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
||||
isCopied
|
||||
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<AssetThumbnail
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
isImage={IMAGE_EXT.test(asset)}
|
||||
isVideo={isVideo}
|
||||
isAudio={AUDIO_EXT.test(asset)}
|
||||
/>
|
||||
)}
|
||||
{isVideo && hovered && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const trimmed = renameName.trim();
|
||||
if (trimmed && trimmed !== name) {
|
||||
const dir = asset.includes("/")
|
||||
? asset.slice(0, asset.lastIndexOf("/") + 1)
|
||||
: "";
|
||||
onRename?.(asset, dir + trimmed);
|
||||
}
|
||||
setRenaming(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const trimmed = renameName.trim();
|
||||
if (trimmed && trimmed !== name) {
|
||||
const dir = asset.includes("/") ? asset.slice(0, asset.lastIndexOf("/") + 1) : "";
|
||||
onRename?.(asset, dir + trimmed);
|
||||
}
|
||||
setRenaming(false);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full bg-neutral-800 text-neutral-200 text-[11px] px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-studio-accent"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">
|
||||
{name}
|
||||
</span>
|
||||
{isCopied ? (
|
||||
<span className="text-[9px] text-studio-accent">Copied!</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
||||
{isCopied ? (
|
||||
<span className="text-[9px] text-studio-accent">Copied!</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200]"
|
||||
onClick={() => setContextMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy(asset);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Copy path
|
||||
</button>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameName(name);
|
||||
setRenaming(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{confirmDelete && (
|
||||
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(asset);
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) {
|
||||
export const AssetsTab = memo(function AssetsTab({
|
||||
projectId,
|
||||
assets,
|
||||
onImport,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: AssetsTabProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||
@@ -239,6 +379,8 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
|
||||
asset={asset}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === asset}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,12 @@ interface LeftSidebarProps {
|
||||
fileTree?: string[];
|
||||
editingFile?: { path: string; content: string | null } | null;
|
||||
onSelectFile?: (path: string) => void;
|
||||
onCreateFile?: (path: string) => void;
|
||||
onCreateFolder?: (path: string) => void;
|
||||
onDeleteFile?: (path: string) => void;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => void;
|
||||
onDuplicateFile?: (path: string) => void;
|
||||
onMoveFile?: (oldPath: string, newPath: string) => void;
|
||||
codeChildren?: ReactNode;
|
||||
onLint?: () => void;
|
||||
linting?: boolean;
|
||||
@@ -42,6 +48,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
fileTree: fileProp,
|
||||
editingFile,
|
||||
onSelectFile,
|
||||
onCreateFile,
|
||||
onCreateFolder,
|
||||
onDeleteFile,
|
||||
onRenameFile,
|
||||
onDuplicateFile,
|
||||
onMoveFile,
|
||||
codeChildren,
|
||||
onLint,
|
||||
linting,
|
||||
@@ -122,16 +134,28 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
/>
|
||||
)}
|
||||
{tab === "assets" && (
|
||||
<AssetsTab projectId={projectId} assets={assets} onImport={onImportFiles} />
|
||||
<AssetsTab
|
||||
projectId={projectId}
|
||||
assets={assets}
|
||||
onImport={onImportFiles}
|
||||
onDelete={onDeleteFile}
|
||||
onRename={onRenameFile}
|
||||
/>
|
||||
)}
|
||||
{tab === "code" && (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{(fileProp?.length ?? 0) > 0 && (
|
||||
<div className="w-[140px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
|
||||
<div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
|
||||
<FileTree
|
||||
files={fileProp ?? []}
|
||||
activeFile={editingFile?.path ?? null}
|
||||
onSelectFile={onSelectFile ?? (() => {})}
|
||||
onCreateFile={onCreateFile}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDuplicateFile={onDuplicateFile}
|
||||
onMoveFile={onMoveFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user