Stream long G-code execution history with scrollable panes

This commit is contained in:
2026-08-29 15:52:06 +08:00
parent 9cd70990e9
commit dfe657e736
4 changed files with 23 additions and 12 deletions

View File

@@ -93,13 +93,15 @@ function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatu
const mainLine = executing && currentLine && currentLine > 0 && currentLine <= lines.length ? currentLine : null;
const executedLines = new Set(executionTrace.map((entry) => entry.line));
const editorRef = useRef<HTMLTextAreaElement>(null);
const gutterRef = useRef<HTMLDivElement>(null);
const highlightsRef = useRef<HTMLDivElement>(null);
const traceRef = useRef<HTMLOListElement>(null);
const followTraceRef = useRef(true);
useEffect(() => {
const node = traceRef.current;
if (node) node.scrollTop = node.scrollHeight;
if (node && followTraceRef.current) node.scrollTop = node.scrollHeight;
}, [executionTrace]);
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-main-layout"><div className="program-editor-wrap"><div className="program-gutter" aria-label="G-code line numbers">{lines.map((_, index) => { const line = index + 1; const className = line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : ""; return <span key={index} className={className} aria-current={line === mainLine ? "true" : undefined}>{line}</span>; })}</div><div className="program-editor-input"><div ref={highlightsRef} className="program-line-highlights" aria-hidden="true">{lines.map((_, index) => { const line = index + 1; const className = line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : ""; return <span key={index} className={className} />; })}</div><textarea ref={editorRef} id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} onScroll={() => { if (editorRef.current && highlightsRef.current) { highlightsRef.current.scrollTop = editorRef.current.scrollTop; highlightsRef.current.scrollLeft = editorRef.current.scrollLeft; } }} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div></div><div className="program-execution-panel"><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code>{executionLineText || "-"}</code></div><div className="program-execution-trace" role="log" aria-label="G-code execution trace" aria-live="polite"><strong>Subprogram / execution trace</strong><ol ref={traceRef}>{executionTrace.slice(-16).map((entry, index, entries) => { const isCurrent = executing && index === entries.length - 1; return <li key={`${entry.line}-${index}`} className={isCurrent ? "executing-line" : "completed-line"} aria-current={isCurrent ? "true" : undefined}><span>L{entry.line}</span><code>{entry.text || "-"}</code></li>; })}</ol></div></div></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onOpenFile}>Open file</TextButton><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!canRun}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!canStep}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-main-layout"><div className="program-editor-wrap"><div ref={gutterRef} className="program-gutter" aria-label="G-code line numbers">{lines.map((_, index) => { const line = index + 1; const className = line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : ""; return <span key={index} className={className} aria-current={line === mainLine ? "true" : undefined}>{line}</span>; })}</div><div className="program-editor-input"><div ref={highlightsRef} className="program-line-highlights" aria-hidden="true">{lines.map((_, index) => { const line = index + 1; const className = line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : ""; return <span key={index} className={className} />; })}</div><textarea ref={editorRef} id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} onScroll={() => { if (editorRef.current) { if (gutterRef.current) gutterRef.current.scrollTop = editorRef.current.scrollTop; if (highlightsRef.current) { highlightsRef.current.scrollTop = editorRef.current.scrollTop; highlightsRef.current.scrollLeft = editorRef.current.scrollLeft; } } }} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div></div><div className="program-execution-panel"><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code title={executionLineText}>{executionLineText || "-"}</code></div><div className="program-execution-trace" role="log" aria-label="G-code execution trace" aria-live="polite"><strong>Subprogram / execution trace <span>({executionTrace.length} cached)</span></strong><ol ref={traceRef} onScroll={(event) => { const node = event.currentTarget; followTraceRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 28; }}>{executionTrace.map((entry, index, entries) => { const isCurrent = executing && index === entries.length - 1; return <li key={`${entry.line}-${index}`} className={isCurrent ? "executing-line" : "completed-line"} aria-current={isCurrent ? "true" : undefined}><span>L{entry.line}</span><code title={entry.text}>{entry.text || "-"}</code></li>; })}</ol></div></div></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onOpenFile}>Open file</TextButton><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!canRun}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!canStep}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
}
function DiagnosticsPanel({ state, gates }: { state: typeof INITIAL_STATE; gates: Record<string, { enabled: boolean; reason: string }> }) {

View File

@@ -27,6 +27,10 @@ export type FrontendState = {
executionTrace: Array<{ line: number; text: string; program_status?: string | null }>;
};
// Keep enough authoritative WASM execution lines for long files while bounding
// browser memory when a session runs continuously.
export const EXECUTION_TRACE_CACHE_LIMIT = 8192;
export const INITIAL_STATE: FrontendState = {
status: "disconnected",
contextId: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
@@ -64,7 +68,7 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
.filter((entry) => Number.isSafeInteger(entry.line) && entry.line > 0)
: [];
const resetExecutionTrace = ["PROGRAM_LOAD", "PROGRAM_RESTART"].includes(String(result.command ?? ""));
const executionTrace = resetExecutionTrace ? trace.slice(-32) : trace.length ? [...previous.executionTrace, ...trace].slice(-32) : previous.executionTrace;
const executionTrace = resetExecutionTrace ? trace.slice(-EXECUTION_TRACE_CACHE_LIMIT) : trace.length ? [...previous.executionTrace, ...trace].slice(-EXECUTION_TRACE_CACHE_LIMIT) : previous.executionTrace;
const delta = result.motion_sample as { tick?: number; pose?: Record<string, number>; joints?: number[]; active_segment_id?: number; active_progress?: number } | undefined;
if (!panel && delta && previous.snapshot) {
const pose = delta.pose ?? {};

View File

@@ -189,20 +189,21 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.program-line-highlights span { display: block; height: 17px; margin: 0 -8px; }
.program-line-highlights span.completed-line { background: #eeeeee; }
.program-line-highlights span.current-line { background: #ffe066; }
.program-editor-input textarea { position: relative; z-index: 1; width: 100%; height: 100%; min-height: 180px; padding: 6px 8px; resize: none; border: 0; outline: none; background: transparent; color: #111; font: 12px/1.45 ui-monospace, monospace; tab-size: 4; }
.program-editor-input textarea { position: relative; z-index: 1; width: 100%; height: 100%; min-height: 180px; padding: 6px 8px; overflow: auto; resize: none; border: 0; outline: none; background: transparent; color: #111; font: 12px/1.45 ui-monospace, monospace; tab-size: 4; white-space: pre; scrollbar-gutter: stable both-edges; }
.program-execution-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); grid-column: 2; grid-row: 1; min-width: 0; min-height: 0; border-left: 1px solid #9aa8b4; background: #f4f7fa; }
.program-execution-line { display: grid; grid-column: 1; grid-row: 1; grid-template-columns: max-content minmax(0, 1fr); align-items: baseline; gap: 10px; min-width: 0; padding: 5px 8px; border-top: 1px solid #9aa8b4; border-bottom: 1px solid #9aa8b4; background: #ffe066; color: #3f2d00; font: 12px/1.35 ui-monospace, monospace; }
.program-execution-line { display: flex; grid-column: 1; grid-row: 1; align-items: baseline; gap: 10px; min-width: 0; padding: 5px 8px; overflow-x: auto; overflow-y: hidden; border-top: 1px solid #9aa8b4; border-bottom: 1px solid #9aa8b4; background: #ffe066; color: #3f2d00; font: 12px/1.35 ui-monospace, monospace; scrollbar-gutter: stable; }
.program-execution-line span { font-weight: 700; white-space: nowrap; }
.program-execution-line code { min-width: 0; overflow: hidden; color: #111; text-overflow: ellipsis; white-space: nowrap; }
.program-execution-line code { flex: 0 0 auto; min-width: max-content; overflow: visible; color: #111; white-space: pre; }
.program-execution-trace { display: grid; grid-template-rows: auto minmax(0, 1fr); grid-column: 1; grid-row: 2; min-width: 0; min-height: 0; padding: 7px 8px; overflow: hidden; border-bottom: 1px solid #9aa8b4; background: #f4f7fa; color: #253746; font: 11px/1.35 ui-monospace, monospace; }
.program-execution-trace strong { display: block; margin-bottom: 2px; font-size: 11px; }
.program-execution-trace ol { display: grid; align-content: start; gap: 2px; max-height: none; margin: 0; padding: 0; overflow: auto; list-style: none; }
.program-execution-trace li { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 8px; min-width: 0; }
.program-execution-trace ol { display: grid; align-content: start; gap: 2px; width: 100%; max-width: 100%; max-height: none; margin: 0; padding: 0; overflow: auto; list-style: none; scrollbar-gutter: stable both-edges; }
.program-execution-trace li { display: grid; grid-template-columns: 42px max-content; gap: 8px; width: max-content; min-width: max-content; }
.program-execution-trace li span { color: #174d84; font-weight: 700; }
.program-execution-trace li code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.program-execution-trace li code { min-width: max-content; overflow: visible; white-space: pre; }
.program-execution-trace li.completed-line { padding: 3px 6px; border-left: 3px solid #b8b8b8; background: #e8e8e8; color: #666; }
.program-execution-trace li.executing-line { padding: 3px 6px; border-left: 3px solid #d09b00; background: #ffe066; color: #111; font-weight: 700; }
.program-execution-trace li.executing-line span { color: #6b4b00; }
.program-execution-trace strong span { color: #60717e; font-weight: 400; }
.program-side { display: flex; grid-column: 2; grid-row: 1; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; padding: 8px; border-left: 1px solid #aaa; background: #d9d9d9; }
.program-actions { display: flex; flex-wrap: wrap; gap: 5px; }
.active-gcodes-label { display: grid; gap: 5px; color: #333; }

View File

@@ -75,6 +75,7 @@ export class WasmProgramRuntime {
this.path = null;
this.subroutines = [];
this.executionSource = "";
this.executionLines = [];
this.rapidVelocity = 35;
if (!this.available || typeof core?._malloc !== "function") return;
this.pointer = core._malloc(call(core, "sim_program_size", "number", [], []));
@@ -106,6 +107,7 @@ export class WasmProgramRuntime {
this.path = path;
this.subroutines = [];
this.executionSource = source;
this.executionLines = source.split(/\r?\n/);
return { status: "verified", program: this.snapshot() };
} finally { this.core._free(pointer); }
}
@@ -167,7 +169,10 @@ export class WasmProgramRuntime {
if (code !== 0) return { status: "error", code: `wasm_program_add_subroutine_${code}`, result: code };
this.subroutines.push({ name: String(name ?? "subroutine"), source });
if (this.executionSource && !this.executionSource.endsWith("\n")) this.executionSource += "\n";
if (this.executionLines.length && this.executionLines.at(-1) !== "") this.executionLines.push("");
this.executionSource += source;
if (this.executionLines.length && this.executionLines.at(-1) === "") this.executionLines.pop();
this.executionLines.push(...source.split(/\r?\n/));
return { status: "verified", command: "sim_program_add_subroutine", name: String(name ?? "subroutine") };
} finally { this.core._free(pointer); }
}
@@ -261,11 +266,10 @@ export class WasmProgramRuntime {
const state = JSON.parse(readString(this.core, buffer, required));
const hash = call(this.core, "sim_program_state_hash", "number", ["number"], [this.pointer]);
const programStatus = { 0: "idle", 1: "loaded", 2: "running", 3: "paused", 4: "completed", 5: "aborted", 6: "fault" }[state.status] ?? "idle";
const executionLines = this.executionSource ? this.executionSource.split(/\r?\n/) : [];
const executionLineText = state.current_line > 0 ? (executionLines[state.current_line - 1] ?? "").trim() : "";
const executionLineText = state.current_line > 0 ? (this.executionLines[state.current_line - 1] ?? "").trim() : "";
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null };
} finally { this.core._free(buffer); }
}
destroy() { for (const pointer of [this.pointer, this.motionPointer, this.samplePointer, this.samplePosePointer]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointer = this.motionPointer = this.samplePointer = this.samplePosePointer = 0; this.executionSource = ""; }
destroy() { for (const pointer of [this.pointer, this.motionPointer, this.samplePointer, this.samplePosePointer]) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointer = this.motionPointer = this.samplePointer = this.samplePosePointer = 0; this.executionSource = ""; this.executionLines = []; }
}