diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index d737372ae..c086daf26 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -23,6 +23,54 @@ import { } from "#utils/mockTrace"; import { traceStepToMachineState } from "#utils/traceState"; +/** + * Compute a key representing an instruction's source range, + * used to detect when stepping has moved to a new source + * location. Returns empty string for instructions without + * source ranges. + */ +function sourceRangeKey(instruction: Program.Instruction | undefined): string { + if (!instruction?.context) return ""; + + const ctx = instruction.context as Record; + const ranges = collectCodeRanges(ctx); + if (ranges.length === 0) return ""; + + return ranges.map((r) => `${r.offset}:${r.length}`).join(","); +} + +function collectCodeRanges( + ctx: Record, +): Array<{ offset: number; length: number }> { + if ("code" in ctx && typeof ctx.code === "object") { + const code = ctx.code as Record; + if (code.range && typeof code.range === "object") { + const r = code.range as Record; + if (typeof r.offset === "number" && typeof r.length === "number") { + return [{ offset: r.offset, length: r.length }]; + } + } + } + + if ("gather" in ctx && Array.isArray(ctx.gather)) { + return ctx.gather.flatMap((item: unknown) => + item && typeof item === "object" + ? collectCodeRanges(item as Record) + : [], + ); + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + return ctx.pick.flatMap((item: unknown) => + item && typeof item === "object" + ? collectCodeRanges(item as Record) + : [], + ); + } + + return []; +} + /** * A variable with its resolved value. */ @@ -98,10 +146,14 @@ export interface TraceState { /** Whether we're at the last step */ isAtEnd: boolean; - /** Move to the next step */ + /** Move to the next trace step */ stepForward(): void; - /** Move to the previous step */ + /** Move to the previous trace step */ stepBackward(): void; + /** Step to the next different source range */ + stepToNextSource(): void; + /** Step to the previous different source range */ + stepToPrevSource(): void; /** Jump to a specific step */ jumpToStep(index: number): void; /** Reset to the first step */ @@ -377,6 +429,41 @@ export function TraceProvider({ setCurrentStepIndex((prev) => Math.max(prev - 1, 0)); }, []); + const stepToNextSource = useCallback(() => { + setCurrentStepIndex((prev) => { + const currentKey = sourceRangeKey(pcToInstruction.get(trace[prev]?.pc)); + for (let i = prev + 1; i < trace.length; i++) { + const instr = pcToInstruction.get(trace[i].pc); + const key = sourceRangeKey(instr); + if (key !== currentKey && key !== "") { + return i; + } + } + return trace.length - 1; + }); + }, [trace, pcToInstruction]); + + const stepToPrevSource = useCallback(() => { + setCurrentStepIndex((prev) => { + const currentKey = sourceRangeKey(pcToInstruction.get(trace[prev]?.pc)); + // First skip past all steps with the same range + let i = prev - 1; + while (i > 0) { + const key = sourceRangeKey(pcToInstruction.get(trace[i].pc)); + if (key !== currentKey && key !== "") break; + i--; + } + // Now find the start of that source range + const targetKey = sourceRangeKey(pcToInstruction.get(trace[i]?.pc)); + while (i > 0) { + const prevKey = sourceRangeKey(pcToInstruction.get(trace[i - 1]?.pc)); + if (prevKey !== targetKey) break; + i--; + } + return Math.max(0, i); + }); + }, [trace, pcToInstruction]); + const jumpToStep = useCallback( (index: number) => { setCurrentStepIndex(Math.max(0, Math.min(index, trace.length - 1))); @@ -406,6 +493,8 @@ export function TraceProvider({ isAtEnd: currentStepIndex >= trace.length - 1, stepForward, stepBackward, + stepToNextSource, + stepToPrevSource, jumpToStep, reset, jumpToEnd, diff --git a/packages/programs-react/src/components/TraceControls.tsx b/packages/programs-react/src/components/TraceControls.tsx index cbda774c7..dc3c8c499 100644 --- a/packages/programs-react/src/components/TraceControls.tsx +++ b/packages/programs-react/src/components/TraceControls.tsx @@ -33,6 +33,8 @@ export function TraceControls({ isAtEnd, stepBackward, stepForward, + stepToPrevSource, + stepToNextSource, reset, jumpToEnd, } = useTraceContext(); @@ -47,25 +49,43 @@ export function TraceControls({ title="Reset to start" type="button" > - ⏮ + ⏮ + + + diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 99cdea4b8..f14dcf3c4 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -258,6 +258,45 @@ function TraceDrawerContent(): JSX.Element { setCurrentStep((prev) => Math.max(prev - 1, 0)); }; + const rangeKey = (stepIdx: number): string => { + const step = trace[stepIdx]; + if (!step) return ""; + const instr = pcToInstruction.get(step.pc); + if (!instr?.debug?.context) return ""; + const ranges = extractSourceRange(instr.debug.context); + if (ranges.length === 0) return ""; + return ranges.map((r) => `${r.offset}:${r.length}`).join(","); + }; + + const stepToNextSource = () => { + setCurrentStep((prev) => { + const currentKey = rangeKey(prev); + for (let i = prev + 1; i < trace.length; i++) { + const key = rangeKey(i); + if (key !== currentKey && key !== "") return i; + } + return trace.length - 1; + }); + }; + + const stepToPrevSource = () => { + setCurrentStep((prev) => { + const currentKey = rangeKey(prev); + let i = prev - 1; + while (i > 0) { + const key = rangeKey(i); + if (key !== currentKey && key !== "") break; + i--; + } + const targetKey = rangeKey(i); + while (i > 0) { + if (rangeKey(i - 1) !== targetKey) break; + i--; + } + return Math.max(0, i); + }); + }; + const jumpToStart = () => setCurrentStep(0); const jumpToEnd = () => setCurrentStep(trace.length - 1); @@ -332,21 +371,37 @@ function TraceDrawerContent(): JSX.Element { ⏮ + {currentStep + 1} / {trace.length} +