Files
feuerwerk/src/editor/autosave.ts
T
rootandClaude Opus 5 c47fd5e4dc Console panel: capture console.*, timestamped lines, resizable (#50)
A log layer (src/editor/console/log.ts) keeps a 500-line ring buffer and
wraps console.log/info/debug/warn/error, still calling the original. Notices
and behaviour runtime errors go straight to the layer instead of through
console, so editor tests stay quiet and errors can render red. world.log is
untouched: it is per-run structured state that runtime tests and e2e read
directly, not a text stream.

The panel sits under the visible tab, resizable with Splitter('y'), with a
CONSOLE header and a clear button. Each line carries its wall-clock time in
its own dimmed column, so locate() still finds the message text.

The taller centre area drops the graph fit zoom to 0.60, which broke six e2e
checks that assumed one screen pixel is one graph unit and that a fixed
offset from the canvas centre is empty. They now ask __gm.emptySpot(),
__gm.graph.emptySpot() and __gm.graph.zoom() instead of guessing.

locate/locateAll added the unscaled Pixi text width to the global position,
so click points in the scaled graph canvas landed too far right; they use
getBounds() now.

check.sh: 831 tests. Full e2e: ALL PASSED - 671 checks. Smokes 24/19/20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 19:35:40 +02:00

75 lines
2.2 KiB
TypeScript

import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
import type { EditorState } from './state';
/**
* Debounced autosave: every model change schedules a save ~1 s later. Saves never run
* during Play (`state.project` is the runtime copy then; a pending save resumes on Stop)
* and never overlap; a change that arrives while a save is in flight triggers another save
* afterwards.
*/
export function attachAutosave(
state: EditorState,
store: ProjectStore,
delayMs = 1000,
): { flush: () => Promise<void> } {
let timer = 0;
let saving: Promise<void> | null = null;
let pending = false;
const save = async (): Promise<void> => {
if (saving) {
pending = true;
return saving;
}
if (state.saveStatus !== 'dirty' || !state.project.id || state.mode === 'play') return;
state.setSaveStatus('saving');
saving = (async () => {
try {
state.revision = await store.save(state.project, state.revision);
state.setSaveStatus(pending ? 'dirty' : 'saved');
} catch (error) {
if (error instanceof StaleRevisionError) {
state.setSaveStatus('conflict');
state.setNotice('This project was changed elsewhere. Reload to continue.', 'error');
} else {
state.setSaveStatus('error');
state.setNotice(
`Saving failed: ${error instanceof Error ? error.message : String(error)}`,
'error',
);
}
} finally {
saving = null;
if (pending) {
pending = false;
if (state.saveStatus === 'dirty') schedule();
}
}
})();
return saving;
};
const schedule = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void save(), delayMs);
};
state.on('save', () => {
if (state.saveStatus === 'dirty') schedule();
});
const flush = async (): Promise<void> => {
window.clearTimeout(timer);
await save();
};
window.addEventListener('beforeunload', (event) => {
if (state.saveStatus === 'dirty' || state.saveStatus === 'saving') {
void flush();
event.preventDefault();
}
});
return { flush };
}