Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
3.6 KiB
TypeScript
103 lines
3.6 KiB
TypeScript
/**
|
||
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger
|
||
* it. Talks only to the store and the editor state – no widgets of its own beyond the menus.
|
||
*/
|
||
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||
import { listScenes } from '../core/model/Project';
|
||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||
import { Dialog, Menu, type MenuItem, type Widget } from '../ui';
|
||
import type { EditorState } from './state';
|
||
|
||
export class ProjectSession {
|
||
/**
|
||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted –
|
||
* a run in progress has to end first, since it holds a copy of the project being replaced.
|
||
*/
|
||
constructor(
|
||
private readonly state: EditorState,
|
||
private readonly store: ProjectStore,
|
||
private readonly beforeSwitch: () => void,
|
||
) {}
|
||
|
||
/** Opens the project named in the URL, else the first one on the server, else a new one. */
|
||
async boot(): Promise<void> {
|
||
const wanted = new URLSearchParams(location.search).get('project');
|
||
try {
|
||
if (wanted) {
|
||
await this.open(wanted);
|
||
return;
|
||
}
|
||
await this.openFirstOrCreate();
|
||
} catch (error) {
|
||
this.state.setNotice(
|
||
`Could not load project: ${error instanceof Error ? error.message : String(error)}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async open(id: string): Promise<void> {
|
||
this.beforeSwitch();
|
||
const loaded = await this.store.load(id);
|
||
this.state.loadProject(loaded.project, loaded.revision);
|
||
const url = new URL(location.href);
|
||
url.searchParams.set('project', id);
|
||
history.replaceState(null, '', url);
|
||
}
|
||
|
||
private async openFirstOrCreate(): Promise<void> {
|
||
const list = await this.store.list();
|
||
if (list[0]) await this.open(list[0].id);
|
||
else await this.create('My first project');
|
||
}
|
||
|
||
private async create(name: string): Promise<void> {
|
||
const created = await this.store.create(name);
|
||
await this.open(created.project.id);
|
||
}
|
||
|
||
private async deleteCurrent(): Promise<void> {
|
||
this.beforeSwitch();
|
||
await this.store.delete(this.state.project.id);
|
||
await this.openFirstOrCreate();
|
||
}
|
||
|
||
openProjectMenu(anchor: Widget): void {
|
||
const open = async () => {
|
||
const list = await this.store.list();
|
||
const items: MenuItem[] = [
|
||
{ label: 'New project', onSelect: () => void this.create(`Project ${list.length + 1}`) },
|
||
...list.map((p) => ({
|
||
label: p.id === this.state.project.id ? `● ${p.name}` : p.name,
|
||
onSelect: () => void this.open(p.id),
|
||
disabled: p.id === this.state.project.id,
|
||
})),
|
||
{
|
||
label: 'Delete this project…',
|
||
danger: true,
|
||
onSelect: () =>
|
||
new Dialog(
|
||
`Delete project "${this.state.project.name}"?`,
|
||
'All assets and scenes are removed permanently.',
|
||
[
|
||
{ label: 'Cancel' },
|
||
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() },
|
||
],
|
||
).openCentered(),
|
||
},
|
||
];
|
||
new Menu(items).openBelow(anchor);
|
||
};
|
||
void open().catch((error) => this.state.setNotice(String(error)));
|
||
}
|
||
|
||
openSceneMenu(anchor: Widget): void {
|
||
const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({
|
||
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
|
||
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
|
||
}));
|
||
if (items.length === 0)
|
||
items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
|
||
new Menu(items).openBelow(anchor);
|
||
}
|
||
}
|