import { useCallback, useEffect, useState } from "react"; import { api, coursePath, rawUrl, type CourseState, type FileConflict, type FileWrite, type ManifestDoc } from "./api"; export type SaveOutcome = { ok: true } | { ok: false; conflicts?: FileConflict[]; error?: string }; /** What every editor gets: the loaded draft, a local manifest copy shared across editors, and save helpers. */ export interface Workspace { courseId: string; state: CourseState; doc: ManifestDoc; docDirty: boolean; setDoc(update: (d: ManifestDoc) => void): void; /** One commit: optional files plus the local manifest when it has changes (or when `withManifest`). */ save(opts: { files?: FileWrite[]; message: string; withManifest?: boolean; manifest?: ManifestDoc }): Promise; upload(file: File, dir: string): Promise<{ ok: true; path: string } | { ok: false; error: string }>; reload(): Promise; /** Bumped after every save so open file editors refetch their base hash. */ version: number; resolveUrl(rel: string): string; } export function clone(x: T): T { return JSON.parse(JSON.stringify(x)) as T; } /** A course file opened for editing, with the hash it was loaded at (for conflict detection on save). */ export function useFile(courseId: string, path: string | undefined, version: number, initialIfMissing = "") { const [s, setS] = useState({ path: undefined as string | undefined, content: "", base: "", hash: null as string | null, loaded: false }); useEffect(() => { let live = true; if (!path) { setS({ path, content: "", base: "", hash: null, loaded: true }); return; } setS((p) => ({ ...p, loaded: false })); api("GET", coursePath(courseId, `/file?path=${encodeURIComponent(path)}`)).then(({ status, data }) => { if (!live) return; if (status === 200) setS({ path, content: data.content, base: data.content, hash: data.hash, loaded: true }); else setS({ path, content: initialIfMissing, base: "", hash: null, loaded: true }); }); return () => { live = false; }; }, [courseId, path, version]); return { path, content: s.content, loaded: s.loaded && s.path === path, exists: s.hash !== null, dirty: s.content !== s.base, setContent: (c: string) => setS((p) => ({ ...p, content: c })), write: (): FileWrite | null => (path ? { path, content: s.content, baseHash: s.hash } : null), }; } export type OpenFile = ReturnType; export function useWorkspace(courseId: string) { const [state, setState] = useState(null); const [readOnly, setReadOnly] = useState<{ message: string; title: string; root: string } | null>(null); const [error, setError] = useState<{ status: number; error?: string } | null>(null); const [doc, setDocState] = useState(null); const [docDirty, setDocDirty] = useState(false); const [version, setVersion] = useState(0); const reload = useCallback(async () => { const { status, data } = await api("GET", coursePath(courseId)); if (status !== 200) { setError({ status, error: data?.error }); return; } setError(null); if (data.readOnly) { setReadOnly({ message: data.message, title: data.course.title, root: data.course.root }); return; } setState(data); setDocState(data.manifestRaw ? clone(data.manifestRaw) : null); setDocDirty(false); }, [courseId]); useEffect(() => { reload(); }, [reload]); const ws: Workspace | null = state && doc ? { courseId, state, doc, docDirty, version, setDoc(update) { setDocState((d) => { const n = clone(d!); update(n); return n; }); setDocDirty(true); }, async save({ files = [], message, withManifest, manifest }) { const body: Record = { message, files }; if (manifest || withManifest === true || (withManifest !== false && docDirty)) { body.manifest = manifest ?? doc; body.manifestBaseHash = state.manifestHash; } const { status, data } = await api("PUT", coursePath(courseId, "/files"), body); if (status === 200) { setVersion((v) => v + 1); await reload(); return { ok: true }; } if (status === 409 && data?.conflicts) return { ok: false, conflicts: data.conflicts }; return { ok: false, error: data?.error ?? `Save failed (${status})` }; }, async upload(file, dir) { const fd = new FormData(); fd.set("file", file); fd.set("dir", dir); const { status, data } = await api("POST", coursePath(courseId, "/upload"), fd); if (status !== 200) return { ok: false, error: data?.error ?? `Upload failed (${status})` }; return { ok: true, path: data.path }; }, reload, resolveUrl: (rel) => rawUrl(courseId, rel), } : null; return { ws, state, readOnly, error, reload, setVersion }; }