fix(studio): shell UX — data-loss guards, error surfacing, dialog contracts, toasts (#1964)

This commit is contained in:
Vance Ingalls
2026-07-06 16:56:06 -07:00
committed by GitHub
parent ccc1308839
commit 89e36da13d
23 changed files with 700 additions and 281 deletions
@@ -0,0 +1,40 @@
import { useCallback } from "react";
/**
* Loads a composition file's content for the source editor when a composition
* is selected. Content stays null until the fetch resolves — the source editor
* must not mount on a null-content file, or its autosave would overwrite the
* real file with an empty document. Load failures surface as an error toast
* instead of silently rendering an empty (and autosave-armed) editor.
*/
export function useCompositionContentLoader({
projectId,
setEditingFile,
setActiveCompPath,
showToast,
}: {
projectId: string | null;
setEditingFile: (file: { path: string; content: string | null }) => void;
setActiveCompPath: (path: string | null) => void;
showToast: (message: string, tone?: "error" | "info") => void;
}) {
return useCallback(
(comp: string) => {
setActiveCompPath(comp.endsWith(".html") ? comp : null);
setEditingFile({ path: comp, content: null });
fetch(`/api/projects/${projectId}/files/${comp}`)
.then(async (r) => {
if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
return r.json();
})
.then((data: { content?: string }) => {
if (typeof data.content !== "string") throw new Error(`No content returned for ${comp}`);
setEditingFile({ path: comp, content: data.content });
})
.catch((err) => {
showToast(err instanceof Error ? err.message : `Failed to load ${comp}`, "error");
});
},
[projectId, setEditingFile, setActiveCompPath, showToast],
);
}
@@ -18,6 +18,7 @@ interface UseEditorSaveOptions {
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
showToast: (message: string, tone?: "error" | "info") => void;
}
export function useEditorSave({
@@ -28,9 +29,13 @@ export function useEditorSave({
recordEdit,
domEditSaveTimestampRef,
setRefreshKey,
showToast,
}: UseEditorSaveOptions) {
const saveRafRef = useRef<number | null>(null);
const refreshRafRef = useRef<number | null>(null);
// One error toast per burst of failures — every keystroke retries the save,
// and error toasts persist until dismissed, so don't stack duplicates.
const lastFailureToastAtRef = useRef(0);
const handleContentChange = useCallback(
(content: string) => {
@@ -61,6 +66,14 @@ export function useEditorSave({
source: "code_editor",
error_message: error instanceof Error ? error.message : "unknown",
});
const now = Date.now();
if (now - lastFailureToastAtRef.current > 5000) {
lastFailureToastAtRef.current = now;
showToast(
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
"error",
);
}
});
});
},
@@ -71,6 +84,7 @@ export function useEditorSave({
readProjectFile,
recordEdit,
setRefreshKey,
showToast,
writeProjectFile,
],
);
+39 -25
View File
@@ -126,6 +126,7 @@ export function useFileManager({
recordEdit,
domEditSaveTimestampRef,
setRefreshKey,
showToast,
});
// ── File select ──
@@ -133,26 +134,34 @@ export function useFileManager({
const revealRequestIdRef = useRef(0);
const revealAbortRef = useRef<AbortController | null>(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(() => {});
}, []);
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) => {
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
return r.json();
})
.then((data: { content?: string }) => {
if (data.content != null) {
setEditingFile({ path, content: data.content });
}
})
.catch((err: unknown) => {
showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
});
},
[showToast],
);
// ── Click-to-source ──
@@ -253,9 +262,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Create file failed: ${err.error}`);
showToast(`Couldn't create ${path}: ${err.error}`, "error");
}
},
[refreshFileTree, handleFileSelect],
[refreshFileTree, handleFileSelect, showToast],
);
const handleCreateFolder = useCallback(
@@ -275,9 +285,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Create folder failed: ${err.error}`);
showToast(`Couldn't create folder ${path}: ${err.error}`, "error");
}
},
[refreshFileTree],
[refreshFileTree, showToast],
);
const handleDeleteFile = useCallback(
@@ -293,9 +304,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Delete failed: ${err.error}`);
showToast(`Couldn't delete ${path}: ${err.error}`, "error");
}
},
[refreshFileTree],
[refreshFileTree, showToast],
);
const handleRenameFile = useCallback(
@@ -316,9 +328,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Rename failed: ${err.error}`);
showToast(`Couldn't rename ${oldPath}: ${err.error}`, "error");
}
},
[refreshFileTree, handleFileSelect, setRefreshKey],
[refreshFileTree, handleFileSelect, setRefreshKey, showToast],
);
const handleDuplicateFile = useCallback(
@@ -337,9 +350,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Duplicate failed: ${err.error}`);
showToast(`Couldn't duplicate ${path}: ${err.error}`, "error");
}
},
[refreshFileTree, handleFileSelect],
[refreshFileTree, handleFileSelect, showToast],
);
const handleMoveFile = handleRenameFile;
+12 -1
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, type MouseEvent } from "react";
import { useState, useCallback, useRef, type MouseEvent } from "react";
import { useMountEffect } from "./useMountEffect";
import { liveTime, usePlayerStore } from "../player";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
@@ -17,6 +17,8 @@ export function useFrameCapture({
waitForPendingDomEditSaves,
}: UseFrameCaptureParams) {
const [captureFrameTime, setCaptureFrameTime] = useState(0);
const [capturing, setCapturing] = useState(false);
const capturingRef = useRef(false);
useMountEffect(() => {
setCaptureFrameTime(usePlayerStore.getState().currentTime);
@@ -31,6 +33,11 @@ export function useFrameCapture({
async (event: MouseEvent<HTMLAnchorElement>) => {
if (!projectId) return;
event.preventDefault();
// A capture can take up to ~35s (save drain + server render) — ignore
// re-entrant clicks instead of firing parallel captures.
if (capturingRef.current) return;
capturingRef.current = true;
setCapturing(true);
try {
const time = usePlayerStore.getState().currentTime;
setCaptureFrameTime(time);
@@ -79,6 +86,9 @@ export function useFrameCapture({
}
} catch (err) {
showToast(err instanceof Error ? err.message : "Capture failed", "error");
} finally {
capturingRef.current = false;
setCapturing(false);
}
},
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
@@ -98,5 +108,6 @@ export function useFrameCapture({
captureFrameFilename,
handleCaptureFrameClick,
refreshCaptureFrameTime,
capturing,
};
}
@@ -100,6 +100,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
leftWidth,
setLeftWidth,
rightWidth,
setRightWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
+67 -14
View File
@@ -2,24 +2,77 @@ import { useState, useCallback, useRef } from "react";
import { useMountEffect } from "./useMountEffect";
import type { AppToast } from "../utils/studioHelpers";
export function useToast() {
const [appToast, setAppToast] = useState<AppToast | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
interface ToastItem extends AppToast {
id: number;
/** True while the exit animation plays, just before removal. */
leaving?: boolean;
}
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
if (timerRef.current) clearTimeout(timerRef.current);
setAppToast({ message, tone });
timerRef.current = setTimeout(() => setAppToast(null), 4000);
const AUTO_DISMISS_MS = 4000;
const EXIT_MS = 160;
const MAX_TOASTS = 3;
let nextToastId = 1;
/**
* Stacked toasts (max 3). Info toasts auto-dismiss after 4s; error toasts
* persist until explicitly dismissed so failures can't silently vanish.
*/
export function useToast() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const clearTimer = useCallback((id: number) => {
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
}, []);
const removeToast = useCallback(
(id: number) => {
clearTimer(id);
setToasts((prev) => prev.filter((t) => t.id !== id));
},
[clearTimer],
);
const dismissToast = useCallback(
(id: number) => {
clearTimer(id);
// Mark leaving so the exit animation plays, then remove.
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, leaving: true } : t)));
const timer = setTimeout(() => removeToast(id), EXIT_MS);
timersRef.current.set(id, timer);
},
[clearTimer, removeToast],
);
const showToast = useCallback(
(message: string, tone: AppToast["tone"] = "error") => {
const id = nextToastId++;
setToasts((prev) => {
const next = [...prev, { id, message, tone }];
// Cap the stack; drop the oldest (and its pending timer).
while (next.length > MAX_TOASTS) {
const dropped = next.shift();
if (dropped) clearTimer(dropped.id);
}
return next;
});
if (tone !== "error") {
const timer = setTimeout(() => dismissToast(id), AUTO_DISMISS_MS);
timersRef.current.set(id, timer);
}
},
[clearTimer, dismissToast],
);
useMountEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
for (const timer of timersRef.current.values()) clearTimeout(timer);
timersRef.current.clear();
});
const dismissToast = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setAppToast(null);
}, []);
return { appToast, showToast, dismissToast };
return { toasts, showToast, dismissToast };
}