The send, the draft and the screenshots take the session's row lock, so an autosave or a paste during a send waits for it and then sees it, and two sends cannot open two issues. A save keeps the delivery's progress; a failed edit that shows the screenshots is finished by the retry; an answer that is not the tracker's API (a redirect, a proxy page) is refused, not a crash that loses the issue's record. Only an attempt that opened an issue counts toward the pace. Screenshots: deleting one after the send is refused, a user's storage is capped across sessions, new sessions and zip exports are paced, images are stored uncompressed in the zip. One liveness rule, one session view, one image type table. Logging: a session's elevation is tracked apart from the user's own, so the browser's own requests (no header) no longer lower the floor a session needs; Stop ends it at once. Composer: an image pasted anywhere is taken; what is still unsaved is saved on close, and "saved" is shown only when it is; the draft is held while it is sent; a failed screenshot read says so instead of reading as none; focus moves to what stays; the preview follows the text size. Pill: the step prompt and a running Stop survive the pill moving between hosts, the prompt keeps the dialog's Tab trap, the toast region lets clicks through, other tabs hear of a Start or Stop, the last seconds are sent before the cap, a capture with no frame is an error.
78 lines
3.5 KiB
TypeScript
78 lines
3.5 KiB
TypeScript
import { createStore } from "./store";
|
|
|
|
// The debug recorder's app-wide state (E16) — what several surfaces open or read, held outside any
|
|
// one of them: Settings, the command palette and the recorder itself all open the start dialog, and
|
|
// the composer opens for whichever session just ended.
|
|
|
|
const startOpen = createStore(false);
|
|
/** Open the start dialog (Settings → Diagnosztika, the palette). */
|
|
export const openRecorderStart = (): void => startOpen.set(true);
|
|
export const closeRecorderStart = (): void => startOpen.set(false);
|
|
export const useRecorderStartOpen = (): boolean => startOpen.use();
|
|
|
|
// The pill's step prompt — open, and what is typed in it — held here, not in the pill: the pill
|
|
// rides the topmost toast host, and a dialog or the player opening or closing moves it to another
|
|
// one (a remount). The prompt must come along with what was typed.
|
|
const stepPrompt = createStore<string | null>(null);
|
|
/** The step prompt's text while it is open, else null. */
|
|
export const useStepPrompt = (): string | null => stepPrompt.use();
|
|
export const setStepPrompt = (text: string | null): void => stepPrompt.set(text);
|
|
|
|
// Other tabs of this browser: a Start or a Stop in one is news to the rest, which otherwise learn of
|
|
// it only at their next `me` poll — and meanwhile record into a session that has ended.
|
|
const CHANNEL = "siftlode.debugSession";
|
|
let channel: BroadcastChannel | null | undefined;
|
|
function tabs(): BroadcastChannel | null {
|
|
if (channel === undefined)
|
|
channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL);
|
|
return channel;
|
|
}
|
|
/** Tell the other tabs the recording started or stopped. */
|
|
export const announceSessionChange = (): void => tabs()?.postMessage("changed");
|
|
/** Run `fn` when another tab started or stopped a recording. */
|
|
export function onSessionChangeElsewhere(fn: () => void): () => void {
|
|
const c = tabs();
|
|
if (!c) return () => {};
|
|
const handler = () => fn();
|
|
c.addEventListener("message", handler);
|
|
return () => c.removeEventListener("message", handler);
|
|
}
|
|
|
|
const composing = createStore<string | null>(null);
|
|
/** Open the issue composer for session `id` — after Stop, when the cap ends it, from Settings. */
|
|
export const openComposer = (id: string): void => composing.set(id);
|
|
export const closeComposer = (): void => composing.set(null);
|
|
export const useComposerSession = (): string | null => composing.use();
|
|
|
|
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
|
|
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
|
|
// the list the issue is drafted from contradict itself.
|
|
const STEP_KEY = "siftlode.debugSteps";
|
|
|
|
function readSteps(): Record<string, number> {
|
|
try {
|
|
const raw = JSON.parse(sessionStorage.getItem(STEP_KEY) ?? "{}") as unknown;
|
|
return raw && typeof raw === "object" ? (raw as Record<string, number>) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
const steps = createStore<Record<string, number>>(readSteps());
|
|
|
|
/** How many steps session `id` has marked in this tab. */
|
|
export const useStepCount = (id: string): number => steps.use()[id] ?? 0;
|
|
|
|
/** Take the next step number for session `id`. */
|
|
export function nextStep(id: string): number {
|
|
const n = (steps.get()[id] ?? 0) + 1;
|
|
// Only this session's count is kept: the others ended, and their numbers are in their events.
|
|
steps.set({ [id]: n });
|
|
try {
|
|
sessionStorage.setItem(STEP_KEY, JSON.stringify(steps.get()));
|
|
} catch {
|
|
// Storage off: the count lives until the reload, which is all it is for.
|
|
}
|
|
return n;
|
|
}
|