import { useState, useCallback, useRef } from "react"; import type { EditingFile } from "../utils/studioHelpers"; import { FONT_EXT, isMediaFile } from "../utils/mediaTypes"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher"; import { createStudioSaveHttpError, retryStudioSave, StudioSaveNetworkError, } from "../utils/studioSaveDiagnostics"; import { useFileTree } from "./useFileTree"; import { useEditorSave } from "./useEditorSave"; // ── Types ── interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; files: Record; } interface UseFileManagerOptions { projectId: string | null; showToast: (message: string, tone?: "error" | "info") => void; recordEdit: (input: RecordEditInput) => Promise; domEditSaveTimestampRef: React.MutableRefObject; setRefreshKey: React.Dispatch>; } // ── Hook ── export function useFileManager({ projectId, showToast, recordEdit, domEditSaveTimestampRef, setRefreshKey, }: UseFileManagerOptions) { // ── Shared refs ── const [editingFile, setEditingFile] = useState(null); const [revealSourceOffset, setRevealSourceOffset] = useState(null); const editingPathRef = useRef(editingFile?.path); editingPathRef.current = editingFile?.path; const projectIdRef = useRef(projectId); projectIdRef.current = projectId; const importedFontAssetsRef = useRef([]); // ── File tree ── const { projectDir, fileTree, setFileTree, fileTreeLoaded, refreshFileTree, compositions, assets, fontAssets, } = useFileTree({ projectId, projectIdRef }); // ── Core file I/O ── const readProjectFile = useCallback(async (path: string): Promise => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`); if (!response.ok) throw new Error(`Failed to read ${path}`); const data = (await response.json()) as { content?: string }; if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`); return data.content; }, []); const writeProjectFile = useCallback(async (path: string, content: string): Promise => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); await retryStudioSave(async () => { let response: Response; try { response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, { method: "PUT", headers: { "Content-Type": "text/plain" }, body: content, }); } catch (error) { throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, { cause: error, }); } if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`); }); if (editingPathRef.current === path) { setEditingFile({ path, content }); } }, []); const updateEditingFileContent = useCallback((path: string, content: string) => { if (editingPathRef.current === path) { setEditingFile({ path, content }); } }, []); const readOptionalProjectFile = useCallback(async (path: string): Promise => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); const response = await fetch( `/api/projects/${pid}/files/${encodeURIComponent(path)}?optional=1`, ); if (!response.ok) throw new Error(`Failed to read ${path}`); const data = (await response.json()) as { content?: string }; return typeof data.content === "string" ? data.content : ""; }, []); // ── Editor save (debounced content change) ── const { saveRafRef, handleContentChange } = useEditorSave({ editingPathRef, projectIdRef, readProjectFile, writeProjectFile, recordEdit, domEditSaveTimestampRef, setRefreshKey, }); // ── File select ── const revealRequestIdRef = useRef(0); const revealAbortRef = useRef(null); const handleFileSelect = useCallback((path: string) => { const pid = projectIdRef.current; if (!pid) return; revealAbortRef.current?.abort(); revealAbortRef.current = null; revealRequestIdRef.current++; // Skip fetching binary content for media files — just set the path for preview if (isMediaFile(path)) { setEditingFile({ path, content: null }); return; } fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`) .then((r) => r.json()) .then((data: { content?: string }) => { if (data.content != null) { setEditingFile({ path, content: data.content }); } }) .catch(() => {}); }, []); // ── Click-to-source ── const openSourceForSelection = useCallback( (sourceFile: string, target: PatchTarget) => { const pid = projectIdRef.current; if (!pid || !sourceFile) return; revealAbortRef.current?.abort(); revealAbortRef.current = null; if (editingPathRef.current === sourceFile && editingFile?.content != null) { const match = findTagByTarget(editingFile.content, target); setRevealSourceOffset(match ? match.start : null); return; } const requestId = ++revealRequestIdRef.current; const controller = new AbortController(); revealAbortRef.current = controller; fetch(`/api/projects/${pid}/files/${encodeURIComponent(sourceFile)}`, { signal: controller.signal, }) .then((r) => r.json()) .then((data: { content?: string }) => { if (requestId !== revealRequestIdRef.current) return; if (data.content != null) { setEditingFile({ path: sourceFile, content: data.content }); const match = findTagByTarget(data.content, target); setRevealSourceOffset(match ? match.start : null); } }) .catch(() => {}); }, [editingFile?.content], ); // ── Upload ── const uploadProjectFiles = useCallback( async (files: Iterable, dir?: string): Promise => { const pid = projectIdRef.current; const fileList = Array.from(files); if (!pid || fileList.length === 0) return []; const formData = new FormData(); for (const file of fileList) { formData.append("file", file); } const qs = dir ? `?dir=${encodeURIComponent(dir)}` : ""; try { const res = await fetch(`/api/projects/${pid}/upload${qs}`, { method: "POST", body: formData, }); if (res.ok) { const data = await res.json(); if (data.skipped?.length) { showToast(`Skipped (too large): ${data.skipped.join(", ")}`); } if (data.invalid?.length) { const names = data.invalid.map((entry: { name: string }) => entry.name).join(", "); showToast(`Unsupported media skipped: ${names}`); } await refreshFileTree(); setRefreshKey((k) => k + 1); return Array.isArray(data.files) ? data.files : []; } else if (res.status === 413) { showToast("Upload rejected: payload too large"); } else { showToast(`Upload failed (${res.status})`); } } catch { showToast("Upload failed: network error"); } return []; }, [refreshFileTree, setRefreshKey, showToast], ); // ── File CRUD ── const handleCreateFile = useCallback( async (path: string) => { const pid = projectIdRef.current; if (!pid) return; let content = ""; if (path.endsWith(".html")) { content = '\n\n\n \n\n\n\n\n\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; 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(); setRefreshKey((k) => k + 1); } else { const err = await res.json().catch(() => ({ error: "unknown" })); console.error(`Rename failed: ${err.error}`); } }, [refreshFileTree, handleFileSelect, setRefreshKey], ); 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 handleImportFiles = useCallback( async (files: FileList | File[], dir?: string) => { return uploadProjectFiles(Array.from(files), dir); }, [uploadProjectFiles], ); const handleImportFonts = useCallback( async (files: FileList | File[]): Promise => { const uploaded = await uploadProjectFiles( Array.from(files).filter((file) => FONT_EXT.test(file.name)), "assets/fonts", ); const pid = projectIdRef.current; const imported = uploaded .filter((asset) => FONT_EXT.test(asset)) .map((asset) => ({ family: fontFamilyFromAssetPath(asset), path: asset, url: `/api/projects/${pid}/preview/${asset}`, })); importedFontAssetsRef.current = [ ...imported, ...importedFontAssetsRef.current.filter( (existing) => !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()), ), ]; return imported; }, [uploadProjectFiles], ); // ── Return ── return { // State editingFile, setEditingFile, projectDir, fileTree, fileTreeLoaded, setFileTree, // Refs editingPathRef, projectIdRef, saveRafRef, importedFontAssetsRef, // Core I/O readProjectFile, writeProjectFile, readOptionalProjectFile, updateEditingFileContent, // Click-to-source revealSourceOffset, openSourceForSelection, // Callbacks handleFileSelect, handleContentChange, refreshFileTree, uploadProjectFiles, handleCreateFile, handleCreateFolder, handleDeleteFile, handleRenameFile, handleDuplicateFile, handleMoveFile, handleImportFiles, handleImportFonts, // Derived compositions, assets, fontAssets, }; }