123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
import { useCallback } from "react";
|
|
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
|
import { notifyActionError } from "../../lib/actionErrors";
|
|
import { captureTab } from "../../lib/captureTab";
|
|
import {
|
|
announceSessionChange,
|
|
closeRecorderStart,
|
|
nextStep,
|
|
openComposer,
|
|
setStepPrompt,
|
|
} from "../../lib/debugRecorder";
|
|
import { flushNow, liveSession } from "../../lib/diagnostics";
|
|
import { log } from "../../lib/log";
|
|
import { msg, notify } from "../../lib/notifications";
|
|
import { qk } from "../../lib/queryKeys";
|
|
import { updateMeCache, useMe } from "../../lib/useMe";
|
|
|
|
/** The debug recording's actions, shared by everything that drives it: the pill, Settings →
|
|
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
|
* answer, so a reload, another tab or the cap all show the same thing. */
|
|
export function useDebugRecording() {
|
|
const qc = useQueryClient();
|
|
const me = useMe();
|
|
const session = liveSession(me?.debug_session);
|
|
const setSession = (next: DebugSession | null) => {
|
|
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
|
|
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
|
|
announceSessionChange();
|
|
setStepPrompt(null); // a prompt left open belongs to the session that ended
|
|
};
|
|
|
|
const start = useMutation({
|
|
mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level),
|
|
meta: { failTitle: "recorder.start.failed" },
|
|
onSuccess: (r) => {
|
|
setSession(r.session);
|
|
closeRecorderStart();
|
|
},
|
|
});
|
|
|
|
const stop = useMutation({
|
|
mutationKey: STOP_KEY,
|
|
mutationFn: async (id: string) => {
|
|
log.info("debug.stop", "recording stopped by the user");
|
|
// The events of the last seconds before Stop are the ones closest to what went wrong. Sent
|
|
// while the session still tags them: once it is closed the server no longer does.
|
|
await flushNow();
|
|
return api.stopDebugSession(id);
|
|
},
|
|
meta: { failTitle: "recorder.pill.stopFailed" },
|
|
onSuccess: (r) => {
|
|
setSession(null);
|
|
notify({ level: "success", message: msg("recorder.pill.stopped") });
|
|
openComposer(r.session.id);
|
|
},
|
|
});
|
|
|
|
const shoot = useMutation({
|
|
mutationKey: SHOOT_KEY,
|
|
mutationFn: async ({ id, image }: { id: string; image: Blob }) =>
|
|
api.addDebugScreenshot(id, image),
|
|
meta: { failTitle: "recorder.pill.shotFailed" },
|
|
onSuccess: (r) => {
|
|
updateMeCache(qc, (prev) =>
|
|
prev.debug_session
|
|
? {
|
|
...prev,
|
|
debug_session: {
|
|
...prev.debug_session,
|
|
screenshots: prev.debug_session.screenshots + 1,
|
|
},
|
|
}
|
|
: prev,
|
|
);
|
|
// In the evidence too, where the step list is drafted from: which step it belongs to.
|
|
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
|
|
notify({ level: "success", message: msg("recorder.pill.shotSaved") });
|
|
},
|
|
});
|
|
|
|
// In flight anywhere, not only through this hook's own observer: the pill remounts when it moves
|
|
// to another toast host, and a Stop still running must keep its button busy there.
|
|
const stopping = useIsMutating({ mutationKey: STOP_KEY }) > 0;
|
|
const shooting = useIsMutating({ mutationKey: SHOOT_KEY }) > 0;
|
|
|
|
/** Mark the next step; `label` is what the user says they just did (may be empty). */
|
|
const sessionId = session?.id;
|
|
const markStep = useCallback(
|
|
(label: string) => {
|
|
if (!sessionId) return;
|
|
const n = nextStep(sessionId);
|
|
const text = label.trim();
|
|
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
|
|
notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) });
|
|
},
|
|
[sessionId],
|
|
);
|
|
|
|
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
|
|
* frame. Nothing happens when the user cancels the browser's picker. */
|
|
const screenshot = async (hide: (hidden: boolean) => void) => {
|
|
if (!session) return;
|
|
hide(true);
|
|
let image: Blob | null = null;
|
|
try {
|
|
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
|
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
image = await captureTab();
|
|
} catch (err) {
|
|
notifyActionError(err, msg("recorder.pill.shotFailed"));
|
|
}
|
|
hide(false);
|
|
if (image) shoot.mutate({ id: session.id, image });
|
|
};
|
|
|
|
return { session, start, stop, stopping, shoot, shooting, markStep, screenshot };
|
|
}
|
|
|
|
const STOP_KEY = ["debug-session", "stop"];
|
|
const SHOOT_KEY = ["debug-session", "screenshot"];
|