diff --git a/bun.lock b/bun.lock index 6f9b39a0e..5e154cff3 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.4.11", + "version": "0.4.13-alpha.4", "bin": { "hyperframes": "./dist/cli.js", }, @@ -62,7 +62,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.4.11", + "version": "0.4.13-alpha.4", "dependencies": { "@chenglou/pretext": "^0.0.5", }, @@ -88,7 +88,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.4.11", + "version": "0.4.13-alpha.4", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -106,7 +106,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.4.11", + "version": "0.4.13-alpha.4", "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0", @@ -115,7 +115,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.4.11", + "version": "0.4.13-alpha.4", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -154,7 +154,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.4.11", + "version": "0.4.13-alpha.4", "dependencies": { "html2canvas": "^1.4.1", }, @@ -166,7 +166,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.4.11", + "version": "0.4.13-alpha.4", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -181,6 +181,7 @@ "@hyperframes/core": "workspace:*", "@hyperframes/player": "workspace:*", "@phosphor-icons/react": "^2.1.10", + "@pierre/trees": "^1.0.0-beta.3", "codemirror": "^6.0.1", "motion": "^12.38.0", }, @@ -646,6 +647,8 @@ "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": "18.3.1", "react-dom": "18.3.1" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@pierre/trees": ["@pierre/trees@1.0.0-beta.3", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-gfV7V1AoceIwTSFwiiWl/89gNtJROyo2dFeYYuAkT4F3AbE+ajCIGZEICBw1ygmVxVetF9Kq1Xpjz8BhXXZwTQ=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -1334,6 +1337,10 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], + + "preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 836bf107e..508edad17 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -39,6 +39,7 @@ "@hyperframes/core": "workspace:*", "@hyperframes/player": "workspace:*", "@phosphor-icons/react": "^2.1.10", + "@pierre/trees": "^1.0.0-beta.3", "codemirror": "^6.0.1", "motion": "^12.38.0" }, diff --git a/packages/studio/src/components/editor/FileTree.test.ts b/packages/studio/src/components/editor/FileTree.test.ts new file mode 100644 index 000000000..1f7ce0bf9 --- /dev/null +++ b/packages/studio/src/components/editor/FileTree.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + buildMoveDestinationPath, + buildStudioTreePaths, + createPlaceholderPath, + getDropPathData, + isPendingCreateCleared, +} from "./FileTree"; + +describe("buildStudioTreePaths", () => { + it("converts .gitkeep placeholders into visible folders", () => { + expect( + new Set( + buildStudioTreePaths([ + "index.html", + "assets/.gitkeep", + "nested/empty/.gitkeep", + "src/main.ts", + ]), + ), + ).toEqual(new Set(["index.html", "assets/", "nested/empty/", "src/main.ts"])); + }); + + it("deduplicates repeated paths", () => { + expect(buildStudioTreePaths(["assets/.gitkeep", "assets/.gitkeep", "assets/logo.png"])).toEqual( + ["assets/", "assets/logo.png"], + ); + }); +}); + +describe("createPlaceholderPath", () => { + it("creates unique file placeholders inside a folder", () => { + expect(createPlaceholderPath(["src/untitled", "src/untitled-2"], "src", "file")).toBe( + "src/untitled-3", + ); + }); + + it("creates unique folder placeholders at the root", () => { + expect(createPlaceholderPath(["new-folder/", "new-folder-2/"], "", "folder")).toBe( + "new-folder-3/", + ); + }); +}); + +describe("getDropPathData", () => { + it("uses the hovered folder path", () => { + expect( + getDropPathData([ + { itemPath: "src/", itemType: "folder" }, + { itemPath: "src/index.ts", itemType: "file" }, + ]), + ).toBe("src/"); + }); + + it("falls back to a file parent path", () => { + expect( + getDropPathData([{ itemParentPath: "src/", itemPath: "src/index.ts", itemType: "file" }]), + ).toBe("src/"); + }); + + it("falls back to the root when there is no row target", () => { + expect(getDropPathData([])).toBe(""); + }); +}); + +describe("buildMoveDestinationPath", () => { + it("builds a root move destination", () => { + expect(buildMoveDestinationPath("src/index.ts", null)).toBe("index.ts"); + }); + + it("builds a folder move destination for files and folders", () => { + expect(buildMoveDestinationPath("src/index.ts", "assets/")).toBe("assets/index.ts"); + expect(buildMoveDestinationPath("src/components/", "assets/")).toBe("assets/components"); + }); +}); + +describe("isPendingCreateCleared", () => { + it("keeps pending creates alive across rename moves", () => { + expect( + isPendingCreateCleared( + { operation: "move", from: "untitled", to: "record-note.html" }, + "untitled", + ), + ).toBe(false); + }); + + it("clears pending creates when the placeholder is removed", () => { + expect(isPendingCreateCleared({ operation: "remove", path: "untitled" }, "untitled")).toBe( + true, + ); + }); + + it("clears pending creates on model reset", () => { + expect(isPendingCreateCleared({ operation: "reset" }, "untitled")).toBe(true); + }); +}); diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index 05d598290..ed5470436 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -1,418 +1,426 @@ -import { memo, useState, useCallback, useMemo, useRef, useEffect } from "react"; import { - FileHtml, - FileCss, - FileJs, - FileJsx, - FileTs, - FileTsx, - FileTxt, - FileMd, - FileSvg, - FilePng, - FileJpg, - FileVideo, - FileCode, - File, - Waveform, - TextAa, - Image as PhImage, - PencilSimple, - Copy, - Trash, - Plus, - FolderSimplePlus, - FilePlus, - FolderSimple, -} from "@phosphor-icons/react"; -import { ChevronDown, ChevronRight } from "../../icons/SystemIcons"; - -// ── Types ── + FILE_TREE_TAG_NAME, + type ContextMenuItem, + type ContextMenuOpenContext, + type FileTreeDirectoryHandle, + type FileTreeItemHandle, + type FileTree as PierreTreeModel, + type FileTreeMutationEvent, + type FileTreeSortComparator, +} from "@pierre/trees"; +import { FileTree as PierreFileTree, useFileTree } from "@pierre/trees/react"; +import { Copy, FilePlus, FolderSimplePlus, PencilSimple, Plus, Trash } from "@phosphor-icons/react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; + +type FileTreeActionResult = void | Promise; 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; - onImportFiles?: (files: FileList, dir?: string) => void; + onCreateFile?: (path: string) => FileTreeActionResult; + onCreateFolder?: (path: string) => FileTreeActionResult; + onDeleteFile?: (path: string) => FileTreeActionResult; + onRenameFile?: (oldPath: string, newPath: string) => FileTreeActionResult; + onDuplicateFile?: (path: string) => FileTreeActionResult; + onMoveFile?: (oldPath: string, newPath: string) => FileTreeActionResult; + onImportFiles?: (files: FileList, dir?: string) => FileTreeActionResult; } -interface TreeNode { +interface DeleteConfirmProps { name: string; - fullPath: string; - children: Map; - isFile: boolean; + onConfirm: () => void; + onCancel: () => void; } -interface ContextMenuState { +interface RootContextMenuState { 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; +interface TreeActionMenuProps { + item?: ContextMenuItem; + onNewFile?: () => void; + onNewFolder?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + onDelete?: () => void; +} + +interface RootContextMenuProps extends RootContextMenuState { + onClose: () => void; + onNewFile?: () => void; + onNewFolder?: () => void; +} + +interface PendingCreateState { + kind: "file" | "folder"; + placeholderPath: string; +} + +interface DropPathData { + itemParentPath?: string; + itemPath?: string; + itemType?: string; +} + +const TREE_HOST_STYLE = { + "--trees-accent-override": "#3CE6AC", + "--trees-bg-override": "#0a0a0a", + "--trees-bg-muted-override": "rgba(38, 38, 38, 0.7)", + "--trees-border-color-override": "rgba(38, 38, 38, 0.8)", + "--trees-fg-override": "#a3a3a3", + "--trees-fg-muted-override": "#737373", + "--trees-font-family-override": "inherit", + "--trees-font-size-override": "12px", + "--trees-item-margin-x-override": "0px", + "--trees-item-padding-x-override": "8px", + "--trees-item-row-gap-override": "6px", + "--trees-level-gap-override": "8px", + "--trees-padding-inline-override": "8px", + "--trees-search-bg-override": "#171717", + "--trees-search-fg-override": "#d4d4d8", + "--trees-selected-bg-override": "rgba(60, 230, 172, 0.12)", + "--trees-selected-fg-override": "#e5e7eb", + "--trees-selected-focused-border-color-override": "rgba(60, 230, 172, 0.45)", + height: "100%", +} as CSSProperties; + +const TREE_UNSAFE_CSS = ` + [data-studio-external-drag-target='true'] { + background-color: var(--trees-selected-bg); + } +`; + +const compareStudioTreeEntries: FileTreeSortComparator = (left, right) => { + if (left.basename === "index.html" && right.basename !== "index.html") return -1; + if (right.basename === "index.html" && left.basename !== "index.html") return 1; + if (left.isDirectory !== right.isDirectory) return left.isDirectory ? -1 : 1; + return left.path.localeCompare(right.path, undefined, { numeric: true }); +}; + +function isCanonicalDirectoryPath(path: string): boolean { + return path.endsWith("/"); } -// ── 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"; - if (ext === "html") return ; - if (ext === "css") return ; - if (ext === "js" || ext === "mjs" || ext === "cjs") - return ; - if (ext === "jsx") return ; - if (ext === "ts" || ext === "mts") - return ; - if (ext === "tsx") return ; - if (ext === "json") return ; - if (ext === "svg") return ; - if (ext === "md" || ext === "mdx") - return ; - if (ext === "txt") return ; - if (ext === "png") return ; - if (ext === "jpg" || ext === "jpeg") - return ; - if (ext === "webp" || ext === "gif" || ext === "ico") - return ; - if (ext === "mp4" || ext === "webm" || ext === "mov") - return ; - if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a") - return ; - if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf") - return ; - return ; +function toCanonicalDirectoryPath(path: string): string { + return isCanonicalDirectoryPath(path) ? path : `${path}/`; } -// ── Tree Helpers ── +function toPublicPath(path: string): string { + return isCanonicalDirectoryPath(path) ? path.slice(0, -1) : path; +} -function buildTree(files: string[]): TreeNode { - const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false }; +function getPathBasename(path: string): string { + const normalized = toPublicPath(path); + const index = normalized.lastIndexOf("/"); + return index >= 0 ? normalized.slice(index + 1) : normalized; +} + +function getCanonicalParentDirectoryPath(path: string): string | null { + const normalized = toPublicPath(path); + const index = normalized.lastIndexOf("/"); + if (index < 0) return null; + return `${normalized.slice(0, index + 1)}`; +} + +function getAncestorDirectoryPaths(path: string): string[] { + const normalized = toPublicPath(path); + if (!normalized) return []; + const segments = normalized.split("/"); + return segments.slice(0, -1).map((_, index) => `${segments.slice(0, index + 1).join("/")}/`); +} + +function buildStudioTreePaths(files: string[]): string[] { + const deduped = new Set(); for (const file of files) { - const parts = file.split("/"); - let current = root; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isLast = i === parts.length - 1; - const fullPath = parts.slice(0, i + 1).join("/"); - if (!current.children.has(part)) { - current.children.set(part, { - name: part, - fullPath, - children: new Map(), - isFile: isLast, - }); - } - current = current.children.get(part)!; - if (isLast) current.isFile = true; + if (file.endsWith("/.gitkeep")) { + const folderPath = file.slice(0, -"/.gitkeep".length); + if (folderPath) deduped.add(toCanonicalDirectoryPath(folderPath)); + continue; } + deduped.add(file); } - return root; + return Array.from(deduped); } -function sortChildren(children: Map): TreeNode[] { - return Array.from(children.values()).sort((a, b) => { - // index.html always first - if (a.name === "index.html") return -1; - if (b.name === "index.html") return 1; - // Directories before files - if (!a.isFile && b.isFile) return -1; - if (a.isFile && !b.isFile) return 1; - return a.name.localeCompare(b.name); - }); +function createPlaceholderPath( + existingPaths: readonly string[], + parentPath: string, + kind: PendingCreateState["kind"], +): string { + const existing = new Set(existingPaths); + const prefix = parentPath ? `${parentPath}/` : ""; + const stem = kind === "folder" ? "new-folder" : "untitled"; + let counter = 1; + + while (true) { + const suffix = counter === 1 ? stem : `${stem}-${counter}`; + const nextPath = `${prefix}${suffix}`; + const candidate = kind === "folder" ? toCanonicalDirectoryPath(nextPath) : nextPath; + if (!existing.has(candidate)) return candidate; + counter += 1; + } +} + +function buildMoveDestinationPath(sourcePath: string, targetDirectoryPath: string | null): string { + const baseName = getPathBasename(sourcePath); + const parentPath = targetDirectoryPath ? toPublicPath(targetDirectoryPath) : ""; + return parentPath ? `${parentPath}/${baseName}` : baseName; +} + +function getHostElement(root: HTMLElement | null): HTMLElement | null { + return root?.querySelector(FILE_TREE_TAG_NAME) ?? null; +} + +function collectExpandedPaths(host: HTMLElement | null): string[] { + if (!host?.shadowRoot) return []; + return Array.from( + host.shadowRoot.querySelectorAll( + '[data-type="item"][data-item-type="folder"][aria-expanded="true"]', + ), + ) + .map((element) => element.dataset.itemPath ?? "") + .filter((path) => path.length > 0); } -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; +function getDropPathData(elements: readonly DropPathData[]): string { + for (const element of elements) { + if (!element.itemPath) continue; + if (element.itemType === "folder") return element.itemPath; + if (element.itemParentPath) return element.itemParentPath; + return ""; } - return false; + return ""; +} + +function resolveImportTargetPath(event: DragEvent): string { + const pathData: DropPathData[] = []; + for (const entry of event.composedPath()) { + if (!(entry instanceof HTMLElement)) continue; + pathData.push({ + itemParentPath: entry.dataset.itemParentPath, + itemPath: entry.dataset.itemPath, + itemType: entry.dataset.itemType, + }); + } + return getDropPathData(pathData); +} + +function hasExternalFiles(dataTransfer: DataTransfer | null): boolean { + if (!dataTransfer) return false; + return Array.from(dataTransfer.items).some((item) => item.kind === "file"); +} + +function escapeAttributeValue(value: string): string { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(value); + } + return value.replace(/["\\]/g, "\\$&"); +} + +function isDirectoryHandle(item: FileTreeItemHandle | null): item is FileTreeDirectoryHandle { + return item?.isDirectory() === true; +} + +function renderMountedTree(model: PierreTreeModel, host: HTMLElement | null) { + if (!host) return; + model.render({ fileTreeContainer: host }); +} + +function syncSelection(model: PierreTreeModel, activeFile: string | null) { + const selectedPaths = model.getSelectedPaths(); + if (!activeFile) { + for (const path of selectedPaths) { + model.getItem(path)?.deselect(); + } + return; + } + + const target = model.getItem(activeFile); + if (!target) return; + + for (const ancestorPath of getAncestorDirectoryPaths(activeFile)) { + const ancestor = model.getItem(ancestorPath); + if (isDirectoryHandle(ancestor)) ancestor.expand(); + } + + const focusedPath = model.getFocusedPath(); + if (selectedPaths.length === 1 && selectedPaths[0] === activeFile && focusedPath === activeFile) { + return; + } + + for (const path of selectedPaths) { + if (path !== activeFile) model.getItem(path)?.deselect(); + } + + target.select(); + model.focusPath(activeFile); } -// ── Context Menu Component ── +function isPendingCreateCleared(event: FileTreeMutationEvent, pendingPath: string): boolean { + if (event.operation === "remove") return event.path === pendingPath; + if (event.operation === "batch") + return event.events.some((entry) => isPendingCreateCleared(entry, pendingPath)); + if (event.operation === "reset") return true; + return false; +} -function ContextMenu({ - state, - onClose, +function TreeActionMenu({ + item, 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(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("/")) - : ""; +}: TreeActionMenuProps) { + const isFolder = item?.kind === "directory"; + const canCreateFolder = item == null || isFolder; return ( -
- {state.targetIsFolder && ( +
+ {(onNewFile || onNewFolder) && ( <> - - -
+ {onNewFile && ( + + )} + {canCreateFolder && onNewFolder && ( + + )} + {(onRename || onDuplicate || onDelete) && ( +
+ )} )} - {!state.targetIsFolder && ( - <> - -
- + + {onRename && ( + )} - - {!state.targetIsFolder && ( + + {!isFolder && onDuplicate && ( )} -
- + + {onDelete && ( + <> + {(onRename || onDuplicate) &&
} + + + )}
); } -// ── 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(null); - const committedRef = useRef(false); - const [value, setValue] = useState(defaultValue); +function RootContextMenu({ x, y, onClose, onNewFile, onNewFolder }: RootContextMenuProps) { + const menuRef = useRef(null); - // 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 handlePointerDown = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; - 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 handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; - const handleBlur = () => { - const trimmed = value.trim(); - if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) - commit(trimmed); - else onCancel(); - }; + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [onClose]); return (
- {isFolder ? ( - - ) : ( - - )} - 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} - /> +
); } -// ── Delete Confirmation ── - -function DeleteConfirm({ - name, - onConfirm, - onCancel, -}: { - name: string; - onConfirm: () => void; - onCancel: () => void; -}) { +function DeleteConfirm({ name, onConfirm, onCancel }: DeleteConfirmProps) { const ref = useRef(null); - // eslint-disable-next-line no-restricted-syntax useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") onCancel(); + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onCancel(); }; - const handleClickOutside = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onCancel(); + const handlePointerDown = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) onCancel(); }; + document.addEventListener("keydown", handleEscape); - document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("mousedown", handlePointerDown); return () => { document.removeEventListener("keydown", handleEscape); - document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("mousedown", handlePointerDown); }; }, [onCancel]); return (
-

+

Delete {name}?

@@ -421,216 +429,6 @@ function DeleteConfirm({ ); } -// ── 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 = 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?.onCommit?.(name); - }} - onCancel={() => { - inlineInput?.onCancel?.(); - }} - /> - ); - } - - return ( - <> - - {isOpen && ( - <> - {/* Inline input for new file/folder inside this folder */} - {inlineInput && - (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") && - inlineInput.parentPath === node.fullPath && ( - { - // 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 ? ( - - ) : child.children.size > 0 ? ( - - ) : ( - - ), - )} - - )} - - ); -} - -// ── 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?.onCommit?.(name); - }} - onCancel={() => { - inlineInput?.onCancel?.(); - }} - /> - ); - } - - return ( - - ); -} - -// ── Main FileTree Component ── - export const FileTree = memo(function FileTree({ files, activeFile, @@ -643,302 +441,441 @@ export const FileTree = memo(function FileTree({ onMoveFile, onImportFiles, }: FileTreeProps) { - const tree = useMemo(() => buildTree(files), [files]); - const children = useMemo(() => sortChildren(tree.children), [tree]); + const wrapperRef = useRef(null); + const hostRef = useRef(null); + const callbacksRef = useRef({ + onCreateFile, + onCreateFolder, + onDeleteFile, + onDuplicateFile, + onImportFiles, + onMoveFile, + onRenameFile, + onSelectFile, + }); + callbacksRef.current = { + onCreateFile, + onCreateFolder, + onDeleteFile, + onDuplicateFile, + onImportFiles, + onMoveFile, + onRenameFile, + onSelectFile, + }; + + const displayPaths = useMemo(() => buildStudioTreePaths(files), [files]); + const displayPathsRef = useRef(displayPaths); + displayPathsRef.current = displayPaths; - const [contextMenu, setContextMenu] = useState(null); - const [inlineInput, setInlineInput] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const [dragOverFolder, setDragOverFolder] = useState(null); - const dragSourceRef = useRef(null); - - const hasFileOps = !!( - onCreateFile || - onCreateFolder || - onDeleteFile || - onRenameFile || - onDuplicateFile + const [externalDropTarget, setExternalDropTarget] = useState(null); + const [rootContextMenu, setRootContextMenu] = useState(null); + const pendingCreateRef = useRef(null); + + const modelRef = useRef(null); + const hasFileOps = Boolean( + onCreateFile || onCreateFolder || onDeleteFile || onRenameFile || onDuplicateFile, ); + const hasRootCreateActions = Boolean(onCreateFile || onCreateFolder); + + const { model } = useFileTree({ + composition: hasFileOps + ? { + contextMenu: { + buttonVisibility: "when-needed", + triggerMode: "both", + }, + } + : undefined, + dragAndDrop: onMoveFile + ? { + canDrag: (paths) => paths.length === 1, + onDropComplete: (event) => { + const sourcePath = event.draggedPaths[0]; + if (!sourcePath) return; + const nextPath = buildMoveDestinationPath(sourcePath, event.target.directoryPath); + void Promise.resolve( + callbacksRef.current.onMoveFile?.(toPublicPath(sourcePath), nextPath), + ).catch(console.error); + }, + } + : false, + icons: { colored: true, set: "complete" }, + initialExpandedPaths: activeFile ? getAncestorDirectoryPaths(activeFile) : undefined, + initialExpansion: "closed", + initialSelectedPaths: activeFile ? [activeFile] : undefined, + itemHeight: 28, + onSelectionChange: (selectedPaths) => { + const focusedPath = modelRef.current?.getFocusedPath() ?? null; + if (!focusedPath || isCanonicalDirectoryPath(focusedPath)) return; + if (!selectedPaths.includes(focusedPath)) return; + callbacksRef.current.onSelectFile(focusedPath); + }, + paths: displayPaths, + renaming: { + onError: (error) => { + console.error(`[Studio] File tree rename failed: ${error}`); + }, + onRename: (event) => { + const pending = pendingCreateRef.current; + const isPendingRename = + pending && toPublicPath(pending.placeholderPath) === event.sourcePath; + + if (isPendingRename) { + pendingCreateRef.current = null; + void Promise.resolve( + pending.kind === "folder" + ? callbacksRef.current.onCreateFolder?.(event.destinationPath) + : callbacksRef.current.onCreateFile?.(event.destinationPath), + ).catch(console.error); + return; + } + + void Promise.resolve( + callbacksRef.current.onRenameFile?.(event.sourcePath, event.destinationPath), + ).catch(console.error); + }, + }, + sort: compareStudioTreeEntries, + unsafeCSS: TREE_UNSAFE_CSS, + }); + modelRef.current = model; - // ── Context Menu handlers ── + const closeRootContextMenu = useCallback(() => { + setRootContextMenu(null); + }, []); - 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 startCreate = useCallback( + (kind: PendingCreateState["kind"], parentPath: string) => { + if (pendingCreateRef.current) return; - 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], - ); + const placeholderPath = createPlaceholderPath(displayPathsRef.current, parentPath, kind); + pendingCreateRef.current = { kind, placeholderPath }; + + const parentDirectory = parentPath ? toCanonicalDirectoryPath(parentPath) : null; + if (parentDirectory) { + const parentItem = model.getItem(parentDirectory); + if (isDirectoryHandle(parentItem)) parentItem.expand(); + } + + try { + model.add(placeholderPath); + renderMountedTree(model, hostRef.current); + requestAnimationFrame(() => { + if (model.startRenaming(placeholderPath, { removeIfCanceled: true }) !== false) { + renderMountedTree(model, hostRef.current); + return; + } - // ── 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), - }); + model.remove( + placeholderPath, + isCanonicalDirectoryPath(placeholderPath) ? { recursive: true } : undefined, + ); + pendingCreateRef.current = null; + }); + } catch (error) { + pendingCreateRef.current = null; + console.error(error); + } }, - [onCreateFolder], + [model], ); - // ── 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), - }); + model.startRenaming(path); + renderMountedTree(model, hostRef.current); }, - [onRenameFile], + [model], ); - // ── Duplicate ── - - const handleDuplicate = useCallback( - (path: string) => { - onDuplicateFile?.(path); + const handleDeleteConfirm = useCallback(() => { + if (!deleteTarget) return; + void Promise.resolve(callbacksRef.current.onDeleteFile?.(deleteTarget)).catch(console.error); + setDeleteTarget(null); + }, [deleteTarget]); + + const renderContextMenu = useCallback( + (item: ContextMenuItem, context: ContextMenuOpenContext) => { + const publicPath = toPublicPath(item.path); + const parentPath = toPublicPath(getCanonicalParentDirectoryPath(item.path) ?? ""); + const createPath = item.kind === "directory" ? publicPath : parentPath; + + return ( + { + context.close({ restoreFocus: false }); + startCreate("file", createPath); + } + : undefined + } + onNewFolder={ + item.kind === "directory" && onCreateFolder + ? () => { + context.close({ restoreFocus: false }); + startCreate("folder", createPath); + } + : undefined + } + onRename={ + onRenameFile + ? () => { + context.close({ restoreFocus: false }); + handleRename(item.path); + } + : undefined + } + onDuplicate={ + item.kind === "file" && onDuplicateFile + ? () => { + context.close(); + void Promise.resolve(callbacksRef.current.onDuplicateFile?.(publicPath)).catch( + console.error, + ); + } + : undefined + } + onDelete={ + onDeleteFile + ? () => { + context.close(); + setDeleteTarget(publicPath); + } + : undefined + } + /> + ); }, - [onDuplicateFile], + [ + handleRename, + onCreateFile, + onCreateFolder, + onDeleteFile, + onDuplicateFile, + onRenameFile, + startCreate, + ], ); - // ── Delete ── + useEffect(() => { + const unsubscribe = model.onMutation("*", (event) => { + const pending = pendingCreateRef.current; + if (!pending) return; + if (isPendingCreateCleared(event, pending.placeholderPath)) { + pendingCreateRef.current = null; + } + }); - const handleDelete = useCallback((path: string) => { - setDeleteTarget(path); - }, []); + return unsubscribe; + }, [model]); - // 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]); + useEffect(() => { + hostRef.current = getHostElement(wrapperRef.current); + }); - const handleDeleteCancel = useCallback(() => { - setDeleteTarget(null); - }, []); + useEffect(() => { + const host = getHostElement(wrapperRef.current); + hostRef.current = host; + const expandedPaths = collectExpandedPaths(host); + model.resetPaths(displayPaths, { + initialExpandedPaths: expandedPaths.length > 0 ? expandedPaths : undefined, + }); + }, [displayPaths, model]); - // ── Drag and Drop ── + useEffect(() => { + syncSelection(model, activeFile); + }, [activeFile, model]); - const handleDragStart = useCallback((e: React.DragEvent, path: string) => { - dragSourceRef.current = path; - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", path); - }, []); + useEffect(() => { + const host = getHostElement(wrapperRef.current); + hostRef.current = host; + if (!host) return; + + const handleContextMenu = (event: MouseEvent) => { + if (!hasRootCreateActions) return; + const path = event + .composedPath() + .find( + (entry) => entry instanceof HTMLElement && typeof entry.dataset.itemPath === "string", + ); + if (path) return; + + const headerTarget = event + .composedPath() + .find( + (entry) => + entry instanceof HTMLElement && + (entry.slot === "header" || entry.closest?.('[slot="header"]') != null), + ); + if (headerTarget) return; + + event.preventDefault(); + setRootContextMenu({ x: event.clientX, y: event.clientY }); + }; - const handleDragOver = useCallback((_e: React.DragEvent, folderPath: string) => { - setDragOverFolder(folderPath); - }, []); + const handleDragOver = (event: DragEvent) => { + if (!callbacksRef.current.onImportFiles || !hasExternalFiles(event.dataTransfer)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + setExternalDropTarget(resolveImportTargetPath(event)); + }; - const handleDrop = useCallback( - (e: React.DragEvent, folderPath: string) => { - // External files from desktop — import into the target folder - if (e.dataTransfer.files.length > 0 && !dragSourceRef.current) { - e.preventDefault(); - onImportFiles?.(e.dataTransfer.files, folderPath || undefined); - setDragOverFolder(null); + const handleDragLeave = (event: DragEvent) => { + const nextTarget = event.relatedTarget; + if ( + nextTarget instanceof Node && + (host.contains(nextTarget) || host.shadowRoot?.contains(nextTarget)) + ) { return; } + setExternalDropTarget(null); + }; - const sourcePath = dragSourceRef.current; - if (!sourcePath || !onMoveFile) { - setDragOverFolder(null); - return; + const handleDrop = (event: DragEvent) => { + if (!callbacksRef.current.onImportFiles || !hasExternalFiles(event.dataTransfer)) return; + event.preventDefault(); + const targetPath = resolveImportTargetPath(event); + const targetDir = targetPath ? toPublicPath(targetPath) || undefined : undefined; + if (event.dataTransfer?.files.length) { + void Promise.resolve( + callbacksRef.current.onImportFiles(event.dataTransfer.files, targetDir), + ).catch(console.error); } - // 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, onImportFiles], - ); + setExternalDropTarget(null); + }; - const handleDragLeave = useCallback(() => { - setDragOverFolder(null); - }, []); + const handleDragEnd = () => { + setExternalDropTarget(null); + }; - // ── Root-level context menu (right-click on empty space) ── + host.addEventListener("contextmenu", handleContextMenu); + host.addEventListener("dragover", handleDragOver); + host.addEventListener("dragleave", handleDragLeave); + host.addEventListener("drop", handleDrop); + window.addEventListener("dragend", handleDragEnd); - 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 () => { + host.removeEventListener("contextmenu", handleContextMenu); + host.removeEventListener("dragover", handleDragOver); + host.removeEventListener("dragleave", handleDragLeave); + host.removeEventListener("drop", handleDrop); + window.removeEventListener("dragend", handleDragEnd); + }; + }, [hasRootCreateActions]); + + useEffect(() => { + const host = hostRef.current ?? getHostElement(wrapperRef.current); + if (!host?.shadowRoot) return; + + for (const element of host.shadowRoot.querySelectorAll( + "[data-studio-external-drag-target='true']", + )) { + element.removeAttribute("data-studio-external-drag-target"); + } + + if (!externalDropTarget) return; + if (externalDropTarget === "") return; + + const selector = `[data-type="item"][data-item-path="${escapeAttributeValue(externalDropTarget)}"]`; + host.shadowRoot + .querySelector(selector) + ?.setAttribute("data-studio-external-drag-target", "true"); + }, [externalDropTarget]); return ( -
- {/* FILES header with action buttons */} +
{hasFileOps && ( -
- - Files - -
- - +
+
+ + Files + +
+ {onCreateFile && ( + + )} + {onCreateFolder && ( + + )} +
)}
{ - e.preventDefault(); - // Show root highlight when dragging over the background (not a child folder) - if (e.target === e.currentTarget) setDragOverFolder(""); - }} - onDragLeave={(e) => { - if (e.target === e.currentTarget) setDragOverFolder(null); - }} - onDrop={(e) => { - e.preventDefault(); - handleDrop(e, ""); - }} > - {/* Root-level inline input for new file/folder */} - {inlineInput && - (inlineInput.mode === "new-file" || inlineInput.mode === "new-folder") && - inlineInput.parentPath === "" && ( - inlineInput.onCommit?.(name)} - onCancel={() => inlineInput.onCancel?.()} - /> - )} - {children.map((child) => - child.isFile && child.children.size === 0 ? ( - - ) : ( - - ), - )} +
- {/* Delete confirmation overlay */} {deleteTarget && ( -
+
setDeleteTarget(null)} onConfirm={handleDeleteConfirm} - onCancel={handleDeleteCancel} />
)} - {/* Context menu */} - {contextMenu && ( - { + closeRootContextMenu(); + startCreate("file", ""); + } + : undefined + } + onNewFolder={ + onCreateFolder + ? () => { + closeRootContextMenu(); + startCreate("folder", ""); + } + : undefined + } /> )}
); }); + +export { + buildMoveDestinationPath, + buildStudioTreePaths, + createPlaceholderPath, + getDropPathData, + isPendingCreateCleared, +};