A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
125 lines
4.8 KiB
TypeScript
125 lines
4.8 KiB
TypeScript
import { useCallback } from "react";
|
|
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
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 { 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 { t } = useTranslation();
|
|
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: t("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: t("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: t("recorder.pill.stepMarked", { n }) });
|
|
},
|
|
[sessionId, t],
|
|
);
|
|
|
|
/** 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, t("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"];
|