mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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:
+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) ? (
|
||||
|
||||
Reference in New Issue
Block a user