Files
STCourses/packages/feature-studio/src/ui/workspace.ts
T

111 lines
4.8 KiB
TypeScript

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<SaveOutcome>;
upload(file: File, dir: string): Promise<{ ok: true; path: string } | { ok: false; error: string }>;
reload(): Promise<void>;
/** Bumped after every save so open file editors refetch their base hash. */
version: number;
resolveUrl(rel: string): string;
}
export function clone<T>(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<typeof useFile>;
export function useWorkspace(courseId: string) {
const [state, setState] = useState<CourseState | null>(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<ManifestDoc | null>(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<string, unknown> = { 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 };
}