diff --git a/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml b/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml index 9b7dbbc1..d926a592 100644 --- a/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/02-tooling_bug_report.yml @@ -14,6 +14,7 @@ body: label: Which tooling is this bug report for? options: - example-plugin + - example-theme - vite-plugin - other validations: diff --git a/.github/ISSUE_TEMPLATE/03-feature_request.yml b/.github/ISSUE_TEMPLATE/03-feature_request.yml index 3a5aed83..4af8efb8 100644 --- a/.github/ISSUE_TEMPLATE/03-feature_request.yml +++ b/.github/ISSUE_TEMPLATE/03-feature_request.yml @@ -39,6 +39,7 @@ body: - settings-ui - viewport - example-plugin + - example-theme - vite-plugin - other validations: diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml index c315c747..b4f3b073 100644 --- a/.github/issue-labeler.yml +++ b/.github/issue-labeler.yml @@ -82,5 +82,8 @@ plugins:viewport: tooling:example-plugin: - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nexample-plugin\\n" +tooling:example-theme: + - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nexample-theme\\n" + tooling:vite-plugin: - "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nvite-plugin\\n" diff --git a/.github/labeler.yml b/.github/labeler.yml index 929ca27f..a0dede96 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -172,6 +172,12 @@ tooling:example-plugin: - tooling/example-plugin/* - tooling/example-plugin/**/* +tooling:example-theme: + - changed-files: + - any-glob-to-any-file: + - tooling/example-theme/* + - tooling/example-theme/**/* + tooling:vite-plugin: - changed-files: - any-glob-to-any-file: diff --git a/.github/labels.yml b/.github/labels.yml index d22ddaa6..3e38b1f5 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -166,6 +166,10 @@ description: 'Related to Example plugin tooling' color: '606bdf' +- name: 'tooling:example-theme' + description: 'Related to Example theme tooling' + color: '606bdf' + - name: 'tooling:vite-plugin' description: 'Related to Vite plugin tooling' color: '606bdf' diff --git a/apps/editor/e2e/adoption.spec.ts b/apps/editor/e2e/adoption.spec.ts new file mode 100644 index 00000000..c9ce5a26 --- /dev/null +++ b/apps/editor/e2e/adoption.spec.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { openProject, resetLayout } from './helpers'; +import { type Page, expect, test } from './test'; +import { PONG } from './workspace'; + +const APPS = join(PONG, 'apps'); +const COMPONENTS = join(APPS, 'client/src/components'); + +/** Every file under a folder, with its bytes (images too: not as text). */ +const snapshot = (dir = APPS): Map => + new Map( + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() + ? [...snapshot(path)] + : [[path, readFileSync(path).toString('base64')] as const]; + }), + ); + +let original: Map; +test.beforeEach(() => { + original = snapshot(); +}); +test.afterEach(async ({ page }) => { + for (const path of snapshot().keys()) if (!original.has(path)) rmSync(path); + for (const [path, bytes] of original) writeFileSync(path, Buffer.from(bytes, 'base64')); + /** The saved layout outlives the test. */ + await page.waitForTimeout(600); +}); + +const report = (page: Page) => page.getByRole('region', { name: 'Adapt to the editor' }); +/** The report starts collapsed. */ +const expand = async (page: Page) => + report(page).getByRole('button', { name: 'Show suggestions' }).click({ timeout: 30_000 }); +const card = (page: Page, title: string, app: string) => + report(page).getByRole('listitem', { name: title }).filter({ hasText: app }); + +test('the report splits a file of components, previewed, then undone in one step', async ({ + page, +}) => { + await openProject(page, 'pong'); + await page.waitForURL(/\/project\//); + await resetLayout(page); + await page.getByRole('tab', { name: 'Project', exact: true }).click(); + await expand(page); + + await expect(card(page, 'Register the editor library', 'pong-network-client')).toBeVisible({ + timeout: 30_000, + }); + const split = card(page, 'Split components.ts', 'pong-network-client'); + await expect(split).toBeVisible({ timeout: 30_000 }); + await split.getByRole('button', { name: 'Split into one file each…' }).click(); + + const preview = page.getByRole('dialog', { name: 'Split components.ts' }); + await expect( + preview.getByRole('region', { name: 'apps/client/src/components/position.ts' }), + ).toContainText('New'); + await expect( + preview.getByRole('region', { name: 'apps/client/src/components/components.ts' }), + ).toContainText('Removed'); + expect(existsSync(join(COMPONENTS, 'position.ts'))).toBe(false); + await preview.getByRole('button', { name: 'Apply' }).click(); + + await expect.poll(() => existsSync(join(COMPONENTS, 'components.ts'))).toBe(false); + expect(readFileSync(join(COMPONENTS, 'position.ts'), 'utf8')).toContain('export class Position'); + expect(readFileSync(join(APPS, 'client/src/systems/systems.ts'), 'utf8')).toContain( + 'from "../components/position"', + ); + await expect(split).toHaveCount(0, { timeout: 15_000 }); + await expect( + card(page, 'circle-component.ts imports main.ts', 'pong-network-client'), + ).toBeVisible(); + + await page.locator('.overview').click({ position: { x: 4, y: 4 } }); + await page.keyboard.press('Control+z'); + await expect.poll(() => snapshot()).toEqual(original); +}); + +test('a suggestion is dismissed for the project', async ({ page }) => { + await openProject(page, 'pong'); + await page.waitForURL(/\/project\//); + await page.getByRole('tab', { name: 'Project', exact: true }).click(); + await expand(page); + const install = report(page).getByRole('listitem', { name: 'Install the dependencies' }); + await expect(install).toBeVisible({ timeout: 30_000 }); + await install.getByRole('button', { name: 'Dismiss' }).click(); + await expect(install).toHaveCount(0); + await page.waitForTimeout(900); + await page.reload(); + await page.getByRole('tab', { name: 'Project', exact: true }).click(); + await expand(page); + await expect(card(page, 'Register the editor library', 'pong-network-client')).toBeVisible({ + timeout: 30_000, + }); + await expect(install).toHaveCount(0); +}); diff --git a/apps/editor/e2e/appearance.spec.ts b/apps/editor/e2e/appearance.spec.ts new file mode 100644 index 00000000..7a1434f3 --- /dev/null +++ b/apps/editor/e2e/appearance.spec.ts @@ -0,0 +1,86 @@ +import { openProject, resetLayout, showPanel } from './helpers'; +import { type Page, expect, test } from './test'; + +const openSettings = async (page: Page) => { + await page.locator('[data-nf-part="screen"]').click(); + await page.keyboard.press('Control+Alt+s'); + const dialog = page.getByRole('dialog', { name: 'Settings' }); + await dialog.getByRole('treeitem', { name: 'Appearance' }).click(); + return dialog; +}; +const variable = (page: Page, name: string) => + page.evaluate( + (token) => getComputedStyle(document.documentElement).getPropertyValue(token).trim(), + name, + ); + +/** Puts the theme, the density and the icons back (account settings outlive the test). */ +const restore = async (page: Page) => { + const open = page.getByRole('dialog', { name: 'Settings' }); + if (await open.isVisible()) await open.getByRole('button', { name: 'Cancel' }).click(); + await expect(open).toBeHidden(); + const dialog = await openSettings(page); + const panel = dialog.getByRole('region', { name: 'Appearance' }); + await panel.getByRole('radio', { name: 'NanoForge Dark' }).click(); + await panel.getByRole('radio', { name: 'Normal' }).click(); + await panel.getByRole('button', { name: 'Icon theme' }).click(); + await page.getByRole('option', { name: 'NanoForge', exact: true }).click(); + await dialog.getByRole('button', { name: 'OK' }).click(); + await expect(dialog).toBeHidden(); + await page.waitForTimeout(900); +}; + +test('themes, density and icons from a plugin, chosen in Settings › Appearance', async ({ + page, +}) => { + test.setTimeout(90_000); + await openProject(page, 'pong'); + await page.waitForURL(/\/project\//); + await resetLayout(page); + try { + const dialog = await openSettings(page); + const panel = dialog.getByRole('region', { name: 'Appearance' }); + const themes = panel.getByRole('radiogroup', { name: 'Theme' }); + await expect(themes.getByRole('radio', { name: 'NanoForge Dark' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + await themes.getByRole('radio', { name: 'Ocean' }).click(); + await panel.getByRole('radio', { name: 'Compact' }).click(); + await panel.getByRole('button', { name: 'Icon theme' }).click(); + await page.getByRole('option', { name: 'Ocean icons' }).click(); + await dialog.getByRole('button', { name: 'Apply' }).click(); + + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.nfTheme)) + .toBe('acme-ocean'); + expect(await variable(page, '--nf-color-accent')).toBe('#176aa8'); + expect(await variable(page, '--nf-color-text')).toBe('#d8dde7'); + expect(await variable(page, '--nf-radius-control')).toBe('4px'); + expect(await variable(page, '--nf-control-height')).toBe('22px'); + + await panel.getByRole('button', { name: 'Get more themes' }).click(); + const plugins = dialog.getByRole('region', { name: 'Plugins' }); + await expect(plugins.getByRole('textbox', { name: 'Search plugins' })).toHaveValue( + 'keywords:theme', + ); + await expect(plugins.getByText('@acme/sunset').first()).toBeVisible(); + await expect(plugins.getByText('@acme/badge')).toHaveCount(0); + /** The first result's details load and render: close once they are there. */ + await expect(plugins.getByRole('button', { name: 'Install' })).toBeVisible(); + await dialog.getByRole('button', { name: 'OK' }).click(); + await expect(dialog).toBeHidden(); + + await showPanel(page, 'Files'); + const tree = page.getByRole('tree', { name: 'Project files' }); + await expect(tree.getByRole('treeitem', { name: 'apps' }).locator('.nf-icon-file')).toHaveCount( + 1, + ); + } finally { + await restore(page); + } + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.nfTheme)) + .toBe('nanoforge-dark'); + expect(await variable(page, '--nf-control-height')).toBe('26px'); +}); diff --git a/apps/editor/e2e/prepare-workspace.mjs b/apps/editor/e2e/prepare-workspace.mjs index a97da526..acdc1777 100644 --- a/apps/editor/e2e/prepare-workspace.mjs +++ b/apps/editor/e2e/prepare-workspace.mjs @@ -61,6 +61,12 @@ for (const [path, content] of Object.entries(files)) { writeFileSync(join(fixture, path), content); } +/** The example theme has no code: its build copies its manifest and icons into dist/. */ +execFileSync('node', ['build.mjs'], { + cwd: join(import.meta.dirname, '../../../tooling/example-theme'), + stdio: 'ignore', +}); + const registry = { '@acme/badge/1.0.0/nanoforge.manifest.json': JSON.stringify({ type: 'plugin', @@ -108,6 +114,17 @@ const registry = { dependencies: { '@acme/shapes': '^1.0.0' }, }), '@acme/render/1.0.0/README.md': 'Draws the shapes of @acme/shapes.\n', + '@acme/sunset/1.0.0/nanoforge.manifest.json': JSON.stringify({ + type: 'plugin', + name: '@acme/sunset', + version: '1.0.0', + description: 'A warm light theme.', + keywords: ['theme'], + engines: { editor: '>=0.1.0' }, + contributes: { + themes: [{ id: 'acme-sunset', label: 'Sunset', extends: 'nanoforge-light', colors: {} }], + }, + }), }; for (const [path, content] of Object.entries(registry)) { mkdirSync(dirname(join(root, '.registry', path)), { recursive: true }); diff --git a/apps/editor/e2e/project-plugins.spec.ts b/apps/editor/e2e/project-plugins.spec.ts index 7fc03eac..2faff0cc 100644 --- a/apps/editor/e2e/project-plugins.spec.ts +++ b/apps/editor/e2e/project-plugins.spec.ts @@ -73,6 +73,23 @@ test("loads the project's plugins and names the plugins its packages suggest", a await page.keyboard.press('Escape'); }); +test('installs a suggested plugin for the project from the notification', async ({ page }) => { + write(join(PACKAGES, '@acme/motion/nanoforge.manifest.json'), { + type: 'package', + name: '@acme/motion', + version: '1.0.0', + suggestedPlugins: ['@acme/badge'], + }); + await openProject(page, 'pong'); + await page.waitForURL(/\/project\//); + await expect(page.getByText('Packages in this project suggest a plugin')).toBeVisible(); + await page.getByRole('button', { name: 'Install for this project' }).click(); + await expect + .poll(() => existsSync(join(PROJECT_PLUGINS, '@acme/badge/nanoforge.manifest.json'))) + .toBe(true); + await expect(page.getByText('Plugins installed: reload the editor to finish')).toBeVisible(); +}); + test("runs a project plugin's server entry while the project is open", async ({ page }) => { const closeProject = async () => { await page.getByRole('button', { name: 'File', exact: true }).click(); diff --git a/apps/editor/playwright.config.ts b/apps/editor/playwright.config.ts index 2c17b603..0f3eaf15 100644 --- a/apps/editor/playwright.config.ts +++ b/apps/editor/playwright.config.ts @@ -63,7 +63,11 @@ export default defineConfig({ DATA_DIR: join(root, 'e2e', WORKSPACE, '.data'), E2E_WORKSPACE: WORKSPACE, REGISTRY_DIR: join(root, 'e2e', WORKSPACE, '.registry'), - DEV_PLUGINS: [join(root, '../../tooling/example-plugin'), process.env.E2E_EXTRA_PLUGINS] + DEV_PLUGINS: [ + join(root, '../../tooling/example-plugin'), + join(root, '../../tooling/example-theme'), + process.env.E2E_EXTRA_PLUGINS, + ] .filter(Boolean) .join(','), ...(process.env.NANOFORGE_ENGINE && { NANOFORGE_ENGINE: process.env.NANOFORGE_ENGINE }), diff --git a/apps/editor/src/lib/adoption/adoption-service.ts b/apps/editor/src/lib/adoption/adoption-service.ts new file mode 100644 index 00000000..3f713c11 --- /dev/null +++ b/apps/editor/src/lib/adoption/adoption-service.ts @@ -0,0 +1,178 @@ +import { DocumentServiceToken } from '@nanoforge-dev/editor-code'; +import { + type Container, + CoreServices, + type Disposable, + DisposableStore, + type Logger, + MutableDisposable, + type Observable, + ObservableValue, + createToken, + observe, +} from '@nanoforge-dev/editor-kernel'; +import { + type ClientProject, + PROJECT_CHECKS, + type ProjectService, + type ProjectSuggestion, +} from '@nanoforge-dev/editor-project'; +import { CoreSettings, type SettingsService } from '@nanoforge-dev/editor-settings'; +import type { ChangesetPreviewService, NotificationService } from '@nanoforge-dev/editor-ui'; +import { notifyError } from '@nanoforge-dev/editor-ui/notification'; + +import type { WorkspaceActions } from '../workspace/workspace-actions'; + +/** The report of the open project. */ +export interface AdoptionReport { + readonly checking: boolean; + readonly suggestions: readonly ProjectSuggestion[]; +} + +/** Files changing this long ago are checked again. */ +const RECHECK_MS = 1500; + +/** + * The Project screen's "Adapt to the editor" report: runs the checks of `PROJECT_CHECKS` when a + * project opens and after its files change, hides dismissed suggestions, and previews a fix + * before applying it as one step of the Project history. Nothing is changed without a click. + */ +export class AdoptionService implements Disposable { + private readonly _report = new ObservableValue({ + checking: false, + suggestions: [], + }); + private readonly _store = new DisposableStore(); + private readonly _project = this._store.add(new MutableDisposable()); + private _timer: ReturnType | undefined; + private _run = 0; + + constructor( + private readonly _options: { + readonly services: Container; + readonly projects: ProjectService; + readonly settings: SettingsService; + readonly workspace: WorkspaceActions; + readonly previews: ChangesetPreviewService; + readonly notifications: NotificationService; + readonly logger: Logger; + }, + ) { + this._store.add(observe(_options.projects.current, (project) => this._follow(project))); + /** A plugin that activates later brings its checks. */ + this._store.add( + _options.services.get(CoreServices.Extensions).onDidChange((point) => { + if (point.id === PROJECT_CHECKS.id) this._schedule(); + }), + ); + } + + get report(): Observable { + return this._report.readonly(); + } + + /** Runs every check again. */ + async refresh(): Promise { + const project = this._options.projects.current.get(); + const run = ++this._run; + if (!project) { + this._report.set({ checking: false, suggestions: [] }); + return; + } + this._report.set({ ...this._report.get(), checking: true }); + const checks = this._options.services.get(CoreServices.Extensions).getValues(PROJECT_CHECKS); + const results = await Promise.all( + checks.map((check) => + check.run(project).catch((error: unknown) => { + this._options.logger.warn(`The ${check.id} check failed`, error); + return [] as ProjectSuggestion[]; + }), + ), + ); + if (run !== this._run) return; + const dismissed = new Set(this._options.settings.get(CoreSettings.adoptionDismissed)); + this._report.set({ + checking: false, + suggestions: results.flat().filter((suggestion) => !dismissed.has(suggestion.id)), + }); + } + + /** Puts a suggestion away for this project. */ + async dismiss(suggestion: ProjectSuggestion): Promise { + const dismissed = this._options.settings.get(CoreSettings.adoptionDismissed); + await this._options.settings.set( + CoreSettings.adoptionDismissed, + [...new Set([...dismissed, suggestion.id])], + 'projectLocal', + ); + this._report.set({ + ...this._report.get(), + suggestions: this._report.get().suggestions.filter((item) => item.id !== suggestion.id), + }); + } + + /** Shows the fix's changes; applies them when the user agrees. True once applied. */ + async fix(suggestion: ProjectSuggestion): Promise { + const { fix } = suggestion; + if (!fix) return false; + const { notifications, previews, workspace } = this._options; + try { + const changeset = await fix.changeset(); + if (!changeset.files.length) { + notifications.notify('info', `${fix.title}: nothing to change`); + return false; + } + const dirty = new Set(this._options.services.tryGet(DocumentServiceToken)?.dirtyUris ?? []); + const unsaved = changeset.files.filter((file) => dirty.has(file.path)); + if (unsaved.length) { + notifications.notify('warning', `${fix.title}: save your changes first`, { + detail: `${unsaved.map((file) => file.path).join(', ')} has unsaved changes.`, + }); + return false; + } + const repository = + this._options.services.get(CoreServices.ContextKeys).get('git.repository') === true; + const apply = await previews.preview({ + ...changeset, + notes: [ + ...(changeset.notes ?? []), + ...(repository + ? ['This project is a git repository: commit your work first, to compare afterwards.'] + : []), + ], + }); + if (!apply) return false; + const applied = await workspace.applyChangeset(changeset); + await this.refresh(); + return applied; + } catch (error) { + notifyError(notifications, `${fix.title}: not possible`, error); + return false; + } + } + + dispose(): void { + clearTimeout(this._timer); + this._store.dispose(); + } + + private _schedule(): void { + clearTimeout(this._timer); + this._timer = setTimeout(() => void this.refresh(), RECHECK_MS); + } + + private _follow(project: ClientProject | undefined): void { + clearTimeout(this._timer); + if (!project) { + this._project.clear(); + void this.refresh(); + return; + } + const store = new DisposableStore(); + store.add(project.fs.onDidChange(() => this._schedule())); + this._project.value = store; + void this.refresh(); + } +} + +export const AdoptionServiceToken = createToken('editor.adoption'); diff --git a/apps/editor/src/lib/adoption/core-checks.ts b/apps/editor/src/lib/adoption/core-checks.ts new file mode 100644 index 00000000..6f9026c8 --- /dev/null +++ b/apps/editor/src/lib/adoption/core-checks.ts @@ -0,0 +1,139 @@ +import { type Container, CoreServices, type Disposable } from '@nanoforge-dev/editor-kernel'; +import { + type ClientProject, + PROJECT_CHECKS, + type ProjectSuggestion, + addEditorLibrary, + librariesOf, + libraryImporters, + recordChanges, + runnableApps, + setLibraryUse, + workspaceIo, +} from '@nanoforge-dev/editor-project'; +import type { SessionInfo } from '@nanoforge-dev/editor-protocol'; + +import type { WorkspaceActions } from '../workspace/workspace-actions'; + +const under = (root: string, path: string) => !root || path.startsWith(`${root}/`); + +/** Apps that register no `EditorLibrary`: the game plays without the bridge. */ +const editorLibrary = async (project: ClientProject): Promise => { + const io = workspaceIo(project, { fresh: false }); + const suggestions: ProjectSuggestion[] = []; + for (const app of runnableApps(project.model.get())) { + if (!app.entryFile || !io.exists(app.entryFile) || !app.engineLibs['@nanoforge-dev/core']) + continue; + if (/\bEditorLibrary\b/.test(await io.read(app.entryFile))) continue; + suggestions.push({ + id: `core.editor-library:${app.id}`, + app: app.name, + title: 'Register the editor library', + detail: `Without @nanoforge-dev/editor-lib, ${app.name} plays without the editor bridge: no Pause, Step or Stop, and the World, Profiler and Network panels stay empty. This adds it to ${app.entryFile} and to package.json.`, + fix: { + title: 'Add the editor library', + changeset: () => + recordChanges(io, `Add the editor library to ${app.name}`, (memory) => + addEditorLibrary(memory, app), + ), + }, + }); + } + return suggestions; +}; + +/** Apps whose code imports a shared library their `libs` does not list (ADR 0004). */ +const unlistedLibraries = async (project: ClientProject): Promise => { + const io = workspaceIo(project, { fresh: false }); + const model = project.model.get(); + const suggestions: ProjectSuggestion[] = []; + for (const library of librariesOf(model).filter((candidate) => !candidate.unclaimed)) { + const importers = await libraryImporters(io, library); + for (const app of runnableApps(model)) { + if (app.libraries.includes(library.name)) continue; + const files = importers.filter((path) => under(app.root, path)); + if (!files.length) continue; + suggestions.push({ + id: `core.libs:${app.id}:${library.name}`, + app: app.name, + title: `List ${library.name} in libs`, + detail: `${files.slice(0, 3).join(', ')}${files.length > 3 ? ` and ${files.length - 3} more` : ''} import ${library.name}, but ${app.name}'s nanoforge.config does not list it in libs: the editor does not offer its components and systems in ${app.name}. This lists it, and adds it to package.json.`, + fix: { + title: 'List it in libs', + changeset: () => + recordChanges(io, `Use ${library.name} in ${app.name}`, (memory) => + setLibraryUse(memory, app, library, true), + ), + }, + }); + } + } + return suggestions; +}; + +/** No `node_modules`: no engine types, no builds (local editors only). */ +const dependencies = async ( + project: ClientProject, + workspace: WorkspaceActions, +): Promise => { + const apps = runnableApps(project.model.get()); + const installed = (root: string) => + project.fs.entry(root ? `${root}/node_modules` : 'node_modules') !== undefined; + if (installed('') || apps.some((app) => installed(app.root))) + return missingLibraries(project, workspace); + return [ + { + id: 'core.install', + title: 'Install the dependencies', + detail: + "The project has no node_modules: the code editor knows nothing of the engine, and the game can neither build nor play. This runs the project's package manager.", + action: { title: 'Install dependencies', run: () => workspace.install() }, + }, + ]; +}; + +/** Engine modules not on npm yet: installed from an engine checkout (linked). */ +const UNPUBLISHED = new Set(['@nanoforge-dev/scene', '@nanoforge-dev/editor-lib']); + +/** Engine libraries an app declares and its node_modules lack: builds and types fail. */ +const missingLibraries = ( + project: ClientProject, + workspace: WorkspaceActions, +): ProjectSuggestion[] => + runnableApps(project.model.get()) + .filter((app) => app.missingEngineLibs.length) + .map((app) => { + const names = app.missingEngineLibs; + const unpublished = names.filter((name) => UNPUBLISHED.has(name)); + return { + id: `core.missing-libs:${app.id}:${names.join(',')}`, + app: app.name, + title: + names.length === 1 ? `Install ${names[0]}` : `Install ${names.length} engine libraries`, + detail: `${app.name}'s package.json needs ${names.join(', ')}, and they are not installed: the game can't build, and the code editor knows nothing of ${names.length === 1 ? 'its' : 'their'} types.${ + unpublished.length + ? ` ${unpublished.join(' and ')} ${unpublished.length === 1 ? 'is' : 'are'} not on npm yet: link ${unpublished.length === 1 ? 'it' : 'them'} from an engine checkout (the engine's modules folder) until the engine publishes ${unpublished.length === 1 ? 'it' : 'them'}.` + : '' + }`, + action: { title: 'Install dependencies', run: () => workspace.install() }, + }; + }); + +/** The checks the editor itself makes (`PROJECT_CHECKS`). */ +export const registerCoreChecks = ( + services: Container, + session: SessionInfo, + workspace: WorkspaceActions, +): Disposable => { + const extensions = services.get(CoreServices.Extensions); + const contribute = (id: string, run: (project: ClientProject) => Promise) => + extensions.contribute(PROJECT_CHECKS, { id, run }, { owner: 'core' }); + const registrations = [ + contribute('core.editor-library', editorLibrary), + contribute('core.libs', unlistedLibraries), + ...(session.mode === 'OFFLINE' + ? [contribute('core.install', (project) => dependencies(project, workspace))] + : []), + ]; + return { dispose: () => registrations.forEach((registration) => registration.dispose()) }; +}; diff --git a/apps/editor/src/lib/editor/create-editor.ts b/apps/editor/src/lib/editor/create-editor.ts index 21537e28..4ff0a34d 100644 --- a/apps/editor/src/lib/editor/create-editor.ts +++ b/apps/editor/src/lib/editor/create-editor.ts @@ -34,6 +34,8 @@ import { setupSettings, } from '@nanoforge-dev/editor-settings'; import { + ChangesetPreviewServiceToken, + IconThemeServiceToken, KeybindingServiceToken, LAYOUT_HISTORY_CONTEXT, NotificationServiceToken, @@ -48,6 +50,8 @@ import { import { installSharedModules } from '$lib/plugin/shared-modules'; import { registerCorePanels } from '$lib/project/register-core-panels'; +import { AdoptionService, AdoptionServiceToken } from '../adoption/adoption-service'; +import { registerCoreChecks } from '../adoption/core-checks'; import { followProjectCode } from '../code/follow-project-code'; import { followProjectPlugins, toDescriptor } from '../plugin/follow-project-plugins'; import { followProjectCaches } from '../project/follow-project-caches'; @@ -191,6 +195,7 @@ export const createEditor = async ( services.provide(KeybindingServiceToken, shell.keybindings); services.provide(StyleServiceToken, shell.styles); services.provide(ThemeServiceToken, shell.themes); + services.provide(IconThemeServiceToken, shell.iconThemes); services.provide(TourServiceToken, shell.tours); /** `editor.local`: games, git and installs work here (tour steps, menus). */ services.get(CoreServices.ContextKeys).set('editor.local', session.mode === 'OFFLINE'); @@ -229,6 +234,22 @@ export const createEditor = async ( }), ); services.provide(WorkspaceActionsToken, workspace); + services.provide(ChangesetPreviewServiceToken, shell.changesets); + store.add(registerCoreChecks(services, session, workspace)); + services.provide( + AdoptionServiceToken, + store.add( + new AdoptionService({ + services, + projects, + settings, + workspace, + previews: shell.changesets, + notifications: shell.notifications, + logger: services.get(CoreServices.Logger).getLogger('adoption'), + }), + ), + ); store.add(registerAppContributions(services, session)); store.add( history.onDidInvalidate(({ contextId, reason }) => { diff --git a/apps/editor/src/lib/plugin/follow-project-plugins.ts b/apps/editor/src/lib/plugin/follow-project-plugins.ts index 7a7b12fe..d0ab8bb5 100644 --- a/apps/editor/src/lib/plugin/follow-project-plugins.ts +++ b/apps/editor/src/lib/plugin/follow-project-plugins.ts @@ -7,7 +7,11 @@ import { toDisposable, } from '@nanoforge-dev/editor-kernel'; import { type ProjectService } from '@nanoforge-dev/editor-project'; -import { type PluginListing, PluginsContract } from '@nanoforge-dev/editor-protocol'; +import { + type PluginListing, + PluginsContract, + RegistryContract, +} from '@nanoforge-dev/editor-protocol'; import { type RpcClient } from '@nanoforge-dev/editor-rpc'; import { CoreSettings, type SettingsService } from '@nanoforge-dev/editor-settings'; import type { NotificationService } from '@nanoforge-dev/editor-ui'; @@ -100,24 +104,74 @@ export const followProjectPlugins = (options: { return listings.filter((listing) => listing.source !== 'project'); }; + /** + * Install actions only in a trusted folder: the project's manifests choose the names, and a + * plugin installed for the user runs in every project. Keyed by trust too, so the actions come + * once the folder is trusted (the project reopens with the same id). + */ const suggest = async (project: string) => { - if (suggested.has(project)) return; - suggested.add(project); + const installable = !options.online && trusted(project); + const key = `${project}\0${installable}`; + if (suggested.has(key)) return; + suggested.add(key); const missing = (await api.suggestions({ project })).filter( (suggestion) => plugins.getPlugin(suggestion.name)?.status.kind !== 'ok', ); if (!missing.length) return; const count = missing.length > 1 ? `${missing.length} plugins` : 'a plugin'; + const names = missing.map(({ name }) => name); + /** Installs them (`registry.installPlugin`); they load once the editor reloads. */ + const install = async (scope: 'project' | 'user') => { + const registry = rpc.api(RegistryContract); + const failed: string[] = []; + for (const name of names) { + try { + const { version } = await registry.installPlugin({ name, scope, project }); + if (scope === 'user') + await settings.set( + CoreSettings.pluginsAccount, + Object.fromEntries( + Object.entries({ + ...settings.get(CoreSettings.pluginsAccount), + [name]: `^${version}`, + }).sort(([a], [b]) => a.localeCompare(b)), + ), + 'account', + ); + } catch (error) { + failed.push(`${name}: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (failed.length) + options.notifications.notify('error', 'Some plugins could not be installed', { + detail: failed.join('. '), + }); + if (failed.length < names.length) + options.notifications.notify('info', 'Plugins installed: reload the editor to finish', { + actions: [{ title: 'Reload now', run: () => location.reload() }], + timeout: 0, + }); + }; options.notifications.notify('info', `Packages in this project suggest ${count}`, { detail: `${missing .map( ({ name, suggestedBy }) => `${name} (for ${suggestedBy.map((entry) => entry.package).join(', ')})`, ) - .join( - ', ', - )}. Install a plugin in the project's .nanoforge/plugins, or in your home for every project.`, - timeout: 15_000, + .join(', ')}.${ + options.online + ? ' Install them from a local editor, or ask who runs this one.' + : installable + ? ' Install them in this project (.nanoforge/plugins), or for you in every project.' + : ' Trust this folder to install them.' + }`, + ...(installable && { + actions: [ + { title: 'Install for this project', run: () => void install('project') }, + { title: 'Install for me', run: () => void install('user') }, + ], + }), + timeout: options.online ? 15_000 : 0, }); }; diff --git a/apps/editor/src/lib/project/AdoptionReport.svelte b/apps/editor/src/lib/project/AdoptionReport.svelte new file mode 100644 index 00000000..6d856076 --- /dev/null +++ b/apps/editor/src/lib/project/AdoptionReport.svelte @@ -0,0 +1,146 @@ + + +{#if adoption && $report?.suggestions.length} +
+
+

Adapt to the editor

+

+ {$report.suggestions.length} + {$report.suggestions.length === 1 ? 'part' : 'parts'} of this project the editor can't edit well + yet. Each change is shown before it is made, and undone in one step. +

+ +
+ {#if expanded} +
    + {#each $report.suggestions as suggestion (suggestion.id)} +
  • +
    +

    {suggestion.title}

    + {#if suggestion.app}{suggestion.app}{/if} +
    +

    {suggestion.detail}

    +
    + {#if suggestion.fix} + {@const fix = suggestion.fix} + + {:else if suggestion.action} + {@const action = suggestion.action} + + {/if} + +
    +
  • + {/each} +
+ {/if} +
+{/if} + + diff --git a/apps/editor/src/lib/project/ProjectOverview.svelte b/apps/editor/src/lib/project/ProjectOverview.svelte index cb3d70b1..8f5a3c32 100644 --- a/apps/editor/src/lib/project/ProjectOverview.svelte +++ b/apps/editor/src/lib/project/ProjectOverview.svelte @@ -21,6 +21,7 @@ import { PROJECT_HISTORY_CONTEXT, WorkspaceActionsToken } from '../workspace/workspace-actions'; import AddAppDialog from './AddAppDialog.svelte'; import AddLibraryDialog from './AddLibraryDialog.svelte'; + import AdoptionReport from './AdoptionReport.svelte'; const { instance }: { instance: WidgetInstance } = $props(); const current = $derived(instance.services.get(ProjectServiceToken).current); @@ -94,6 +95,8 @@ {/if} + +

Apps

{#if actions} diff --git a/apps/editor/src/lib/workspace/workspace-actions.ts b/apps/editor/src/lib/workspace/workspace-actions.ts index 620d42ea..9fe1bde0 100644 --- a/apps/editor/src/lib/workspace/workspace-actions.ts +++ b/apps/editor/src/lib/workspace/workspace-actions.ts @@ -14,6 +14,7 @@ import { createToken, } from '@nanoforge-dev/editor-kernel'; import { + type Changeset, type ClientProject, EDITOR_LIBRARY, type NewApp, @@ -23,6 +24,7 @@ import { WorkspaceError, type WorkspaceIo, addEditorLibrary, + applyChangeset, createApp, createLibrary, nameTaken, @@ -277,6 +279,11 @@ export class WorkspaceActions implements Disposable { } } + /** Applies a previewed changeset as one step of the Project history. */ + applyChangeset(changeset: Changeset): Promise { + return this._run(changeset.title, (io) => applyChangeset(io, changeset)); + } + dispose(): void { this._store.dispose(); } @@ -295,12 +302,27 @@ export class WorkspaceActions implements Disposable { ...plain, readEdited: async (path) => documents.get(path)?.getText() ?? plain.read(path), }; - if (!code) return io; const saved = async (path: string) => { if (documents.get(path)) await documents.save(path); }; + /** + * An open file is changed through its document, then saved: never behind it. One with unsaved + * edits is refused (the operation rolls back): its text was built from the file on disk. + */ + const write = async (path: string, text: string) => { + const open = documents.get(path); + if (!open) return io.write(path, text); + if (open.dirty.get()) + throw new WorkspaceError(`${path} has unsaved changes: save it first`, [path]); + const current = open.getText(); + if (current !== text) + await documents.applyEdits(path, [{ start: 0, end: current.length, text }]); + await documents.save(path); + }; + if (!code) return { ...io, write }; return { ...io, + write, renameImports: async (path, from, to) => { const command = await code.edit( path, diff --git a/apps/editor/test/plugin/follow-project-plugins.spec.ts b/apps/editor/test/plugin/follow-project-plugins.spec.ts new file mode 100644 index 00000000..e0665432 --- /dev/null +++ b/apps/editor/test/plugin/follow-project-plugins.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ObservableValue } from '@nanoforge-dev/editor-kernel'; +import { PluginsContract } from '@nanoforge-dev/editor-protocol'; + +import { followProjectPlugins } from '../../src/lib/plugin/follow-project-plugins'; + +const setup = (trusted: boolean) => { + const current = new ObservableValue<{ id: string; trusted: boolean } | undefined>(undefined); + const notify = vi.fn(); + const plugins = { + api: { + list: async () => [], + runProjectServers: async () => {}, + suggestions: async () => [ + { name: '@acme/physics-ui', suggestedBy: [{ package: '@acme/physics' }] }, + ], + }, + }; + const disposable = followProjectPlugins({ + rpc: { + api: (contract: unknown) => (contract === PluginsContract ? plugins.api : {}), + } as never, + projects: { current } as never, + plugins: { + update: async () => {}, + getPlugin: () => undefined, + plugins: { get: () => [] }, + } as never, + notifications: { notify } as never, + logger: { warn: vi.fn(), error: vi.fn() } as never, + settings: { get: () => [] } as never, + online: false, + }); + current.set({ id: 'pong', trusted }); + return { current, notify, disposable }; +}; + +const suggestion = (notify: ReturnType) => + notify.mock.calls.find(([, title]) => String(title).startsWith('Packages in this project')); + +describe('followProjectPlugins', () => { + it('offers to install suggested plugins in a trusted folder', async () => { + const { notify, disposable } = setup(true); + await expect.poll(() => suggestion(notify)).toBeDefined(); + expect(suggestion(notify)![2].actions.map((action: { title: string }) => action.title)).toEqual( + ['Install for this project', 'Install for me'], + ); + disposable.dispose(); + }); + + it('only names them in a folder that is not trusted, and offers them once it is', async () => { + const { current, notify, disposable } = setup(false); + await expect.poll(() => suggestion(notify)).toBeDefined(); + expect(suggestion(notify)![2].actions).toBeUndefined(); + expect(suggestion(notify)![2].detail).toContain('Trust this folder'); + + notify.mockClear(); + current.set(undefined); + current.set({ id: 'pong', trusted: true }); + await expect.poll(() => suggestion(notify)?.[2].actions?.length).toBe(2); + disposable.dispose(); + }); +}); diff --git a/apps/editor/test/workspace/workspace-actions.spec.ts b/apps/editor/test/workspace/workspace-actions.spec.ts index bbcc2f54..550a7127 100644 --- a/apps/editor/test/workspace/workspace-actions.spec.ts +++ b/apps/editor/test/workspace/workspace-actions.spec.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { DocumentServiceToken } from '@nanoforge-dev/editor-code'; import { HistoryService } from '@nanoforge-dev/editor-history'; import { CommandRegistry, @@ -10,21 +11,21 @@ import { import { WorkspaceActions } from '../../src/lib/workspace/workspace-actions'; -const setup = () => { +const setup = (current = new ObservableValue({ id: 'pong' })) => { const services = new Container('test'); services.provide(CoreServices.Commands, new CommandRegistry(services)); const history = new HistoryService(); - const current = new ObservableValue<{ id: string } | undefined>({ id: 'pong' }); + const notify = vi.fn(); const actions = new WorkspaceActions({ services, projects: { current } as never, rpc: {} as never, history, - notifications: {} as never, + notifications: { notify } as never, prompts: {} as never, local: true, }); - return { actions, history, current }; + return { actions, history, current, services, notify }; }; describe('WorkspaceActions', () => { @@ -42,4 +43,47 @@ describe('WorkspaceActions', () => { await expect.poll(() => stack.state.canUndo).toBe(false); actions.dispose(); }); + + it('refuses to write over a document with unsaved changes', async () => { + const path = 'apps/client/src/main.ts'; + const write = vi.fn(); + const project = { + id: 'pong', + fs: { + entries: [{ path, kind: 'file' }], + entry: (candidate: string) => (candidate === path ? { path, kind: 'file' } : undefined), + readText: async () => ({ text: 'main();\n' }), + write, + refresh: async () => {}, + }, + }; + const { actions, services, notify } = setup(new ObservableValue(project)); + const applyEdits = vi.fn(); + const save = vi.fn(); + services.provide(DocumentServiceToken, { + get: (uri: string) => + uri === path + ? { getText: () => 'main(); edited();\n', dirty: new ObservableValue(true) } + : undefined, + dirtyUris: [path], + applyEdits, + save, + } as never); + + const applied = await actions.applyChangeset({ + title: 'Add the editor library', + files: [{ path, before: 'main();\n', after: 'main();\napp.use(lib);\n' }], + }); + + expect(applied).toBe(false); + expect(notify).toHaveBeenCalledWith( + 'error', + 'Add the editor library: not possible', + expect.objectContaining({ detail: expect.stringContaining(`${path} has unsaved changes`) }), + ); + expect(applyEdits).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + actions.dispose(); + }); }); diff --git a/docs/docs/README.md b/docs/docs/README.md index 4f8c80ed..e5d6a159 100644 --- a/docs/docs/README.md +++ b/docs/docs/README.md @@ -12,6 +12,7 @@ The pages of the documentation site for the editor (Mintlify `.mdx`). The site i "group": "Using the editor", "pages": [ "docs/editor/overview", + "docs/editor/adapt-a-project", "docs/editor/layout", "docs/editor/files", "docs/editor/code-editor", @@ -34,6 +35,7 @@ The pages of the documentation site for the editor (Mintlify `.mdx`). The site i "docs/plugins/sdk", "docs/plugins/extension-points", "docs/plugins/widgets-and-styles", + "docs/plugins/themes", "docs/plugins/settings", "docs/plugins/history", "docs/plugins/code-and-codegen", diff --git a/docs/docs/editor/adapt-a-project.mdx b/docs/docs/editor/adapt-a-project.mdx new file mode 100644 index 00000000..3615c5c5 --- /dev/null +++ b/docs/docs/editor/adapt-a-project.mdx @@ -0,0 +1,37 @@ +--- +title: 'Bring an existing project' +icon: 'wand-sparkles' +description: 'What the editor changes so it can edit a game made without it, shown before it is done' +--- + +A game written before the editor, or by hand, works in it as it is. Some ways of writing it, though, keep parts of the game out of the panels. The **Project** screen counts them under **Adapt to the editor**, and **Show suggestions** lists them: one card each, saying what the editor can't do and why the change helps. + +Nothing changes on its own. A card's button shows the change first: every file it creates, changes or removes, with its lines. **Apply** makes it as one step: `Ctrl+Z` on the Project screen puts every file back. When the project is a Git repository, the preview reminds you to commit first, so you can compare afterwards. A file with unsaved changes is never overwritten: save it first. + +**Dismiss** puts a card away for this project. _Settings › Editor › Project › Dismissed suggestions_ brings it back. + +## What the report finds + +| Card | What the editor can't do | The change | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Move … out of main.ts** | Components and systems defined in the app's entry file are not in the Components panel, have no documentation in the Inspector, and can't move to a shared library. | Each goes to a file of its own in the app's `components/` or `systems/` folder, with only the imports it uses. A helper only they use goes with them (an exported one stays: other files import it). `main.ts` imports them back. Refused when they use something `main.ts` keeps (a layer, a constant): they would import `main.ts` while it imports them, so move that to a module of its own first. | +| **Split components.ts** | Several components (or systems) in one file can only be moved, renamed or removed together. | One file each. Every file that imported them imports each from its new file. | +| **Share Position** | The client and the server each have their own copy: a change to one is not made to the other. | When the copies are the same (their imports and strings too), the component moves to a [shared library](components#share-between-client-and-server) both apps use (`libs/shared`, named after the project's scope, created if the project has none), and every import points at it. When they differ, or share a file with other items, the card says what to do first. | +| **… imports main.ts** | A component or system that imports the app's entry file can't move to a shared library. | Nothing automatic: move what it uses from `main.ts` (a layer, a constant) to a module of its own. | +| **Make main's entities a scene** | Entities `main` makes are there for the whole game. | They move into a scene, `Game`, loaded when the game starts. See [Scenes](scenes). `@nanoforge-dev/scene` goes in the app's `package.json` when another `package.json` of the project names it; otherwise the preview says to add it by hand. Refused when they use variables of `main` itself, or what `main.ts` exports (it would import the scene that imports it). | +| **Register the editor library** | Without `@nanoforge-dev/editor-lib`, a game plays without the editor bridge: no Pause, Step or Stop, no World, Profiler or Network. | `app.use(new EditorLibrary())` in `main.ts`, and the dependency in `package.json`. | +| **List … in libs** | An app imports a shared library its `nanoforge.config` does not list: the editor does not offer that library's items in the app. | The library is added to `libs` and to the app's `package.json`. | +| **Install the dependencies**, **Install …** | Without `node_modules`, or without an engine library the app declares, the game can't build and the code editor knows nothing of the engine. | Runs the project's package manager. A library not published on npm yet (`@nanoforge-dev/scene`) is linked from an engine checkout. | + + + `@nanoforge-dev/scene` and the editor library with the bridge are not on npm yet. A project turned + into scenes installs them from an engine checkout until the engine publishes them. + + +## Plugins the project needs + +When packages of the project suggest plugins, a notification names them with **Install for this project** (in `.nanoforge/plugins`) and **Install for me** (every project, and kept in your account's plugin list). Plugins load once the editor reloads. In a folder you have not trusted, the notification only names them: the project's packages choose those names, so trust the folder first. + +## Plugins can add checks + +A plugin adds its own cards with the `PROJECT_CHECKS` extension point: see [Extension points](../plugins/extension-points). diff --git a/docs/docs/editor/overview.mdx b/docs/docs/editor/overview.mdx index ddaa6f7d..3d1530ae 100644 --- a/docs/docs/editor/overview.mdx +++ b/docs/docs/editor/overview.mdx @@ -21,6 +21,7 @@ The center of the editor shows one **screen** at a time, chosen in the top bar. The Project screen lists what the project is made of, and changes it. +- **Adapt to the editor**: what the editor can't edit well in this project, with a change to preview and apply (see [Bring an existing project](adapt-a-project)). - **Apps**: each client and server, with its folder, its engine libraries and the shared libraries it uses. **Add app** creates a client or a server, empty or as a copy of another one. An empty app needs its dependencies: the editor offers to install them. - **Shared libraries**: code that several apps use (see [Components](components#share-between-client-and-server)). **Add shared library** creates one. Under **Used by**, tick the apps that use it: the editor writes it in the `libs` of each app's `nanoforge.config.ts` (`libs: ["../../libs/shared"]`) and in its `package.json`. Edit `libs` by hand and the screen follows. - The **⋯** menu of a card renames or removes it. Renaming a library rewrites every import of it. A library that code still imports can't be removed: the editor names the files. diff --git a/docs/docs/editor/settings.mdx b/docs/docs/editor/settings.mdx index 05571e78..0651ed6e 100644 --- a/docs/docs/editor/settings.mdx +++ b/docs/docs/editor/settings.mdx @@ -37,6 +37,10 @@ In the online editor, signing in as someone else on the same browser removes the The _Import & export_ page saves a scope to a `.json` file, and loads one with a preview of what would change. +## Appearance + +_Settings › Editor › Appearance_ shows each theme as a card in its own colors: click one, then **Apply**. **Density** makes panels, rows and buttons tighter or roomier. **Icons** chooses an icon theme. **Get more themes** opens the marketplace on the plugins that are themes (`keywords:theme`). + ## Keyboard shortcuts The _Keyboard shortcuts_ page lists every action with its shortcuts. diff --git a/docs/docs/plugins/extension-points.mdx b/docs/docs/plugins/extension-points.mdx index d6dfc1a4..56b77921 100644 --- a/docs/docs/plugins/extension-points.mdx +++ b/docs/docs/plugins/extension-points.mdx @@ -65,6 +65,38 @@ From `@nanoforge-dev/editor-sdk/ui`. | `VIEWPORT_OVERLAYS` | A component drawn over the running game or the scene (gizmos, debug views). | | `SCENE_EDITORS` | The content of the Scene screen for a code generation target. | +## Project + +From `@nanoforge-dev/editor-sdk`. + +`PROJECT_CHECKS`: a check of the open project, for the Project screen's **Adapt to the editor** report (see [Bring an existing project](../editor/adapt-a-project)). `run(project)` resolves with suggestions: an `id` (stable, so a dismissed one stays dismissed), a `title`, a `detail` (what the editor can't do, why the change helps) and either a `fix` or an `action`. A fix computes its change without writing anything: `recordChanges` runs workspace operations on the files kept in memory, and the editor shows the result as a diff before applying it as one undo step. + +```ts +import { PROJECT_CHECKS, recordChanges, workspaceIo } from '@nanoforge-dev/editor-sdk'; + +context.contribute(PROJECT_CHECKS, { + id: 'counter.config', + run: async (project) => { + const io = workspaceIo(project); + if (io.exists('counter.json')) return []; + return [ + { + id: 'counter.config', + title: 'Add a counter config', + detail: 'The Counter panel reads counter.json: without it, it starts at 0 every time.', + fix: { + title: 'Add counter.json', + changeset: () => + recordChanges(io, 'Add counter.json', (memory) => memory.write('counter.json', '{ "start": 0 }\n')), + }, + }, + ]; + }, +}); +``` + +Checks run when a project opens, when a check is contributed, and after files change. Run the expensive parts in the code worker (an analyzer), never on the page. + ## Points of other plugins A plugin can define its own points with `defineExtensionPoint(id)`, and others contribute to them by declaring the same id. The ECS plugin has three: diff --git a/docs/docs/plugins/manifest.mdx b/docs/docs/plugins/manifest.mdx index cceabc2e..4ce1e5d6 100644 --- a/docs/docs/plugins/manifest.mdx +++ b/docs/docs/plugins/manifest.mdx @@ -15,6 +15,7 @@ description: 'Every field of a plugin nanoforge.manifest.json' | `version` | yes | Semver. | | `displayName` | | The name shown in the editor. | | `description` | | One sentence, shown in _Settings › Plugins_ and the marketplace. | +| `keywords` | | Words the registry searches. A theme says `["theme"]`. | | `icon`, `tags`, `license`, `repository` | | Shown by the registry. | ## Compatibility @@ -30,7 +31,7 @@ description: 'Every field of a plugin nanoforge.manifest.json' ## Entries -Paths relative to the built folder. +Paths relative to the built folder. A plugin that only contributes from its manifest (a theme, icons) has no `entry`. Any other key is refused: a typo (`clinet`) keeps the plugin from loading, and the error names the key. | Field | Runs in | Used for | | -------------- | ------------------- | ------------------------------------------------------------------------------------------------------- | @@ -145,7 +146,11 @@ The key is prefixed with the plugin's name: `@acme/counter.step`. See [settings] ### `themes` -A color theme: `id`, `label`, `kind` (`dark` or `light`) and `colors`, a value for every color token. See [widgets and styles](./widgets-and-styles#theme-tokens). +A color theme: `id`, `label`, and either `extends` (another theme's id) with the colors it changes, or `kind` (`dark` or `light`) with every color. Also `syntax` (code colors) and `tokens` (fonts and corners). See [make a theme](./themes). + +### `icons`, `iconThemes` + +Icons as SVG files of the plugin, by name, and icon themes that replace the editor's icons and give file and folder icons. See [make a theme](./themes#icons). ### `tours` diff --git a/docs/docs/plugins/themes.mdx b/docs/docs/plugins/themes.mdx new file mode 100644 index 00000000..b0bf3e89 --- /dev/null +++ b/docs/docs/plugins/themes.mdx @@ -0,0 +1,106 @@ +--- +title: 'Make a theme' +icon: 'palette' +description: 'Color themes, code colors, fonts and icons, from a manifest with no code' +--- + +A theme is a plugin with no code: everything is in its `nanoforge.manifest.json`. Start one with the CLI: + +```sh +nf create plugin --name @acme/forest --template theme +cd forest +node build.mjs --watch # copies the manifest and icons to dist/ on every change +DEV_PLUGINS=$(pwd) nf editor ../my-game # in another terminal +``` + +Choose it in _Settings › Editor › Appearance_. While `--watch` runs, each save of the manifest changes the editor at once. `tooling/example-theme` in the editor's repository is a complete example: a theme, an icon and an icon theme. + +## A theme + +```json +{ + "type": "plugin", + "name": "@acme/forest", + "version": "0.1.0", + "keywords": ["theme"], + "engines": { "editor": ">=0.1.0" }, + "contributes": { + "themes": [ + { + "id": "forest", + "label": "Forest", + "extends": "nanoforge-dark", + "colors": { "accent": "#2f7d6d", "accent-hover": "#286a5d", "accent-text": "#7fd6c2" }, + "syntax": { "keyword": "#7fd6c2", "string": "#e6c07b" }, + "tokens": { "radius-control": "4px" } + } + ] + } +} +``` + +- `id`: unique among installed themes. The built-in `nanoforge-dark` and `nanoforge-light` can't be replaced: a theme whose id is already taken is left out, and named in the Console. +- `extends`: the theme it starts from, `nanoforge-dark`, `nanoforge-light` or another plugin's theme. Give only what changes. A theme that extends none gives `kind` (`dark` or `light`) and every color. +- No `entry`: a theme has no code. +- `keywords: ["theme"]` lists it under _Get more themes_ once it is in the registry. + +A color is any CSS color: `#rrggbb`, `rgb()`, `oklch()`. A value never holds `;`, `{`, `}`, `\`, `<`, `>`, `@`, `/*`, `url()` or an image, and closes its brackets and quotes: a theme brings no files but its icons. A `var()` works in the editor's own UI, but the code editor and the contrast check can't read it: give the code editor's colors (`surface`, `raised`, `text`, `accent`, `selection`…) as plain colors, or it keeps its own for them. An unknown token is refused, by name, in the Console (`Invalid contributes.themes of "@acme/forest"`). + +### Colors + +| Token | For | +| ---------------------------------------------- | ---------------------------------------------------- | +| `bg`, `surface`, `raised`, `sunken` | Behind panels, panels, menus and dialogs, inputs | +| `hover`, `pressed` | Rows and buttons under the pointer, pressed | +| `border`, `border-strong` | Lines, edges of controls | +| `text`, `text-muted`, `text-faint` | Text, from the most to the least important | +| `accent`, `accent-hover`, `on-accent` | Primary buttons and their text | +| `accent-text` | The accent used as text (links, active markers) | +| `focus`, `selection`, `selection-text` | Keyboard focus, selected rows and text | +| `danger`, `warning`, `success` | Status | +| `scrim` | Behind dialogs | + +### Code colors (`syntax`) + +`keyword`, `string`, `number`, `comment`, `type`, `variable`, `regexp`, `delimiter`. Those you leave out keep the code editor's own. + +### Tokens + +`font-ui`, `font-code`, `radius-control`, `radius-float`. A font must be one the computer has, or a web font loaded another way: the editor loads only its own. + +The _Density_ setting (compact, normal, comfortable) is the user's, not the theme's. + +## Contrast + +When a theme is applied, the editor checks it as WCAG does: the three text colors need 4.5:1 on each background (`bg`, `surface`, `raised`, `sunken`), `border-strong` 3:1, `on-accent` 4.5:1 on `accent`, and `selection-text` on `selection`. What falls under is named once in the Console: + +```text +Theme "forest" of @acme/forest has text too faint to read (WCAG AA): on-accent on accent is 4.29:1 (needs 4.5:1). +``` + +## Icons + +`contributes.icons` adds icons by name, as SVG files of the plugin. Panels, menus and file icons use them by their name. + +```json +"icons": [{ "id": "forest-leaf", "path": "icons/leaf.svg" }] +``` + +An icon theme replaces the editor's icons while it is chosen (_Settings › Editor › Appearance › Icons_). Its id is unique too: the built-in `nanoforge` can't be replaced. + +```json +"iconThemes": [ + { + "id": "forest-icons", + "label": "Forest icons", + "icons": { "folder": "icons/folder.svg", "folder-open": "icons/folder-open.svg", "forest-code": "icons/code.svg" }, + "files": [{ "pattern": "**/*.{ts,js}", "icon": "forest-code" }], + "folders": [{ "pattern": "**/assets", "icon": "folder", "iconOpen": "folder-open" }] + } +] +``` + +- `icons`: any icon of the editor by its name (`folder`, `play`, `settings`, the panels' icons…), or new names for `files` and `folders`. +- `files` and `folders`: patterns of the Files panel, ahead of every other plugin's while the icon theme is chosen. +- A path is a relative `.svg` file inside the plugin: never `..`, an absolute path or another site. +- An SVG is drawn in the color of the text around it (a CSS mask): draw it in one color, its shape only. It is never put in the page, so it can't run scripts. diff --git a/docs/docs/plugins/widgets-and-styles.mdx b/docs/docs/plugins/widgets-and-styles.mdx index 733f4b5b..1bf81e0d 100644 --- a/docs/docs/plugins/widgets-and-styles.mdx +++ b/docs/docs/plugins/widgets-and-styles.mdx @@ -97,7 +97,7 @@ Use the editor's CSS variables, never fixed colors: your panel then follows the | `--nf-radius-control`, `--nf-radius-float` | Corners | | `--nf-control-height` | Height of buttons and inputs | -A plugin adds a theme with `contributes.themes`: an `id`, a `label`, a `kind` (`dark` or `light`) and a color for every token. +A plugin adds a theme with `contributes.themes`: see [make a theme](./themes). Icons drawn with `` follow the chosen icon theme. ## Screens diff --git a/packages/kernel/schemas/plugin.schema.json b/packages/kernel/schemas/plugin.schema.json index 95e5a8a2..230589bf 100644 --- a/packages/kernel/schemas/plugin.schema.json +++ b/packages/kernel/schemas/plugin.schema.json @@ -30,6 +30,13 @@ "type": "string" } }, + "keywords": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "icon": { "type": "string", "minLength": 1 @@ -123,6 +130,7 @@ } }, "entry": { + "default": {}, "type": "object", "properties": { "client": { @@ -137,7 +145,8 @@ "type": "string", "minLength": 1 } - } + }, + "additionalProperties": false }, "activation": { "default": ["onStartup"], @@ -201,6 +210,276 @@ } }, "required": ["schema"] + }, + "themes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[\\w-]+$" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": ["dark", "light"] + }, + "extends": { + "type": "string" + }, + "colors": { + "default": {}, + "type": "object", + "properties": { + "bg": { + "type": "string", + "minLength": 1 + }, + "surface": { + "type": "string", + "minLength": 1 + }, + "raised": { + "type": "string", + "minLength": 1 + }, + "sunken": { + "type": "string", + "minLength": 1 + }, + "hover": { + "type": "string", + "minLength": 1 + }, + "pressed": { + "type": "string", + "minLength": 1 + }, + "border": { + "type": "string", + "minLength": 1 + }, + "border-strong": { + "type": "string", + "minLength": 1 + }, + "text": { + "type": "string", + "minLength": 1 + }, + "text-muted": { + "type": "string", + "minLength": 1 + }, + "text-faint": { + "type": "string", + "minLength": 1 + }, + "accent": { + "type": "string", + "minLength": 1 + }, + "accent-hover": { + "type": "string", + "minLength": 1 + }, + "on-accent": { + "type": "string", + "minLength": 1 + }, + "accent-text": { + "type": "string", + "minLength": 1 + }, + "focus": { + "type": "string", + "minLength": 1 + }, + "selection": { + "type": "string", + "minLength": 1 + }, + "selection-text": { + "type": "string", + "minLength": 1 + }, + "danger": { + "type": "string", + "minLength": 1 + }, + "warning": { + "type": "string", + "minLength": 1 + }, + "success": { + "type": "string", + "minLength": 1 + }, + "scrim": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "syntax": { + "type": "object", + "properties": { + "keyword": { + "type": "string", + "minLength": 1 + }, + "string": { + "type": "string", + "minLength": 1 + }, + "number": { + "type": "string", + "minLength": 1 + }, + "comment": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "minLength": 1 + }, + "variable": { + "type": "string", + "minLength": 1 + }, + "regexp": { + "type": "string", + "minLength": 1 + }, + "delimiter": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "tokens": { + "type": "object", + "properties": { + "font-ui": { + "type": "string", + "minLength": 1 + }, + "font-code": { + "type": "string", + "minLength": 1 + }, + "radius-control": { + "type": "string", + "minLength": 1 + }, + "radius-float": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + }, + "icons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][\\w-]*$" + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "path"], + "additionalProperties": false + } + }, + "iconThemes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[\\w-]+$" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "icons": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z0-9][\\w-]*$" + }, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "minLength": 1 + }, + "icon": { + "type": "string", + "minLength": 1 + } + }, + "required": ["pattern", "icon"], + "additionalProperties": false + } + }, + "folders": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "minLength": 1 + }, + "icon": { + "type": "string", + "minLength": 1 + }, + "iconOpen": { + "type": "string", + "minLength": 1 + } + }, + "required": ["pattern", "icon"], + "additionalProperties": false + } + } + }, + "required": ["id", "label"], + "additionalProperties": false + } } }, "additionalProperties": {} @@ -218,5 +497,5 @@ } } }, - "required": ["type", "name", "version", "engines", "entry"] + "required": ["type", "name", "version", "engines"] } diff --git a/packages/kernel/scripts/generate-schema.ts b/packages/kernel/scripts/generate-schema.ts index 732aab8c..75ef98ca 100644 --- a/packages/kernel/scripts/generate-schema.ts +++ b/packages/kernel/scripts/generate-schema.ts @@ -1,7 +1,8 @@ import { writeFileSync } from 'node:fs'; import { pluginManifestJsonSchema } from '../src/plugin/plugin-manifest'; +import { KIT_CONTRIBUTIONS } from './kit-contributions'; const target = new URL('../schemas/plugin.schema.json', import.meta.url); -writeFileSync(target, `${JSON.stringify(pluginManifestJsonSchema(), null, 2)}\n`); +writeFileSync(target, `${JSON.stringify(pluginManifestJsonSchema(KIT_CONTRIBUTIONS), null, 2)}\n`); console.log(`Wrote ${target.pathname}`); diff --git a/packages/kernel/scripts/kit-contributions.ts b/packages/kernel/scripts/kit-contributions.ts new file mode 100644 index 00000000..c55b826d --- /dev/null +++ b/packages/kernel/scripts/kit-contributions.ts @@ -0,0 +1,14 @@ +/** + * The contribution keys the kit handles, for the JSON schema. Read by path (zod only): the kernel + * does not depend on the kit. + */ +import { z } from 'zod'; + +import { IconFileSchema, IconThemeContributionSchema } from '../../ui/src/theme/icon-theme.schema'; +import { ThemeContributionSchema } from '../../ui/src/theme/theme-contribution'; + +export const KIT_CONTRIBUTIONS: Record = { + themes: z.array(ThemeContributionSchema), + icons: z.array(IconFileSchema), + iconThemes: z.array(IconThemeContributionSchema), +}; diff --git a/packages/kernel/src/plugin/plugin-manifest.ts b/packages/kernel/src/plugin/plugin-manifest.ts index 25fd842a..462676f4 100644 --- a/packages/kernel/src/plugin/plugin-manifest.ts +++ b/packages/kernel/src/plugin/plugin-manifest.ts @@ -34,6 +34,8 @@ export const PluginManifestSchema = z.object({ displayName: z.string().min(1).optional(), description: z.string().optional(), tags: z.array(z.string()).default([]), + /** Words the registry searches (`keywords:theme`): a theme plugin says `theme`. */ + keywords: z.array(z.string()).default([]), icon: EntryPath.optional(), license: z.string().optional(), repository: z.string().optional(), @@ -45,15 +47,14 @@ export const PluginManifestSchema = z.object({ .object({ required: NpmRanges, optional: NpmRanges }) .default({ required: {}, optional: {} }), + /** None for a plugin that only contributes from its manifest (a theme, icons). Unknown keys are refused. */ entry: z - .object({ + .strictObject({ client: EntryPath.optional(), server: EntryPath.optional(), worker: EntryPath.optional(), }) - .refine((entry) => entry.client || entry.server || entry.worker, { - message: 'at least one of client, server or worker is required', - }), + .default({}), /** Defaults to `["onStartup"]`. */ activation: z.array(ActivationEvent).default(['onStartup']), contributes: z @@ -88,6 +89,23 @@ export const parsePluginManifest = (value: unknown, location?: string): PluginMa ); }; -/** JSON Schema of the manifest, for editor completion (`$schema` in manifests). */ -export const pluginManifestJsonSchema = (): unknown => - z.toJSONSchema(PluginManifestSchema, { io: 'input', unrepresentable: 'any' }); +/** + * JSON Schema of the manifest, for editor completion (`$schema` in manifests). `contributes` + * gives the schemas of contribution keys handled above the kernel (`themes`, `iconThemes`…). + */ +export const pluginManifestJsonSchema = (contributes: Record = {}): unknown => { + const schema = z.toJSONSchema(PluginManifestSchema, { + io: 'input', + unrepresentable: 'any', + }) as unknown as { + properties: { contributes: { properties: Record } }; + }; + for (const [key, value] of Object.entries(contributes)) { + const part: Record = { + ...z.toJSONSchema(value, { io: 'input', unrepresentable: 'any' }), + }; + delete part.$schema; + schema.properties.contributes.properties[key] = part; + } + return schema; +}; diff --git a/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts b/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts index 27c83486..99e177aa 100644 --- a/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts +++ b/packages/kernel/test/plugin/plugin-manifest-schema.spec.ts @@ -2,11 +2,14 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; +import { KIT_CONTRIBUTIONS } from '../../scripts/kit-contributions'; import { pluginManifestJsonSchema } from '../../src/plugin/plugin-manifest'; describe('plugin JSON schema', () => { it('is up to date (run `pnpm generate:schema` in packages/kernel)', () => { const file = new URL('../../schemas/plugin.schema.json', import.meta.url); - expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(pluginManifestJsonSchema()); + expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual( + pluginManifestJsonSchema(KIT_CONTRIBUTIONS), + ); }); }); diff --git a/packages/kernel/test/plugin/plugin-manifest.spec.ts b/packages/kernel/test/plugin/plugin-manifest.spec.ts index 09fdf060..68276e8c 100644 --- a/packages/kernel/test/plugin/plugin-manifest.spec.ts +++ b/packages/kernel/test/plugin/plugin-manifest.spec.ts @@ -67,7 +67,18 @@ describe('parsePluginManifest', () => { } }); - it('requires at least one entry', () => { - expect(() => parsePluginManifest({ ...base, entry: {} })).toThrow(/at least one/); + it('takes a plugin with no code, only contributions (a theme)', () => { + const withoutEntry: Record = { ...base }; + delete withoutEntry.entry; + expect(parsePluginManifest({ ...withoutEntry, keywords: ['theme'] })).toMatchObject({ + entry: {}, + keywords: ['theme'], + }); + }); + + it('refuses an entry it does not know (a typo would leave the plugin doing nothing)', () => { + expect(() => + parsePluginManifest({ ...base, entry: { clinet: 'dist/index.js' } }, 'plugins/typo'), + ).toThrow(/clinet/); }); }); diff --git a/packages/kernel/turbo.json b/packages/kernel/turbo.json new file mode 100644 index 00000000..f549607d --- /dev/null +++ b/packages/kernel/turbo.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "test:unit": { + "inputs": [ + "$TURBO_EXTENDS$", + "scripts/**", + "schemas/**", + "../ui/src/theme/icon-theme.schema.ts", + "../ui/src/theme/theme-contribution.ts", + "../ui/src/theme/tokens.ts" + ] + } + } +} diff --git a/packages/project/src/adoption/index.ts b/packages/project/src/adoption/index.ts new file mode 100644 index 00000000..5cc49b68 --- /dev/null +++ b/packages/project/src/adoption/index.ts @@ -0,0 +1,2 @@ +export * from './project-check.type'; +export * from './project-checks.extension-point'; diff --git a/packages/project/src/adoption/project-check.type.ts b/packages/project/src/adoption/project-check.type.ts new file mode 100644 index 00000000..f82f980f --- /dev/null +++ b/packages/project/src/adoption/project-check.type.ts @@ -0,0 +1,32 @@ +import type { ClientProject } from '../service/client-project'; +import type { Changeset } from '../workspace/changeset'; + +/** + * Something the editor can't edit well in a project, and how to adapt it (the Project screen's + * "Adapt to the editor" report). Never applied by itself: a fix is previewed, then applied. + */ +export interface ProjectSuggestion { + /** Stable for the same finding (a dismissed one stays dismissed): `:`. */ + readonly id: string; + readonly title: string; + /** What the editor can't do as it is, and why the change helps. */ + readonly detail: string; + /** The app or shared library it is about (its name). */ + readonly app?: string; + /** A change of files, previewed as a diff before it is applied as one undo step. */ + readonly fix?: { + readonly title: string; + changeset(): Promise; + }; + /** Something else to do (install the dependencies, open a page…). */ + readonly action?: { + readonly title: string; + run(): unknown; + }; +} + +/** A check of the open project, contributed by core or a plugin (`PROJECT_CHECKS`). */ +export interface ProjectCheck { + readonly id: string; + run(project: ClientProject): Promise; +} diff --git a/packages/project/src/adoption/project-checks.extension-point.ts b/packages/project/src/adoption/project-checks.extension-point.ts new file mode 100644 index 00000000..ae28febb --- /dev/null +++ b/packages/project/src/adoption/project-checks.extension-point.ts @@ -0,0 +1,6 @@ +import { type ExtensionPoint, defineExtensionPoint } from '@nanoforge-dev/editor-kernel'; + +import type { ProjectCheck } from './project-check.type'; + +/** Checks behind the Project screen's "Adapt to the editor" report. */ +export const PROJECT_CHECKS: ExtensionPoint = defineExtensionPoint('project.checks'); diff --git a/packages/project/src/index.ts b/packages/project/src/index.ts index ddf95ca9..24b2a48f 100644 --- a/packages/project/src/index.ts +++ b/packages/project/src/index.ts @@ -2,3 +2,4 @@ export * from './cache'; export * from './fs'; export * from './service'; export * from './workspace'; +export * from './adoption'; diff --git a/packages/project/src/workspace/changeset.ts b/packages/project/src/workspace/changeset.ts new file mode 100644 index 00000000..be98121b --- /dev/null +++ b/packages/project/src/workspace/changeset.ts @@ -0,0 +1,111 @@ +import { Changes } from './changes'; +import { WorkspaceError } from './workspace.exception'; +import type { Undo, WorkspaceIo } from './workspace.type'; + +/** A file a changeset writes or removes: no `before`, it is created; no `after`, it goes. */ +export interface ChangesetFile { + readonly path: string; + readonly before?: string; + readonly after?: string; +} + +/** What an operation would change, to show before it is made (project adoption, ADR 0003). */ +export interface Changeset { + readonly title: string; + /** In the order they are written: what makes the change visible comes last. */ + readonly files: readonly ChangesetFile[]; + /** What the preview should say besides the files (a dependency not on npm yet…). */ + readonly notes?: readonly string[]; +} + +/** + * Runs a workspace operation on the project's files kept in memory, and returns what it would + * change, nothing written. A file written twice takes the place of its last write. + */ +export const recordChanges = async ( + base: WorkspaceIo, + title: string, + operation: (io: WorkspaceIo) => Promise, +): Promise => { + /** Path → new text, or null once removed; insertion order is the write order. */ + const overlay = new Map(); + const touch = (path: string, text: string | null) => { + overlay.delete(path); + overlay.set(path, text); + }; + const exists = (path: string) => + overlay.has(path) ? overlay.get(path) !== null : base.exists(path); + const files = () => [ + ...base.files().filter((path) => !overlay.has(path)), + ...[...overlay].filter(([, text]) => text !== null).map(([path]) => path), + ]; + const under = (folder: string) => + files().filter((path) => path === folder || path.startsWith(`${folder}/`)); + const read = async (path: string) => { + const text = overlay.get(path); + if (text === null) throw new WorkspaceError(`${path} does not exist.`, [path]); + return text ?? base.read(path); + }; + const io: WorkspaceIo = { + exists, + files, + read, + write: async (path, text) => touch(path, text), + remove: async (path) => { + for (const file of under(path)) touch(file, null); + return async () => undefined; + }, + copy: async (from, to) => { + for (const file of under(from)) touch(`${to}${file.slice(from.length)}`, await read(file)); + }, + }; + await operation(io); + const changes: ChangesetFile[] = []; + for (const [path, after] of overlay) { + const before = base.exists(path) ? await base.read(path) : undefined; + if (before === (after ?? undefined)) continue; + changes.push({ + path, + ...(before !== undefined && { before }), + ...(after !== null && { after }), + }); + } + return { title, files: changes }; +}; + +/** + * Makes a changeset's changes, in its order, and resolves with their undo. Nothing is written when + * a file is no longer what the preview showed. + */ +export const applyChangeset = (io: WorkspaceIo, changeset: Changeset): Promise => { + const changes = new Changes(io); + return changes.run(async () => { + const stale: string[] = []; + for (const file of changeset.files) { + const current = io.exists(file.path) ? await io.read(file.path) : undefined; + if (current !== file.before) stale.push(file.path); + } + if (stale.length) + throw new WorkspaceError( + 'These files changed since the preview: look at the changes again.', + stale, + ); + /** Folders the new files bring (the topmost missing one of each): undo removes them too. */ + const folders = new Set(); + for (const file of changeset.files) { + if (file.before !== undefined) continue; + const parts = file.path.split('/').slice(0, -1); + for (let depth = 1; depth <= parts.length; depth++) { + const folder = parts.slice(0, depth).join('/'); + if (io.exists(folder)) continue; + if (![...folders].some((known) => folder.startsWith(`${known}/`))) folders.add(folder); + break; + } + } + for (const file of changeset.files) { + if (file.after === undefined) await changes.remove(file.path); + else await changes.write(file.path, file.after); + } + for (const folder of folders) changes.created(folder); + }); +}; diff --git a/packages/project/src/workspace/index.ts b/packages/project/src/workspace/index.ts index 464a2453..26789e36 100644 --- a/packages/project/src/workspace/index.ts +++ b/packages/project/src/workspace/index.ts @@ -13,3 +13,4 @@ export { export * from './editor-library'; export * from './app-operations'; export * from './workspace-io'; +export * from './changeset'; diff --git a/packages/project/src/workspace/workspace-io.ts b/packages/project/src/workspace/workspace-io.ts index da648fa3..3433704f 100644 --- a/packages/project/src/workspace/workspace-io.ts +++ b/packages/project/src/workspace/workspace-io.ts @@ -1,12 +1,18 @@ import type { ClientProject } from '../service/client-project'; import type { WorkspaceIo } from './workspace.type'; -/** The files of an open project, as the workspace operations need them. */ -export const workspaceIo = (project: ClientProject): WorkspaceIo => ({ +/** + * The files of an open project, as the workspace operations need them. Reads are fresh (from the + * server) unless `fresh: false`: checks that only look read the browser's copies. + */ +export const workspaceIo = ( + project: ClientProject, + { fresh = true }: { fresh?: boolean } = {}, +): WorkspaceIo => ({ exists: (path) => project.fs.entry(path) !== undefined, files: () => project.fs.entries.filter((entry) => entry.kind === 'file').map((entry) => entry.path), - read: async (path) => (await project.fs.readText(path, { fresh: true })).text, + read: async (path) => (await project.fs.readText(path, { fresh })).text, write: (path, text) => project.fs.write(path, text).then(() => undefined), remove: async (path) => { const { trashPath } = await project.fs.delete(path); diff --git a/packages/project/test/fixtures/workspace.ts b/packages/project/test/fixtures/workspace.ts index 1ab499ca..a2dfda90 100644 --- a/packages/project/test/fixtures/workspace.ts +++ b/packages/project/test/fixtures/workspace.ts @@ -55,6 +55,8 @@ export const app = (root: string, name: string, type: AppModel['type']): AppMode scenes: '', }, engineLibs: {}, + missingEngineLibs: [], + availableEngineLibs: [], libraries: [], }); export const client = app('apps/client', 'pong-client', 'client'); diff --git a/packages/project/test/workspace/changeset.spec.ts b/packages/project/test/workspace/changeset.spec.ts new file mode 100644 index 00000000..5678ae08 --- /dev/null +++ b/packages/project/test/workspace/changeset.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { createApp } from '../../src/workspace/app-operations'; +import { applyChangeset, recordChanges } from '../../src/workspace/changeset'; +import { client, model, project, server } from '../fixtures/workspace'; + +const newApp = { type: 'client', folder: 'spectator', name: 'pong-spectator' } as const; + +describe('changesets', () => { + it('records what an operation would change, writes nothing, and applies the same result', async () => { + const direct = project(); + await createApp(direct.io, model(client, server), newApp); + + const { io, files, snapshot } = project(); + const before = snapshot(); + const changeset = await recordChanges(io, 'Add spectator', (memory) => + createApp(memory, model(client, server), newApp), + ); + expect(snapshot()).toEqual(before); + expect(changeset.files.map((file) => file.path)).toContain('apps/spectator/src/main.ts'); + expect(changeset.files.every((file) => file.before === undefined)).toBe(true); + + const undo = await applyChangeset(io, changeset); + expect(snapshot()).toEqual(direct.snapshot()); + expect(files.get('apps/spectator/package.json')).toBeDefined(); + await undo(); + expect(snapshot()).toEqual(before); + expect(io.exists('apps/spectator')).toBe(false); + }); + + it('records edits and removals, and refuses files changed since the preview', async () => { + const { io, files, snapshot } = project(); + const changeset = await recordChanges(io, 'Edit', async (memory) => { + await memory.write('apps/client/src/main.ts', 'export const main = 3;\n'); + await memory.remove('apps/server'); + await memory.write('apps/client/src/main.ts', 'export const main = 4;\n'); + }); + expect(changeset.files).toContainEqual({ + path: 'apps/client/src/main.ts', + before: 'export const main = 1;\n', + after: 'export const main = 4;\n', + }); + expect(changeset.files.find((file) => file.path === 'apps/server/src/main.ts')).toEqual({ + path: 'apps/server/src/main.ts', + before: 'export const main = 2;\n', + }); + + files.set('apps/client/src/main.ts', 'export const main = 9;\n'); + const before = snapshot(); + await expect(applyChangeset(io, changeset)).rejects.toThrow('changed since the preview'); + expect(snapshot()).toEqual(before); + }); +}); diff --git a/packages/protocol/src/project/project.schema.ts b/packages/protocol/src/project/project.schema.ts index 4dd5b5cf..12498977 100644 --- a/packages/protocol/src/project/project.schema.ts +++ b/packages/protocol/src/project/project.schema.ts @@ -27,6 +27,10 @@ export const AppModel = z.object({ outDir: ProjectPath.optional(), /** Engine libraries (`@nanoforge-dev/*` runtime packages) and their version or range. */ engineLibs: z.record(z.string(), z.string()), + /** Engine libraries its package.json declares that are not installed (their range above). */ + missingEngineLibs: z.array(z.string()).default([]), + /** Engine libraries installed for it (its node_modules or the project's), declared or not. */ + availableEngineLibs: z.array(z.string()).default([]), /** * The shared libraries (ADR 0004) the app uses, by package name: those its `nanoforge.config` * lists in `libs` (a library: those its package.json depends on). A library nothing mentions is diff --git a/packages/registry/src/directory/directory-registry.ts b/packages/registry/src/directory/directory-registry.ts index eb4e9511..7fce7f5a 100644 --- a/packages/registry/src/directory/directory-registry.ts +++ b/packages/registry/src/directory/directory-registry.ts @@ -45,13 +45,19 @@ export class DirectoryRegistry implements Registry { async search(query: SearchQuery = {}): Promise { const { type, q, page, limit } = SearchQuery.parse(query); - const words = q.toLowerCase().split(/\s+/).filter(Boolean); + /** `keywords:a,b` (the API's qualifier): items with every one of them. */ + const tokens = q.toLowerCase().split(/\s+/).filter(Boolean); + const keywords = tokens + .filter((token) => token.startsWith('keywords:')) + .flatMap((token) => token.slice('keywords:'.length).split(',').filter(Boolean)); + const words = tokens.filter((token) => !token.startsWith('keywords:')); const all = [...(await this._load()).values()] .map(({ item }): RegistrySummary => ({ name: item.name, type: item.type, version: item.version, description: item.description, + keywords: item.keywords, author: item.author, downloads: item.downloads, updatedAt: item.updatedAt, @@ -60,7 +66,14 @@ export class DirectoryRegistry implements Registry { .filter( (item) => (!type || item.type === type) && - words.every((word) => `${item.name} ${item.description}`.toLowerCase().includes(word)), + keywords.every((keyword) => + item.keywords.some((candidate) => candidate.toLowerCase() === keyword), + ) && + words.every((word) => + `${item.name} ${item.description} ${item.keywords.join(' ')}` + .toLowerCase() + .includes(word), + ), ) .sort((a, b) => b.downloads - a.downloads || a.name.localeCompare(b.name)); return { items: all.slice((page - 1) * limit, page * limit), total: all.length }; @@ -160,6 +173,7 @@ export class DirectoryRegistry implements Registry { type: latest.manifest.type, version: latest.version, description: latest.manifest.description ?? '', + keywords: latest.manifest.keywords ?? [], author: extra.author ?? '', downloads: extra.downloads ?? 0, updatedAt: latest.publishedAt, diff --git a/packages/registry/src/item/item.schema.ts b/packages/registry/src/item/item.schema.ts index 43a1dba8..714a3265 100644 --- a/packages/registry/src/item/item.schema.ts +++ b/packages/registry/src/item/item.schema.ts @@ -36,6 +36,8 @@ export const RegistrySummary = z.object({ /** The newest version. */ version: ItemVersion, description: z.string().default(''), + /** Words the manifest gives (`keywords:theme` searches them). */ + keywords: z.array(z.string()).default([]), author: z.string().default(''), downloads: z.number().int().nonnegative().default(0), /** ISO date of the newest version. */ @@ -109,6 +111,7 @@ export const ItemManifest = z name: ItemName, version: ItemVersion, description: z.string().optional(), + keywords: z.array(z.string()).optional(), dependencies: ManifestDependencies.optional(), engines: z.record(z.string(), z.string()).optional(), suggestedPlugins: ManifestDependencies.optional(), diff --git a/packages/registry/src/wire/packument.ts b/packages/registry/src/wire/packument.ts index fbc12ffd..7dc03c37 100644 --- a/packages/registry/src/wire/packument.ts +++ b/packages/registry/src/wire/packument.ts @@ -8,6 +8,7 @@ export const toPackument = (item: RegistryItem): z.input => ({ name: item.name, type: item.type, description: item.description, + keywords: item.keywords, ...(item.author && { author: { name: item.author } }), maintainers: item.author ? [{ name: item.author }] : [], readme: item.readme, @@ -53,6 +54,7 @@ export const fromPackument = (packument: Packument, compare: (a: string, b: stri type: packument.type, version: latest, description: packument.description, + keywords: packument.keywords, author: packument.author?.name || packument.maintainers[0]?.name || '', downloads: packument.downloads?.total ?? 0, updatedAt: packument.time[latest] ?? '', @@ -70,6 +72,7 @@ export const toSearchObject = (item: RegistrySummary): WireSearch['objects'][num type: item.type, version: item.version, description: item.description, + keywords: item.keywords, date: item.updatedAt, ...(item.author && { author: { name: item.author }, publisher: { username: item.author } }), kinds: item.kinds, @@ -82,6 +85,7 @@ export const fromSearchObject = (entry: WireSearch['objects'][number]): Registry type: entry.package.type, version: entry.package.version, description: entry.package.description, + keywords: entry.package.keywords, author: entry.package.author?.name || entry.package.publisher?.username || '', downloads: entry.downloads?.total ?? 0, updatedAt: entry.package.date, diff --git a/packages/registry/src/wire/wire.schema.ts b/packages/registry/src/wire/wire.schema.ts index dbf36d96..1c7f2125 100644 --- a/packages/registry/src/wire/wire.schema.ts +++ b/packages/registry/src/wire/wire.schema.ts @@ -61,6 +61,7 @@ export const Packument = z name: ItemName, type: ItemType, description: z.string().default(''), + keywords: z.array(z.string()).default([]), author: Person.optional(), maintainers: z.array(Person).default([]), readme: z.string().nullable().default(''), @@ -83,6 +84,7 @@ export const WireSearch = z.object({ type: ItemType, version: ItemVersion, description: z.string().default(''), + keywords: z.array(z.string()).default([]), date: z.string().default(''), author: Person.optional(), publisher: z.object({ username: z.string() }).loose().optional(), diff --git a/packages/registry/test/registry.spec.ts b/packages/registry/test/registry.spec.ts index c02bc869..57eb15da 100644 --- a/packages/registry/test/registry.spec.ts +++ b/packages/registry/test/registry.spec.ts @@ -101,7 +101,11 @@ beforeAll(() => { }); publish('@acme/physics', '1.0.0', { dependencies: { '@acme/shapes': '^2.0.0' } }); publish('@acme/tools', '1.0.0', { type: 'plugin', engines: { editor: '>=0.1.0' } }); - publish('@acme/tools', '2.0.0', { type: 'plugin', engines: { editor: '>=9.0.0' } }); + publish('@acme/tools', '2.0.0', { + type: 'plugin', + engines: { editor: '>=9.0.0' }, + manifest: { keywords: ['theme', 'dark'] }, + }); writeFileSync( join(registryDir, '@acme/render/registry.json'), JSON.stringify({ author: 'Acme', downloads: 42 }), @@ -138,6 +142,12 @@ describe('registries', () => { expect((await source.search({ q: 'draws SHAPES' })).items.map((item) => item.name)).toEqual([ '@acme/render', ]); + const themes = await source.search({ type: 'plugin', q: 'keywords:theme' }); + expect(themes.items).toMatchObject([{ name: '@acme/tools', keywords: ['theme', 'dark'] }]); + expect((await source.search({ q: 'keywords:theme,light' })).items).toEqual([]); + expect((await source.search({ q: 'dark' })).items.map((item) => item.name)).toEqual([ + '@acme/tools', + ]); expect((await source.search({ limit: 1, page: 2 })).items.map((item) => item.name)).toEqual([ '@acme/physics', ]); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7a3aa143..79373fbb 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -100,8 +100,13 @@ export type { export { basename, dirname, joinPath, runtimeOutputPath } from '@nanoforge-dev/editor-protocol'; export { + type Changeset, + type ChangesetFile, type NewApp, type NewLibrary, + PROJECT_CHECKS, + type ProjectCheck, + type ProjectSuggestion, type Undo, WorkspaceError, type WorkspaceIo, @@ -109,6 +114,8 @@ export { createLibrary, librariesOf, libraryImporters, + nameTaken, + recordChanges, removeApp, removeLibrary, renameApp, diff --git a/packages/sdk/src/ui.ts b/packages/sdk/src/ui.ts index d7283f0b..4667940b 100644 --- a/packages/sdk/src/ui.ts +++ b/packages/sdk/src/ui.ts @@ -85,6 +85,11 @@ export { type StyleService, StyleServiceToken, type ThemeDefinition, + themeDeclarations, + IconThemeServiceToken, + type SyntaxToken, + parseColor, + toHex, type ThemeService, ThemeServiceToken, THEMES, @@ -122,6 +127,7 @@ export { type Tour, type TourStep, TourServiceToken, + ChangesetPreviewServiceToken, describeError, notifyError, type ErrorExplanation, diff --git a/packages/sdk/test/index.spec.ts b/packages/sdk/test/index.spec.ts index 7e7acb79..e53dc869 100644 --- a/packages/sdk/test/index.spec.ts +++ b/packages/sdk/test/index.spec.ts @@ -54,6 +54,7 @@ describe('public API', () => { "LogLevel", "MutableDisposable", "ObservableValue", + "PROJECT_CHECKS", "PluginHostToken", "ProjectCachesToken", "ProjectServiceToken", @@ -101,9 +102,11 @@ describe('public API', () => { "libraryImporters", "matchesGlob", "missingEngineLibs", + "nameTaken", "observe", "ownerObjects", "parseWhen", + "recordChanges", "removeApp", "removeLibrary", "renameApp", diff --git a/packages/server-core/src/http/static-files.ts b/packages/server-core/src/http/static-files.ts index 1f23a954..ec3273f4 100644 --- a/packages/server-core/src/http/static-files.ts +++ b/packages/server-core/src/http/static-files.ts @@ -27,12 +27,15 @@ export const contentType = (file: string) => /** * Serves a file (Bun streams it; Node reads it) with the given cache policy. Given the request's - * `Accept-Encoding`, text files are sent compressed (Brotli, else gzip). + * `Accept-Encoding`, text files are sent compressed (Brotli, else gzip). With `ifNoneMatch` (the + * request's header), the file has an ETag (its size and time) and an unchanged one comes back + * empty (304): for `no-cache` files that change without changing their URL. */ export const fileResponse = async ( file: string, cacheControl: string, acceptEncoding?: string | null, + ifNoneMatch?: string | null, ): Promise => { const info = await stat(file).catch(() => undefined); if (!info?.isFile()) return undefined; @@ -42,6 +45,11 @@ export const fileResponse = async ( 'cache-control': cacheControl, 'x-content-type-options': 'nosniff', }; + if (ifNoneMatch !== undefined) { + const etag = `W/"${info.size.toString(36)}-${Math.trunc(info.mtimeMs).toString(36)}"`; + headers.etag = etag; + if (ifNoneMatch === etag) return new Response(null, { status: 304, headers }); + } const encoding = acceptEncoding === undefined ? undefined : pickEncoding(acceptEncoding); if (acceptEncoding !== undefined) headers.vary = 'accept-encoding'; if (encoding && isCompressible(type, info.size)) { diff --git a/packages/server-core/src/project/discovery.ts b/packages/server-core/src/project/discovery.ts index 1c3a73aa..9750c142 100644 --- a/packages/server-core/src/project/discovery.ts +++ b/packages/server-core/src/project/discovery.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import type { AppModel, ProjectModel } from '@nanoforge-dev/editor-protocol'; @@ -70,6 +70,7 @@ const engineLibs = async ( appRoot: string, projectRoot: string, pkg: PackageJson | undefined, + missing: string[] = [], ): Promise> => { const declared: Record = { ...pkg?.devDependencies, ...pkg?.dependencies }; const names = new Set( @@ -79,11 +80,49 @@ const engineLibs = async ( ); const libs: Record = {}; for (const name of [...names].sort()) { - libs[name] = (await installedVersion(name, appRoot, projectRoot)) ?? declared[name] ?? '*'; + const installed = await installedVersion(name, appRoot, projectRoot); + if (!installed) missing.push(name); + libs[name] = installed ?? declared[name] ?? '*'; } return libs; }; +/** The `@nanoforge-dev/*` engine packages installed from an app up to the project (links too). */ +const availableEngineLibs = async (appRoot: string, projectRoot: string): Promise => { + const found = new Set(); + for (let dir = appRoot; ; dir = dirname(dir)) { + const names = await readdir(join(dir, 'node_modules', '@nanoforge-dev')).catch(() => []); + for (const name of names) { + const full = `@nanoforge-dev/${name}`; + if (!TOOLING_PACKAGES.has(full)) found.add(full); + } + if (dir === projectRoot || dirname(dir) === dir) break; + } + return [...found].sort(); +}; + +/** + * An app's engine libraries, those its package.json declares that are not installed, and those + * installed for it. + */ +const engineLibsOf = async ( + appRoot: string, + projectRoot: string, + pkg: PackageJson | undefined, +): Promise<{ + engineLibs: Record; + missingEngineLibs: string[]; + availableEngineLibs: string[]; +}> => { + const missingEngineLibs: string[] = []; + const libs = await engineLibs(appRoot, projectRoot, pkg, missingEngineLibs); + return { + engineLibs: libs, + missingEngineLibs, + availableEngineLibs: await availableEngineLibs(appRoot, projectRoot), + }; +}; + /** Every package an app's package.json depends on (narrowed to the project's libraries later). */ const declaredPackages = (pkg: PackageJson | undefined): string[] => Object.keys({ ...pkg?.devDependencies, ...pkg?.dependencies }); @@ -199,7 +238,7 @@ const toApp = async ( systems: at(config.dir.systems), scenes: at(config.dir.scenes), }, - engineLibs: await engineLibs(appRoot, projectRoot, pkg), + ...(await engineLibsOf(appRoot, projectRoot, pkg)), libraries: [], }; } @@ -218,7 +257,7 @@ const toApp = async ( entryFile: at(config.entryFile), editorEntryFile: at(config.editor.entryFile), outDir: at(config.out.dir), - engineLibs: await engineLibs(appRoot, projectRoot, pkg), + ...(await engineLibsOf(appRoot, projectRoot, pkg)), libraries: [], }; }; diff --git a/packages/server-core/src/rpc/implementation/projects.implementation.ts b/packages/server-core/src/rpc/implementation/projects.implementation.ts index df184368..9e8596a0 100644 --- a/packages/server-core/src/rpc/implementation/projects.implementation.ts +++ b/packages/server-core/src/rpc/implementation/projects.implementation.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { cp, readFile, realpath, writeFile } from 'node:fs/promises'; +import { cp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { type Disposable, observe } from '@nanoforge-dev/editor-kernel'; @@ -47,6 +47,14 @@ export const implementProjectsRpc = ( packageManager: 'npm', strict: false, }); + /** An old CLI writes nanoforge.config.json and client/: the tutorial's files don't fit. */ + if (!existsSync(join(root, 'nanoforge.config.ts'))) { + await rm(root, { recursive: true, force: true }); + throw new RpcError( + 'PRECONDITION_FAILED', + 'The NanoForge CLI that made the project is too old for the editor (it wrote no nanoforge.config.ts): update it (npm install -g @nanoforge-dev/cli), then start the tutorial again', + ); + } await cp(env.tutorialDir, root, { recursive: true, force: true }); await addEditorLibraryDependency(join(root, 'package.json')); await ignoreBuildOutput(join(root, '.gitignore')); diff --git a/packages/server-core/src/server/create-editor-server.ts b/packages/server-core/src/server/create-editor-server.ts index 1dadfa7b..4aaeaa00 100644 --- a/packages/server-core/src/server/create-editor-server.ts +++ b/packages/server-core/src/server/create-editor-server.ts @@ -151,16 +151,21 @@ export const createEditorServer = (options: EditorServerOptions): EditorServer = mayLoadProjectPlugins(session, projectId), ); if (!resolved) return json(404, { error: 'Not found' }); + /** + * Revalidated, never `immutable`: a plugin's URL holds its version, and an upgraded editor (or + * a reinstall) can ship other files under the same version, whose chunks then go missing. + */ const cache = { dev: 'no-cache', project: 'private, no-cache', - installed: 'public, max-age=31536000, immutable', - bundled: 'public, max-age=31536000, immutable', + installed: 'public, no-cache', + bundled: 'public, no-cache', }[resolved.plugin.source]; const response = await fileResponse( resolved.file, cache, request.headers.get('accept-encoding'), + request.headers.get('if-none-match'), ); if (!response) return json(404, { error: 'Not found' }); /** A project's page or image opened on its own runs nothing on the editor's origin. */ diff --git a/packages/server-core/test/project/create-tutorial.spec.ts b/packages/server-core/test/project/create-tutorial.spec.ts index 634f02fe..f0a1022d 100644 --- a/packages/server-core/test/project/create-tutorial.spec.ts +++ b/packages/server-core/test/project/create-tutorial.spec.ts @@ -87,6 +87,46 @@ describe('projects.createTutorial', () => { expect(readFileSync(join(root, 'notes.txt'), 'utf8')).toBe('kept'); }); + it('refuses a CLI too old to write nanoforge.config.ts, and leaves no folder', async () => { + const cli = join(workspace, 'old-nf.cjs'); + writeFileSync( + cli, + `#!/usr/bin/env node +const { mkdirSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const args = process.argv.slice(2); +const root = join(process.cwd(), args[args.indexOf('--path') + 1]); +mkdirSync(join(root, 'client'), { recursive: true }); +writeFileSync(join(root, 'nanoforge.config.json'), '{}'); +writeFileSync(join(root, 'package.json'), '{}'); +`, + ); + chmodSync(cli, 0o755); + const old = createEditorServer({ + env: loadEnv( + { + NODE_ENV: 'test', + FS_ROOT: workspace, + DATA_DIR: join(mkdtempSync(join(tmpdir(), 'nf-data-')), 'editor'), + NF_CLI_PATH: cli, + TUTORIAL_DIR: join(import.meta.dirname, '../../../../apps/editor/tutorial'), + }, + workspace, + ), + version: '0.0.0-test', + }); + const oldClient = createLoopbackClient(old); + try { + await expect( + oldClient.api(ProjectsContract).createTutorial({ name: 'old-game', install: false }), + ).rejects.toThrow(/too old for the editor/); + expect(existsSync(join(workspace, 'old-game'))).toBe(false); + } finally { + oldClient.dispose(); + old.dispose(); + } + }); + it('refuses a folder of that name that is not the tutorial, even a NanoForge project', async () => { mkdirSync(join(workspace, 'other-game')); await expect( diff --git a/packages/server-core/test/project/discovery.spec.ts b/packages/server-core/test/project/discovery.spec.ts index b2270a03..9915d231 100644 --- a/packages/server-core/test/project/discovery.spec.ts +++ b/packages/server-core/test/project/discovery.spec.ts @@ -1,4 +1,12 @@ -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; @@ -183,9 +191,37 @@ describe('discoverProject diagnostics', () => { language: 'js', entryFile: 'src/main.ts', engineLibs: { '@nanoforge-dev/ecs': '^1.4.0' }, + /** Declared, not installed (no node_modules): the editor can say so. */ + missingEngineLibs: ['@nanoforge-dev/ecs'], + availableEngineLibs: [], }, ]); }); + + it('lists the engine libraries installed for an app, linked ones too', async () => { + const root = mkdtempSync(join(tmpdir(), 'nf-linked-')); + const engine = mkdtempSync(join(tmpdir(), 'nf-engine-')); + temporary.push(root, engine); + writeFileSync(join(root, 'nanoforge.config.ts'), 'export default { type: "client" };'); + writeFileSync(join(root, 'package.json'), '{"name":"solo","dependencies":{}}'); + writeFileSync( + join(engine, 'package.json'), + '{"name":"@nanoforge-dev/scene","version":"2.0.0"}', + ); + mkdirSync(join(root, 'node_modules/@nanoforge-dev/ecs'), { recursive: true }); + symlinkSync(engine, join(root, 'node_modules/@nanoforge-dev/scene')); + mkdirSync(join(root, 'node_modules/@nanoforge-dev/cli'), { recursive: true }); + const model = await discoverProject({ + id: 'solo-5678', + root, + location: root, + loader: staticConfigLoader, + }); + expect(model.apps[0]!.availableEngineLibs).toEqual([ + '@nanoforge-dev/ecs', + '@nanoforge-dev/scene', + ]); + }); }); describe('resolveLibraries', () => { diff --git a/packages/server-core/test/server/create-editor-server.spec.ts b/packages/server-core/test/server/create-editor-server.spec.ts index a885eb27..6eba6693 100644 --- a/packages/server-core/test/server/create-editor-server.spec.ts +++ b/packages/server-core/test/server/create-editor-server.spec.ts @@ -205,6 +205,13 @@ describe('editor server', () => { const plugin = await get('/plugins/bundled/@acme/echo/1.0.0/index.js'); expect(plugin.status).toBe(200); expect(plugin.headers.get('content-type')).toContain('javascript'); + /** Revalidated, not immutable: a new editor may ship other files under the same version. */ + expect(plugin.headers.get('cache-control')).toBe('public, no-cache'); + const etag = plugin.headers.get('etag')!; + expect(etag).toMatch(/^W\/"/); + expect( + (await get('/plugins/bundled/@acme/echo/1.0.0/index.js', { 'if-none-match': etag })).status, + ).toBe(304); expect( (await get('/plugins/bundled/@acme/echo/1.0.0/../../../../../package.json')).status, ).toBe(404); diff --git a/packages/settings/src/definition/core-settings.ts b/packages/settings/src/definition/core-settings.ts index 4a5c1326..cb0ba69c 100644 --- a/packages/settings/src/definition/core-settings.ts +++ b/packages/settings/src/definition/core-settings.ts @@ -22,6 +22,24 @@ export const CoreSettings = { category: 'Editor/Appearance', scopes: ['account', 'machine'], }), + iconTheme: defineSetting({ + key: 'appearance.iconTheme', + schema: z.string().min(1), + default: 'nanoforge', + title: 'Icon theme', + description: 'Icons of the editor: panels, menus and files.', + category: 'Editor/Appearance', + scopes: ['account', 'machine'], + }), + density: defineSetting({ + key: 'appearance.density', + schema: z.enum(['compact', 'normal', 'comfortable']), + default: 'normal', + title: 'Density', + description: 'How much room the panels, rows and buttons take.', + category: 'Editor/Appearance', + scopes: ['account', 'machine'], + }), cacheBudget: defineSetting({ key: 'storage.cacheBudgetMb', schema: z.number().int().min(50).max(100_000), @@ -77,6 +95,16 @@ export const CoreSettings = { category: 'Editor/Appearance', scopes: ['account', 'machine'], }), + adoptionDismissed: defineSetting({ + key: 'adoption.dismissed', + schema: z.array(z.string()), + default: [], + title: 'Dismissed suggestions', + description: + 'Suggestions of the Project screen\'s "Adapt to the editor" report that were put away in this project.', + category: 'Editor/Project', + scopes: ['projectLocal'], + }), layoutPreset: defineSetting({ key: 'layout.preset', schema: z.enum(['simple', 'full']), diff --git a/packages/ui/.vitest-attachments/dfb475b7e8b06a5e0a93e06a0c4948dd49d6394d.png b/packages/ui/.vitest-attachments/dfb475b7e8b06a5e0a93e06a0c4948dd49d6394d.png new file mode 100644 index 00000000..47767d2f Binary files /dev/null and b/packages/ui/.vitest-attachments/dfb475b7e8b06a5e0a93e06a0c4948dd49d6394d.png differ diff --git a/packages/ui/src/components/icon.svelte b/packages/ui/src/components/icon.svelte index 2e3dc36e..e75ed4f3 100644 --- a/packages/ui/src/components/icon.svelte +++ b/packages/ui/src/components/icon.svelte @@ -1,8 +1,8 @@ -{#if Resolved} - +{:else if resolved} + {/if} + + diff --git a/packages/ui/src/components/icons.ts b/packages/ui/src/components/icons.ts index 2af2910b..03b7fcd0 100644 --- a/packages/ui/src/components/icons.ts +++ b/packages/ui/src/components/icons.ts @@ -126,5 +126,41 @@ export const registerIcons = (icons: Record): Disposable return { dispose: () => added.forEach(([name]) => registry.delete(name)) }; }; +/** Icons contributed as SVG files (`contributes.icons`): name → URL. */ +const files = new SvelteMap(); +/** The active icon theme's icons: name → URL, ahead of every other icon. */ +const themed = new SvelteMap(); + +/** + * Registers SVG files as icons by name (`contributes.icons`); names the editor already has + * stay its own (an icon theme replaces those). + */ +export const registerIconFiles = (icons: Record): Disposable => { + const added = Object.entries(icons).filter(([name]) => !registry.has(name) && !files.has(name)); + for (const [name, url] of added) files.set(name, url); + return { dispose: () => added.forEach(([name]) => files.delete(name)) }; +}; + +/** The active icon theme's icons (empty: the editor's own). */ +export const setThemeIcons = (icons: Record): void => { + themed.clear(); + for (const [name, url] of Object.entries(icons)) themed.set(name, url); +}; + +/** An icon to draw: a component, or an SVG file drawn as a mask (never inlined). */ +export type ResolvedIcon = + | { readonly kind: 'component'; readonly component: IconComponent } + | { readonly kind: 'url'; readonly url: string }; + +/** An icon by name: the icon theme's, a contributed file, then the editor's own set. */ +export const resolveIconOf = (name: string | undefined): ResolvedIcon | undefined => { + if (!name) return undefined; + const url = themed.get(name) ?? files.get(name); + if (url) return { kind: 'url', url }; + const component = registry.get(name); + return component ? { kind: 'component', component } : undefined; +}; + +/** The component of an icon of the editor's own set (not themed). */ export const resolveIcon = (name: string | undefined): IconComponent | undefined => name ? registry.get(name) : undefined; diff --git a/packages/ui/src/theme/icon-theme.schema.ts b/packages/ui/src/theme/icon-theme.schema.ts new file mode 100644 index 00000000..8668d57a --- /dev/null +++ b/packages/ui/src/theme/icon-theme.schema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +/** An icon contributed as a file: `{ "id": "rocket", "path": "icons/rocket.svg" }`. */ +export const IconFileSchema = z.strictObject({ + id: z.string().regex(/^[a-z0-9][\w-]*$/), + path: z.string().min(1), +}); + +/** + * An icon theme as contributed (`contributes.iconThemes`): icons by name (any icon of the editor: + * panels, stripes, menus, files), and file and folder icons by pattern. Paths are SVG files of + * the plugin. + */ +export const IconThemeContributionSchema = z.strictObject({ + id: z.string().regex(/^[\w-]+$/), + label: z.string().min(1), + icons: z.record(z.string().regex(/^[a-z0-9][\w-]*$/), z.string().min(1)).default({}), + files: z + .array(z.strictObject({ pattern: z.string().min(1), icon: z.string().min(1) })) + .default([]), + folders: z + .array( + z.strictObject({ + pattern: z.string().min(1), + icon: z.string().min(1), + iconOpen: z.string().min(1).optional(), + }), + ) + .default([]), +}); +export type IconThemeContribution = z.output; diff --git a/packages/ui/src/theme/icon-theme.ts b/packages/ui/src/theme/icon-theme.ts new file mode 100644 index 00000000..e8d9f52d --- /dev/null +++ b/packages/ui/src/theme/icon-theme.ts @@ -0,0 +1,123 @@ +import { + type Disposable, + DisposableStore, + type ExtensionRegistry, + type Logger, + MutableDisposable, + type Observable, + ObservableValue, + createToken, + defineExtensionPoint, + observe, +} from '@nanoforge-dev/editor-kernel'; + +import { setThemeIcons } from '../components/icons'; +import { FILE_ICONS, type FileIcon } from '../workbench/extension-point/file.extension-point'; +import type { IconThemeContribution } from './icon-theme.schema'; + +export * from './icon-theme.schema'; + +/** An icon theme with its icons' URLs. */ +export interface IconTheme { + readonly id: string; + readonly label: string; + readonly icons: Readonly>; + readonly files: readonly FileIcon[]; +} + +export const ICON_THEMES = defineExtensionPoint('ui.iconThemes'); + +/** The editor's own icons (Lucide): the default, nothing replaced. */ +export const DEFAULT_ICON_THEME: IconTheme = { + id: 'nanoforge', + label: 'NanoForge', + icons: {}, + files: [], +}; + +/** Above every plugin's file icons while the theme is active. */ +const THEME_PRIORITY = 1000; + +/** + * The URL of a plugin's SVG file: a relative `.svg` path inside the plugin, never `..`, an + * absolute path or another site. + */ +export const iconFileUrl = (baseUrl: string, path: string): string => { + if (!/^[\w./-]+\.svg$/i.test(path) || path.startsWith('/') || path.split('/').includes('..')) + throw new Error(`${path}: an icon is an .svg file inside the plugin (a relative path)`); + return new URL(path, baseUrl).href; +}; + +/** An icon theme's contribution with its paths as URLs of the plugin. */ +export const iconThemeOf = (theme: IconThemeContribution, baseUrl: string): IconTheme => ({ + id: theme.id, + label: theme.label, + icons: Object.fromEntries( + Object.entries(theme.icons).map(([name, path]) => [name, iconFileUrl(baseUrl, path)]), + ), + files: [ + ...theme.files.map((file) => ({ ...file, priority: THEME_PRIORITY })), + ...theme.folders.map((folder) => ({ ...folder, folder: true, priority: THEME_PRIORITY })), + ], +}); + +/** Applies the selected icon theme (`appearance.iconTheme`): its icons and its file icons. */ +export class IconThemeService implements Disposable { + private readonly _store = new DisposableStore(); + private readonly _files = new MutableDisposable(); + private readonly _current = new ObservableValue(DEFAULT_ICON_THEME); + /** Icon themes with an id already taken, each reported once. */ + private readonly _reported = new Set(); + + constructor( + private readonly _extensions: ExtensionRegistry, + private readonly _selected: Observable, + /** Where icon themes with an id already taken are reported. */ + private readonly _logger?: Logger, + ) { + this._store.add(_extensions.contribute(ICON_THEMES, DEFAULT_ICON_THEME, { owner: 'core' })); + const apply = () => this._apply(); + this._store.add(observe(_selected, apply)); + this._store.add(_extensions.onDidChange((point) => point.id === ICON_THEMES.id && apply())); + } + + get current(): Observable { + return this._current.readonly(); + } + + /** Every icon theme: the first contribution of an id (core's comes first), the rest reported. */ + get themes(): IconTheme[] { + const themes = new Map(); + for (const { value, owner } of this._extensions.getContributions(ICON_THEMES)) { + if (!themes.has(value.id)) { + themes.set(value.id, value); + continue; + } + const message = `Icon theme "${value.id}" of ${owner} has the id of an icon theme already contributed`; + if (this._reported.has(message)) continue; + this._reported.add(message); + this._logger?.warn(message); + } + return [...themes.values()]; + } + + dispose(): void { + this._files.dispose(); + this._store.dispose(); + setThemeIcons({}); + } + + private _apply(): void { + const id = this._selected.get(); + const theme = this.themes.find((candidate) => candidate.id === id) ?? DEFAULT_ICON_THEME; + if (theme === this._current.get() && this._files.value) return; + setThemeIcons(theme.icons); + const files = new DisposableStore(); + for (const file of theme.files) + files.add(this._extensions.contribute(FILE_ICONS, file, { owner: `icon-theme:${theme.id}` })); + this._files.value = files; + this._current.set(theme); + } +} + +export const IconThemeServiceToken = createToken('ui.iconTheme'); diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts index ca43cf1d..2066c9e6 100644 --- a/packages/ui/src/theme/index.ts +++ b/packages/ui/src/theme/index.ts @@ -1,5 +1,8 @@ export * from './base-css'; export * from './kit-css'; +export * from './icon-theme'; +export * from './theme-colors'; +export * from './theme-contribution'; export * from './theme-service'; export * from './tokens'; export * from './uno'; diff --git a/packages/ui/src/theme/theme-colors.ts b/packages/ui/src/theme/theme-colors.ts new file mode 100644 index 00000000..65e88bd9 --- /dev/null +++ b/packages/ui/src/theme/theme-colors.ts @@ -0,0 +1,99 @@ +import type { ColorToken, ThemeDefinition } from './tokens'; + +/** A color in sRGB, 0–255, with its alpha 0–1. */ +export interface Rgba { + readonly r: number; + readonly g: number; + readonly b: number; + readonly a: number; +} + +let context: CanvasRenderingContext2D | null | undefined; + +/** The pixel `value` draws over a canvas filled with `fallback` (kept when it is not drawn). */ +const draw = (context: CanvasRenderingContext2D, value: string, fallback: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = fallback; + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + return context.getImageData(0, 0, 1, 1).data; +}; + +/** + * Any CSS color (hex, `rgb()`, `oklch()`, `color-mix()` of literals…) in sRGB, as the browser + * draws it; undefined when it is not a color, or one that depends on the page (`var()`, `env()`, + * `currentColor`). The canvas skips a `var()` it can't resolve and keeps its fill: the value is + * drawn over black then white, and taken only when both agree. + */ +export const parseColor = (value: string): Rgba | undefined => { + context ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true }); + if (!context || !CSS.supports('color', value) || /\bcurrentcolor\b/i.test(value)) + return undefined; + const [r, g, b, a] = draw(context, value, '#000'); + const [r2, g2, b2, a2] = draw(context, value, '#fff'); + if (r !== r2 || g !== g2 || b !== b2 || a !== a2) return undefined; + return { r: r!, g: g!, b: b!, a: a! / 255 }; +}; + +const hexByte = (value: number) => Math.round(value).toString(16).padStart(2, '0'); + +/** `#rrggbb` (or `#rrggbbaa` when translucent): what Monaco takes. */ +export const toHex = (color: Rgba): string => + `#${hexByte(color.r)}${hexByte(color.g)}${hexByte(color.b)}${color.a < 1 ? hexByte(color.a * 255) : ''}`; + +/** A translucent color as it shows over an opaque one. */ +export const over = (top: Rgba, bottom: Rgba): Rgba => ({ + r: top.r * top.a + bottom.r * (1 - top.a), + g: top.g * top.a + bottom.g * (1 - top.a), + b: top.b * top.a + bottom.b * (1 - top.a), + a: 1, +}); + +const channel = (value: number) => { + const c = value / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +}; +const luminance = ({ r, g, b }: Rgba) => + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + +/** WCAG contrast ratio of two colors (the first composited over the second). */ +export const contrast = (foreground: Rgba, background: Rgba): number => { + const a = luminance(over(foreground, background)); + const b = luminance(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +}; + +const BACKGROUNDS: ColorToken[] = ['bg', 'surface', 'raised', 'sunken']; + +/** The pairs `tokens.ts` promises: text 4.5:1 on every background, control edges 3:1. */ +const PAIRS: { + readonly fg: ColorToken; + readonly bg: readonly ColorToken[]; + readonly min: number; +}[] = [ + { fg: 'text', bg: BACKGROUNDS, min: 4.5 }, + { fg: 'text-muted', bg: BACKGROUNDS, min: 4.5 }, + { fg: 'text-faint', bg: BACKGROUNDS, min: 4.5 }, + { fg: 'border-strong', bg: BACKGROUNDS, min: 3 }, + { fg: 'on-accent', bg: ['accent'], min: 4.5 }, + { fg: 'selection-text', bg: ['selection'], min: 4.5 }, +]; + +/** Where a theme's text is too faint to read (WCAG AA), one line each. */ +export const contrastWarnings = (theme: ThemeDefinition): string[] => { + const warnings: string[] = []; + const color = (token: ColorToken) => parseColor(theme.colors[token]); + const base = color('bg'); + for (const pair of PAIRS) { + const fg = color(pair.fg); + for (const token of pair.bg) { + const raw = color(token); + if (!fg || !raw) continue; + const background = base && raw.a < 1 ? over(raw, base) : raw; + const ratio = contrast(fg, background); + if (ratio < pair.min) + warnings.push(`${pair.fg} on ${token} is ${ratio.toFixed(2)}:1 (needs ${pair.min}:1)`); + } + } + return warnings; +}; diff --git a/packages/ui/src/theme/theme-contribution.ts b/packages/ui/src/theme/theme-contribution.ts new file mode 100644 index 00000000..ddd23512 --- /dev/null +++ b/packages/ui/src/theme/theme-contribution.ts @@ -0,0 +1,132 @@ +import { z } from 'zod'; + +import { + COLOR_TOKENS, + type ColorToken, + SYNTAX_TOKENS, + type SyntaxToken, + THEME_TOKENS, + type ThemeDefinition, + type ThemeToken, +} from './tokens'; + +/** + * A value goes into a stylesheet: nothing that ends its declaration or loads another file + * (a theme brings no files but its icons). + */ +const UNSAFE = /[;{}\\<>@]|\/\*|\b(?:url|image|image-set|src|element)\s*\(/i; + +const CLOSERS: Record = { ')': '(', ']': '[' }; + +/** Brackets closed in order and quotes closed, so the value can't run into what follows it. */ +const balanced = (value: string): boolean => { + const open: string[] = []; + let quote: string | undefined; + for (const char of value) { + if (quote) { + if (char === quote) quote = undefined; + } else if (char === '"' || char === "'") quote = char; + else if (char === '(' || char === '[') open.push(char); + else if (CLOSERS[char] && open.pop() !== CLOSERS[char]) return false; + } + return !quote && !open.length; +}; + +const CssValue = z + .string() + .min(1) + .refine((value) => !UNSAFE.test(value) && balanced(value), { + message: + 'is not a plain CSS value (no ; { } \\ < > @, /*, url() nor images, brackets and quotes closed)', + }); + +const partial = (names: readonly T[]) => + z.strictObject( + Object.fromEntries(names.map((name) => [name, CssValue.optional()])), + ) as unknown as z.ZodType>>; + +/** + * A theme as contributed (`contributes.themes`): complete, or `extends` another and gives only + * what it changes. Unknown tokens are refused by name. + */ +export const ThemeContributionSchema = z + .strictObject({ + id: z.string().regex(/^[\w-]+$/), + label: z.string().min(1), + /** Taken from the theme it extends when not given. */ + kind: z.enum(['dark', 'light']).optional(), + /** The id of the theme it starts from (`nanoforge-dark`, or another plugin's). */ + extends: z.string().optional(), + colors: partial(COLOR_TOKENS).default({}), + syntax: partial(SYNTAX_TOKENS).optional(), + tokens: partial(THEME_TOKENS).optional(), + }) + .superRefine((theme, context) => { + if (theme.extends) return; + if (!theme.kind) + context.addIssue({ code: 'custom', message: 'kind is needed when the theme extends none' }); + const missing = COLOR_TOKENS.filter((token) => !theme.colors[token]); + if (missing.length) + context.addIssue({ + code: 'custom', + message: `colors lacks ${missing.join(', ')}: give every color, or extend a theme`, + }); + }); + +export type ThemeContribution = z.output; + +/** Why a theme can't be resolved: its base is missing or extends it back. */ +export class ThemeResolveError extends Error {} + +/** + * The complete themes from their contributions: each `extends` chain is followed (a base's + * colors, code colors and tokens, then the theme's own). The first contribution of an id is + * kept (core's themes come first); later ones, and themes that can't be resolved, are left out, + * with the reason given to `report`. + */ +export const resolveThemes = ( + contributions: readonly ThemeContribution[], + report: (id: string, error: ThemeResolveError, theme: ThemeContribution) => void = () => + undefined, +): ThemeDefinition[] => { + const byId = new Map(); + for (const theme of contributions) + if (byId.has(theme.id)) + report(theme.id, new ThemeResolveError('has the id of a theme already contributed'), theme); + else byId.set(theme.id, theme); + const resolved = new Map(); + const resolve = (id: string, chain: readonly string[]): ThemeDefinition => { + const done = resolved.get(id); + if (done) return done; + if (chain.includes(id)) + throw new ThemeResolveError(`extends itself: ${[...chain, id].join(' → ')}`); + const theme = byId.get(id); + if (!theme) + throw new ThemeResolveError( + `extends ${id}, which no installed plugin contributes (${[...chain].join(' → ')})`, + ); + const base = theme.extends ? resolve(theme.extends, [...chain, id]) : undefined; + const syntax = { ...base?.syntax, ...theme.syntax } as Partial>; + const tokens = { ...base?.tokens, ...theme.tokens } as Partial>; + const definition: ThemeDefinition = { + id: theme.id, + label: theme.label, + kind: theme.kind ?? base!.kind, + colors: { ...base?.colors, ...theme.colors } as Record, + ...(Object.keys(syntax).length && { syntax }), + ...(Object.keys(tokens).length && { tokens }), + }; + resolved.set(id, definition); + return definition; + }; + const themes: ThemeDefinition[] = []; + for (const theme of byId.values()) { + try { + themes.push(resolve(theme.id, [])); + } catch (error) { + if (error instanceof ThemeResolveError) report(theme.id, error, theme); + else throw error; + } + } + return themes; +}; diff --git a/packages/ui/src/theme/theme-service.ts b/packages/ui/src/theme/theme-service.ts index bcb61140..505e0f0e 100644 --- a/packages/ui/src/theme/theme-service.ts +++ b/packages/ui/src/theme/theme-service.ts @@ -1,9 +1,8 @@ -import { z } from 'zod'; - import { type Disposable, DisposableStore, type ExtensionRegistry, + type Logger, MutableDisposable, type Observable, ObservableValue, @@ -13,42 +12,57 @@ import { } from '@nanoforge-dev/editor-kernel'; import type { StyleService } from '../style/style-service'; +import { contrastWarnings } from './theme-colors'; +import { + type ThemeContribution, + ThemeContributionSchema, + resolveThemes, +} from './theme-contribution'; import { - COLOR_TOKENS, + type Density, type ThemeDefinition, nanoforgeDark, nanoforgeLight, themeDeclarations, } from './tokens'; -const ThemeSchema = z.object({ - id: z.string().regex(/^[\w-]+$/), - label: z.string().min(1), - kind: z.enum(['dark', 'light']), - colors: z.object(Object.fromEntries(COLOR_TOKENS.map((token) => [token, z.string().min(1)]))), -}) as unknown as z.ZodType; - -/** Color themes (built-in ones and `contributes.themes` of plugins). */ -export const THEMES = defineExtensionPoint('ui.themes', { - validator: ThemeSchema, +/** + * Color themes: the built-in ones and `contributes.themes` of plugins, complete or `extends` + * another (see `ThemeContributionSchema`). + */ +export const THEMES = defineExtensionPoint('ui.themes', { + validator: ThemeContributionSchema as never, }); -/** Applies the selected theme to the document root. */ +export interface ThemeServiceOptions { + /** `appearance.density`: how tight the UI is. */ + readonly density?: Observable; + /** Where unresolved themes and faint text are reported. */ + readonly logger?: Logger; +} + +/** Applies the selected theme (resolved) and the density to the document root. */ export class ThemeService implements Disposable { private readonly _store = new DisposableStore(); private readonly _applied = new MutableDisposable(); private readonly _current = new ObservableValue(nanoforgeDark); + /** Themes already checked (id and owner): each warns once. */ + private readonly _checked = new Set(); + /** Themes that could not be resolved, each reported once. */ + private readonly _reported = new Set(); constructor( private readonly _extensions: ExtensionRegistry, private readonly _styles: StyleService, - selected: Observable, + private readonly _selected: Observable, private readonly _root: HTMLElement = document.documentElement, + private readonly _options: ThemeServiceOptions = {}, ) { this._store.add(_extensions.contribute(THEMES, nanoforgeDark, { owner: 'core' })); this._store.add(_extensions.contribute(THEMES, nanoforgeLight, { owner: 'core' })); - const apply = () => this._apply(selected.get()); - this._store.add(observe(selected, apply)); + const apply = () => this._apply(); + this._store.add(observe(_selected, apply)); + if (_options.density) this._store.add(observe(_options.density, apply)); this._store.add(_extensions.onDidChange((point) => point.id === THEMES.id && apply())); } @@ -56,8 +70,19 @@ export class ThemeService implements Disposable { return this._current.readonly(); } + /** Every theme, resolved (an `extends` chain that can't be is left out). */ get themes(): ThemeDefinition[] { - return this._extensions.getValues(THEMES); + const owners = new Map( + this._extensions + .getContributions(THEMES) + .map((contribution) => [contribution.value, contribution.owner]), + ); + return resolveThemes(this._extensions.getValues(THEMES), (id, error, theme) => { + const message = `Theme "${id}" of ${owners.get(theme) ?? 'a plugin'} ${error.message}`; + if (this._reported.has(message)) return; + this._reported.add(message); + this._options.logger?.warn(message); + }); } dispose(): void { @@ -65,13 +90,31 @@ export class ThemeService implements Disposable { this._store.dispose(); } - private _apply(id: string): void { + private _apply(): void { + const id = this._selected.get(); const theme = this.themes.find((candidate) => candidate.id === id) ?? nanoforgeDark; this._root.dataset.nfTheme = theme.id; - this._applied.value = this._styles.inject('core', `:root {\n ${themeDeclarations(theme)}\n}`, { - layer: 'reset', - }); + this._applied.value = this._styles.inject( + 'core', + `:root {\n ${themeDeclarations(theme, this._options.density?.get())}\n}`, + { layer: 'reset' }, + ); this._current.set(theme); + this._check(theme); + } + + /** Faint text in a plugin's theme, said once to its author (the Console). */ + private _check(theme: ThemeDefinition): void { + const owner = this._extensions + .getContributions(THEMES) + .find((contribution) => contribution.value.id === theme.id)?.owner; + if (!owner || owner === 'core' || this._checked.has(`${owner}:${theme.id}`)) return; + this._checked.add(`${owner}:${theme.id}`); + const warnings = contrastWarnings(theme); + if (warnings.length) + this._options.logger?.warn( + `Theme "${theme.id}" of ${owner} has text too faint to read (WCAG AA): ${warnings.join('; ')}.`, + ); } } diff --git a/packages/ui/src/theme/tokens.ts b/packages/ui/src/theme/tokens.ts index 78abefbf..0ec5bf6e 100644 --- a/packages/ui/src/theme/tokens.ts +++ b/packages/ui/src/theme/tokens.ts @@ -38,11 +38,33 @@ export const COLOR_TOKENS = [ export type ColorToken = (typeof COLOR_TOKENS)[number]; +/** Code colors a theme may give (Monaco's token rules); the others come from Monaco's base. */ +export const SYNTAX_TOKENS = [ + 'keyword', + 'string', + 'number', + 'comment', + 'type', + 'variable', + 'regexp', + 'delimiter', +] as const; + +export type SyntaxToken = (typeof SYNTAX_TOKENS)[number]; + +/** Shared tokens a theme may change (fonts and corners); the rest stays the editor's. */ +export const THEME_TOKENS = ['font-ui', 'font-code', 'radius-control', 'radius-float'] as const; + +export type ThemeToken = (typeof THEME_TOKENS)[number]; + +/** A complete theme, as applied: every color, and the code colors and tokens it changes. */ export interface ThemeDefinition { readonly id: string; readonly label: string; readonly kind: 'dark' | 'light'; readonly colors: Readonly>; + readonly syntax?: Readonly>>; + readonly tokens?: Readonly>>; } /** @@ -150,10 +172,31 @@ export const SHARED_TOKENS: Readonly> = { export const cssVar = (group: 'color' | 'token', name: string) => group === 'color' ? `var(--nf-color-${name})` : `var(--nf-${name})`; +/** How tight the UI is: `normal` is the editor's own sizes. */ +export type Density = 'compact' | 'normal' | 'comfortable'; + +/** The sizes a density changes, and its factor. */ +const DENSITY_TOKENS = ['space-1', 'space-2', 'space-3', 'space-4', 'control-height', 'row-height']; +const DENSITY_FACTOR: Record = { compact: 0.85, normal: 1, comfortable: 1.2 }; + +/** The shared tokens for a theme and a density. */ +export const sharedTokens = ( + theme?: Pick, + density: Density = 'normal', +): Record => { + const tokens: Record = { ...SHARED_TOKENS, ...theme?.tokens }; + if (density !== 'normal') + for (const name of DENSITY_TOKENS) + tokens[name] = `${Math.round(Number.parseFloat(tokens[name]!) * DENSITY_FACTOR[density])}px`; + return tokens; +}; + /** CSS declarations of a theme (colors) plus the shared tokens. */ -export const themeDeclarations = (theme: ThemeDefinition): string => +export const themeDeclarations = (theme: ThemeDefinition, density: Density = 'normal'): string => [ `color-scheme: ${theme.kind};`, ...Object.entries(theme.colors).map(([name, value]) => `--nf-color-${name}: ${value};`), - ...Object.entries(SHARED_TOKENS).map(([name, value]) => `--nf-${name}: ${value};`), + ...Object.entries(sharedTokens(theme, density)).map( + ([name, value]) => `--nf-${name}: ${value};`, + ), ].join('\n '); diff --git a/packages/ui/src/workbench/changeset/changeset-preview-service.ts b/packages/ui/src/workbench/changeset/changeset-preview-service.ts new file mode 100644 index 00000000..62736a17 --- /dev/null +++ b/packages/ui/src/workbench/changeset/changeset-preview-service.ts @@ -0,0 +1,45 @@ +import { type Observable, ObservableValue, createToken } from '@nanoforge-dev/editor-kernel'; + +/** What a change would do to the project's files (`Changeset` of the project package). */ +export interface ChangesetPreview { + readonly title: string; + /** No `before`: created; no `after`: removed. */ + readonly files: readonly { + readonly path: string; + readonly before?: string; + readonly after?: string; + }[]; + readonly notes?: readonly string[]; + /** The button that makes the change (`Apply`). */ + readonly confirm?: string; +} + +export interface PendingPreview extends ChangesetPreview { + readonly resolve: (apply: boolean) => void; +} + +/** Shows a change as a diff of its files, to apply or cancel (project adoption's codemods). */ +export class ChangesetPreviewService { + private readonly _current = new ObservableValue(undefined); + + get current(): Observable { + return this._current.readonly(); + } + + /** Resolves with true when the user applies it. */ + preview(changeset: ChangesetPreview): Promise { + this._current.get()?.resolve(false); + return new Promise((resolve) => { + this._current.set({ + ...changeset, + resolve: (apply) => { + this._current.set(undefined); + resolve(apply); + }, + }); + }); + } +} + +export const ChangesetPreviewServiceToken = + createToken('ui.changesetPreview'); diff --git a/packages/ui/src/workbench/changeset/diff-lines.ts b/packages/ui/src/workbench/changeset/diff-lines.ts new file mode 100644 index 00000000..e94b0355 --- /dev/null +++ b/packages/ui/src/workbench/changeset/diff-lines.ts @@ -0,0 +1,94 @@ +/** A line of a diff: kept (`' '`), removed (`'-'`) or added (`'+'`), with its line numbers. */ +export interface DiffLine { + readonly kind: ' ' | '-' | '+'; + readonly text: string; + /** In the old text (kept and removed lines). */ + readonly old?: number; + /** In the new text (kept and added lines). */ + readonly new?: number; +} + +/** A run of changed lines with some unchanged ones around them. */ +export interface DiffHunk { + readonly lines: readonly DiffLine[]; +} + +/** Past this many line pairs, a file is shown as all removed then all added. */ +const MAX_CELLS = 4_000_000; + +const split = (text: string | undefined) => + text === undefined || text === '' ? [] : text.replace(/\n$/, '').split('\n'); + +/** The lines of two texts, kept, removed and added (a longest common subsequence). */ +export const diffLines = (before: string | undefined, after: string | undefined): DiffLine[] => { + const a = split(before); + const b = split(after); + const lines: DiffLine[] = []; + let start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start++; + let endA = a.length; + let endB = b.length; + while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { + endA--; + endB--; + } + for (let i = 0; i < start; i++) lines.push({ kind: ' ', text: a[i]!, old: i + 1, new: i + 1 }); + const n = endA - start; + const m = endB - start; + if (n * m > MAX_CELLS) { + for (let i = start; i < endA; i++) lines.push({ kind: '-', text: a[i]!, old: i + 1 }); + for (let j = start; j < endB; j++) lines.push({ kind: '+', text: b[j]!, new: j + 1 }); + } else { + /** `lcs[i][j]`: length of the common subsequence of a[start+i…] and b[start+j…]. */ + const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)); + for (let i = n - 1; i >= 0; i--) + for (let j = m - 1; j >= 0; j--) + lcs[i]![j] = + a[start + i] === b[start + j] + ? lcs[i + 1]![j + 1]! + 1 + : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!); + let i = 0; + let j = 0; + while (i < n || j < m) { + if (i < n && j < m && a[start + i] === b[start + j]) { + lines.push({ kind: ' ', text: a[start + i]!, old: start + i + 1, new: start + j + 1 }); + i++; + j++; + } else if (i < n && (j === m || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) { + lines.push({ kind: '-', text: a[start + i]!, old: start + i + 1 }); + i++; + } else { + lines.push({ kind: '+', text: b[start + j]!, new: start + j + 1 }); + j++; + } + } + } + for (let i = endA, j = endB; i < a.length; i++, j++) + lines.push({ kind: ' ', text: a[i]!, old: i + 1, new: j + 1 }); + return lines; +}; + +/** The changed lines in hunks, with `context` unchanged lines around each. */ +export const diffHunks = (lines: readonly DiffLine[], context = 3): DiffHunk[] => { + const keep = lines.map(() => false); + lines.forEach((line, index) => { + if (line.kind === ' ') return; + for ( + let k = Math.max(0, index - context); + k <= Math.min(lines.length - 1, index + context); + k++ + ) + keep[k] = true; + }); + const hunks: DiffHunk[] = []; + let current: DiffLine[] | undefined; + lines.forEach((line, index) => { + if (!keep[index]) { + current = undefined; + return; + } + if (!current) hunks.push({ lines: (current = []) }); + current.push(line); + }); + return hunks; +}; diff --git a/packages/ui/src/workbench/changeset/index.ts b/packages/ui/src/workbench/changeset/index.ts new file mode 100644 index 00000000..f98435bc --- /dev/null +++ b/packages/ui/src/workbench/changeset/index.ts @@ -0,0 +1,2 @@ +export * from './diff-lines'; +export * from './changeset-preview-service'; diff --git a/packages/ui/src/workbench/extension-point/file.extension-point.ts b/packages/ui/src/workbench/extension-point/file.extension-point.ts index d40d89b5..987dd22a 100644 --- a/packages/ui/src/workbench/extension-point/file.extension-point.ts +++ b/packages/ui/src/workbench/extension-point/file.extension-point.ts @@ -55,6 +55,10 @@ export interface FileIcon { readonly pattern: string; readonly icon: string; readonly priority?: number; + /** For folders (their path matches `pattern`), not files. */ + readonly folder?: boolean; + /** A folder's icon while it is expanded. */ + readonly iconOpen?: string; } export const FILE_ICONS: ExtensionPoint = defineExtensionPoint('ui.fileIcons'); diff --git a/packages/ui/src/workbench/index.ts b/packages/ui/src/workbench/index.ts index 23c771e4..ef560ca0 100644 --- a/packages/ui/src/workbench/index.ts +++ b/packages/ui/src/workbench/index.ts @@ -11,4 +11,5 @@ export * from './prompt'; export * from './setup'; export * from './tour'; export * from './tip'; +export * from './changeset'; export { default as Workbench } from './workbench.svelte'; diff --git a/packages/ui/src/workbench/parts/ChangesetPreviewHost.svelte b/packages/ui/src/workbench/parts/ChangesetPreviewHost.svelte new file mode 100644 index 00000000..51559920 --- /dev/null +++ b/packages/ui/src/workbench/parts/ChangesetPreviewHost.svelte @@ -0,0 +1,144 @@ + + +{#if $current} + $current?.resolve(false)} + > + {#if $current.notes?.length} +
    + {#each $current.notes as note (note)}
  • {note}
  • {/each} +
+ {/if} +
+ {#each files as file (file.path)} +
+
+ {file.status} + {file.path} + +{file.added} −{file.removed} +
+ {#if file.after !== undefined || file.before !== undefined} +
+ {#each file.hunks as hunk, index (index)} + {#if index > 0}{/if} + {#each hunk.lines as line, row (row)} +
+ {number(line) ?? ''} + {line.kind === ' ' ? '' : line.kind} + {line.text} +
+ {/each} + {/each} +
+ {/if} +
+ {/each} +
+ {#snippet footer()} + + + {/snippet} +
+{/if} + + diff --git a/packages/ui/src/workbench/setup/setup-shell.ts b/packages/ui/src/workbench/setup/setup-shell.ts index c2711dbe..0bc980bb 100644 --- a/packages/ui/src/workbench/setup/setup-shell.ts +++ b/packages/ui/src/workbench/setup/setup-shell.ts @@ -15,11 +15,23 @@ import { } from '@nanoforge-dev/editor-kernel'; import { CoreSettings, type SettingsService } from '@nanoforge-dev/editor-settings'; +import { registerIconFiles } from '../../components/icons'; import { StyleService } from '../../style/style-service'; import { BASE_CSS } from '../../theme/base-css'; +import { + ICON_THEMES, + IconFileSchema, + type IconThemeContribution, + IconThemeContributionSchema, + IconThemeService, + iconFileUrl, + iconThemeOf, +} from '../../theme/icon-theme'; import { KIT_CSS } from '../../theme/kit-css'; +import type { ThemeContribution } from '../../theme/theme-contribution'; import { THEMES, ThemeService } from '../../theme/theme-service'; -import type { ThemeDefinition } from '../../theme/tokens'; +import type { Density } from '../../theme/tokens'; +import { ChangesetPreviewService } from '../changeset/changeset-preview-service'; import { KEYBINDINGS, KeybindingSchema } from '../extension-point/keybinding.extension-point'; import { MENU_BAR, MENU_ITEMS, MenuItemSchema } from '../extension-point/menu.extension-point'; import { @@ -48,10 +60,12 @@ export interface ShellOptions { export interface Shell extends Disposable { readonly styles: StyleService; readonly themes: ThemeService; + readonly iconThemes: IconThemeService; readonly notifications: NotificationService; readonly prompts: PromptService; readonly keybindings: KeybindingService; readonly tours: TourService; + readonly changesets: ChangesetPreviewService; } /** Manifest handler registering each declared value on an extension point. */ @@ -77,11 +91,21 @@ export const setupShell = (options: ShellOptions): Shell => { store.add(styles.inject('core', BASE_CSS, { layer: 'reset' })); store.add(styles.inject('core', KIT_CSS, { layer: 'ui' })); const themes = store.add( - new ThemeService(extensions, styles, settings.observe(CoreSettings.theme)), + new ThemeService( + extensions, + styles, + settings.observe(CoreSettings.theme), + document.documentElement, + { density: settings.observe(CoreSettings.density) as Observable, logger }, + ), + ); + const iconThemes = store.add( + new IconThemeService(extensions, settings.observe(CoreSettings.iconTheme), logger), ); const notifications = new NotificationService(); const prompts = new PromptService(); const tours = store.add(new TourService(extensions, contextKeys, commands, notifications)); + const changesets = new ChangesetPreviewService(); store.add( commands.register({ id: 'tour.start', @@ -181,9 +205,41 @@ export const setupShell = (options: ShellOptions): Shell => { ), ), ); + store.add( + host.registerContributionHandler({ + key: 'icons', + validator: z.array(IconFileSchema) as unknown as z.ZodType< + z.output[] + >, + apply: (icons, plugin) => + registerIconFiles( + Object.fromEntries( + icons.map((icon) => [icon.id, iconFileUrl(plugin.baseUrl, icon.path)]), + ), + ), + }), + ); + store.add( + host.registerContributionHandler({ + key: 'iconThemes', + validator: z.array(IconThemeContributionSchema) as unknown as z.ZodType< + IconThemeContribution[] + >, + apply: (themes, plugin) => { + const registrations = new DisposableStore(); + for (const theme of themes) + registrations.add( + extensions.contribute(ICON_THEMES, iconThemeOf(theme, plugin.baseUrl), { + owner: plugin.manifest.name, + }), + ); + return registrations; + }, + }), + ); store.add( host.registerContributionHandler( - listHandler('themes', z.unknown() as z.ZodType, (value, owner) => + listHandler('themes', z.unknown() as z.ZodType, (value, owner) => extensions.contribute(THEMES, value, { owner }), ), ), @@ -193,10 +249,12 @@ export const setupShell = (options: ShellOptions): Shell => { return { styles, themes, + iconThemes, notifications, prompts, keybindings, tours, + changesets, dispose: () => store.dispose(), }; }; diff --git a/packages/ui/src/workbench/setup/setup-workbench.ts b/packages/ui/src/workbench/setup/setup-workbench.ts index b03d71f4..4f9a62f6 100644 --- a/packages/ui/src/workbench/setup/setup-workbench.ts +++ b/packages/ui/src/workbench/setup/setup-workbench.ts @@ -201,6 +201,7 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext prompts: shell.prompts, keybindings: shell.keybindings, tours: shell.tours, + changesets: shell.changesets, settings, extensions, commands, diff --git a/packages/ui/src/workbench/workbench-context.ts b/packages/ui/src/workbench/workbench-context.ts index b35d188b..a708835b 100644 --- a/packages/ui/src/workbench/workbench-context.ts +++ b/packages/ui/src/workbench/workbench-context.ts @@ -8,6 +8,7 @@ import type { } from '@nanoforge-dev/editor-kernel'; import type { SettingsService } from '@nanoforge-dev/editor-settings'; +import type { ChangesetPreviewService } from './changeset/changeset-preview-service'; import type { KeybindingService } from './keybinding/keybinding-service'; import type { DragController } from './layout/drag-controller'; import type { LayoutController } from './layout/layout-controller'; @@ -28,6 +29,7 @@ export interface WorkbenchContext extends MenuContext { readonly contextKeys: ContextKeyService; readonly keybindings: KeybindingService; readonly tours: TourService; + readonly changesets: ChangesetPreviewService; readonly settings: SettingsService; readonly logger: Logger; } diff --git a/packages/ui/src/workbench/workbench.svelte b/packages/ui/src/workbench/workbench.svelte index 0840cd6a..e38b46d8 100644 --- a/packages/ui/src/workbench/workbench.svelte +++ b/packages/ui/src/workbench/workbench.svelte @@ -12,6 +12,7 @@ import FloatWindow from './parts/FloatWindow.svelte'; import { FOCUSABLE } from './parts/panel-focus'; import PromptHost from './parts/PromptHost.svelte'; + import ChangesetPreviewHost from './parts/ChangesetPreviewHost.svelte'; import StatusBar from './parts/StatusBar.svelte'; import ToolStripe from './parts/ToolStripe.svelte'; import TopBar from './parts/TopBar.svelte'; @@ -330,6 +331,7 @@ + diff --git a/packages/ui/test/theme/__screenshots__/theme-colors.svelte.spec.ts/theme-colors-takes-no-color-the-canvas-cannot-draw-on-its-own-1.png b/packages/ui/test/theme/__screenshots__/theme-colors.svelte.spec.ts/theme-colors-takes-no-color-the-canvas-cannot-draw-on-its-own-1.png new file mode 100644 index 00000000..47767d2f Binary files /dev/null and b/packages/ui/test/theme/__screenshots__/theme-colors.svelte.spec.ts/theme-colors-takes-no-color-the-canvas-cannot-draw-on-its-own-1.png differ diff --git a/packages/ui/test/theme/icon-theme.svelte.spec.ts b/packages/ui/test/theme/icon-theme.svelte.spec.ts new file mode 100644 index 00000000..01bf3d0b --- /dev/null +++ b/packages/ui/test/theme/icon-theme.svelte.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import { ExtensionRegistry, ObservableValue } from '@nanoforge-dev/editor-kernel'; + +import Icon from '../../src/components/icon.svelte'; +import { registerIconFiles, resolveIconOf, setThemeIcons } from '../../src/components/icons'; +import { + ICON_THEMES, + IconThemeContributionSchema, + IconThemeService, + iconFileUrl, + iconThemeOf, +} from '../../src/theme/icon-theme'; +import { FILE_ICONS } from '../../src/workbench/extension-point/file.extension-point'; + +const BASE = 'http://127.0.0.1/plugins/bundled/@acme/icons/1.0.0/'; + +describe('icon files', () => { + it('are SVG files inside the plugin only', () => { + expect(iconFileUrl(BASE, 'icons/rocket.svg')).toBe(`${BASE}icons/rocket.svg`); + for (const path of [ + '../x.svg', + '/x.svg', + 'https://evil.test/x.svg', + 'icons/x.png', + 'a/../../x.svg', + ]) + expect(() => iconFileUrl(BASE, path), path).toThrow(/inside the plugin/); + }); + + it("resolve by name: the icon theme's, a contributed file, then the editor's own", () => { + const added = registerIconFiles({ rocket: `${BASE}rocket.svg`, folder: `${BASE}folder.svg` }); + expect(resolveIconOf('rocket')).toEqual({ kind: 'url', url: `${BASE}rocket.svg` }); + expect(resolveIconOf('folder')?.kind).toBe('component'); + setThemeIcons({ folder: `${BASE}theme-folder.svg` }); + expect(resolveIconOf('folder')).toEqual({ kind: 'url', url: `${BASE}theme-folder.svg` }); + setThemeIcons({}); + added.dispose(); + expect(resolveIconOf('rocket')).toBeUndefined(); + }); + + it('draws an SVG file as a mask in the text color, never inline', async () => { + setThemeIcons({ play: `${BASE}play.svg` }); + const { container } = render(Icon, { name: 'play', size: 20, label: 'Play' }); + const icon = container.querySelector('.nf-icon-file')!; + expect(icon).toBeTruthy(); + expect(icon.getAttribute('aria-label')).toBe('Play'); + expect(getComputedStyle(icon).width).toBe('20px'); + expect(icon.style.getPropertyValue('--nf-icon-url')).toContain('play.svg'); + expect(container.querySelector('svg')).toBeNull(); + setThemeIcons({}); + }); +}); + +describe('icon themes', () => { + it('replace icons and add file and folder icons while selected', () => { + const extensions = new ExtensionRegistry(); + const selected = new ObservableValue('nanoforge'); + const service = new IconThemeService(extensions, selected); + const theme = IconThemeContributionSchema.parse({ + id: 'acme', + label: 'Acme', + icons: { 'file-code': 'code.svg', folder: 'folder.svg' }, + files: [{ pattern: '**/*.ts', icon: 'file-code' }], + folders: [{ pattern: '**/assets', icon: 'folder', iconOpen: 'folder-open' }], + }); + extensions.contribute(ICON_THEMES, iconThemeOf(theme, BASE), { owner: '@acme/icons' }); + expect(extensions.getValues(FILE_ICONS)).toEqual([]); + selected.set('acme'); + expect(resolveIconOf('file-code')).toEqual({ kind: 'url', url: `${BASE}code.svg` }); + expect(extensions.getValues(FILE_ICONS)).toMatchObject([ + { pattern: '**/*.ts', icon: 'file-code', priority: 1000 }, + { pattern: '**/assets', folder: true, iconOpen: 'folder-open' }, + ]); + selected.set('nanoforge'); + expect(resolveIconOf('file-code')?.kind).toBe('component'); + expect(extensions.getValues(FILE_ICONS)).toEqual([]); + service.dispose(); + }); +}); diff --git a/packages/ui/test/theme/theme-colors.svelte.spec.ts b/packages/ui/test/theme/theme-colors.svelte.spec.ts new file mode 100644 index 00000000..a5ffc7ec --- /dev/null +++ b/packages/ui/test/theme/theme-colors.svelte.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import example from '../../../../tooling/example-theme/nanoforge.manifest.json'; +import { contrast, contrastWarnings, parseColor, toHex } from '../../src/theme/theme-colors'; +import { ThemeContributionSchema, resolveThemes } from '../../src/theme/theme-contribution'; +import { nanoforgeDark, nanoforgeLight } from '../../src/theme/tokens'; + +describe('theme colors', () => { + it('reads any CSS color the browser draws', () => { + expect(toHex(parseColor('#12161d')!)).toBe('#12161d'); + expect(toHex(parseColor('rgb(255 0 0)')!)).toBe('#ff0000'); + expect(parseColor('oklch(0.4493 0.1953 294.69)')).toMatchObject({ a: 1 }); + expect(parseColor('rgba(8, 10, 14, 0.62)')!.a).toBeCloseTo(0.62, 1); + expect(parseColor('not a color')).toBeUndefined(); + }); + + it('takes no color the canvas cannot draw on its own', () => { + for (const value of [ + 'var(--nf-color-bg)', + 'var(--missing, #fff)', + 'color-mix(in srgb, var(--nf-color-bg) 50%, #fff)', + 'env(--x, #000)', + 'currentColor', + 'currentcolor', + ]) + expect(parseColor(value), value).toBeUndefined(); + }); + + it('measures contrast as WCAG does', () => { + expect(contrast(parseColor('#000')!, parseColor('#fff')!)).toBeCloseTo(21, 0); + expect(contrast(parseColor('#777')!, parseColor('#fff')!)).toBeCloseTo(4.48, 1); + }); + + it('finds no faint text in the built-in themes, and finds it in a faint one', () => { + expect(contrastWarnings(nanoforgeDark)).toEqual([]); + expect(contrastWarnings(nanoforgeLight)).toEqual([]); + const faint = { + ...nanoforgeDark, + colors: { ...nanoforgeDark.colors, 'text-faint': '#3a3f4a' }, + }; + expect(contrastWarnings(faint)).toContainEqual( + expect.stringMatching(/^text-faint on bg is 1\.\d\d:1 \(needs 4\.5:1\)$/), + ); + }); + + it('finds no faint text in the example theme plugin', () => { + const themes = resolveThemes( + [nanoforgeDark, ...example.contributes.themes].map((theme) => + ThemeContributionSchema.parse(theme), + ), + ); + expect(themes[1]).toMatchObject({ id: 'acme-ocean', kind: 'dark' }); + expect(contrastWarnings(themes[1]!)).toEqual([]); + }); +}); diff --git a/packages/ui/test/theme/theme-contribution.spec.ts b/packages/ui/test/theme/theme-contribution.spec.ts new file mode 100644 index 00000000..f277e393 --- /dev/null +++ b/packages/ui/test/theme/theme-contribution.spec.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; + +import { ThemeContributionSchema, resolveThemes } from '../../src/theme/theme-contribution'; +import { + SHARED_TOKENS, + type ThemeDefinition, + nanoforgeDark, + nanoforgeLight, + sharedTokens, + themeDeclarations, +} from '../../src/theme/tokens'; + +const parse = (value: unknown) => ThemeContributionSchema.parse(value); + +describe('theme contributions', () => { + it('extends a theme with what it changes, through chains', () => { + const ocean = parse({ + id: 'ocean', + label: 'Ocean', + extends: 'nanoforge-dark', + colors: { accent: '#0af' }, + }); + const deep = parse({ + id: 'deep', + label: 'Deep', + extends: 'ocean', + syntax: { keyword: '#f0f' }, + tokens: { 'radius-control': '0px' }, + }); + const [, , o, d] = resolveThemes([nanoforgeDark, nanoforgeLight, ocean, deep].map(parse)); + expect(o).toMatchObject({ + id: 'ocean', + kind: 'dark', + colors: { ...nanoforgeDark.colors, accent: '#0af' }, + }); + expect(d).toMatchObject({ + kind: 'dark', + colors: { accent: '#0af', bg: nanoforgeDark.colors.bg }, + syntax: { keyword: '#f0f' }, + tokens: { 'radius-control': '0px' }, + }); + }); + + it('refuses values that leave their declaration or load a file', () => { + const theme = (colors: Record, tokens: Record = {}) => ({ + id: 'x', + label: 'X', + extends: 'nanoforge-dark', + colors, + tokens, + }); + for (const value of [ + 'red; } * { background: red } :root {', + 'url(https://example.com/a.png)', + 'image-set("https://example.com/a.png" 1x)', + '\\75rl(x)', + '@import "x"', + ]) + expect(() => parse(theme({ accent: value })), value).toThrow(/not a plain CSS value/); + expect( + parse( + theme({ accent: 'oklch(0.6 0.1 200 / 80%)' }, { 'font-code': '"Fira Code", monospace' }), + ), + ).toBeTruthy(); + }); + + it('refuses values that run into what follows them', () => { + const theme = (accent: string) => ({ + id: 'x', + label: 'X', + extends: 'nanoforge-dark', + colors: { accent }, + }); + const refused = (value: string) => + expect(() => parse(theme(value)), value).toThrow(/not a plain CSS value/); + refused('#000 /*'); + refused('rgb(0 0 0'); + refused('rgb(0 0 0))'); + refused('rgb(0 0 0]'); + refused('#000 [x'); + refused('"Fira Code, monospace'); + refused("'Fira Code', 'mono"); + for (const value of [ + 'rgb(0 0 0 / 50%)', + 'color-mix(in srgb, rgb(0 0 0) 20%, #fff)', + '"a ( [ \' b", monospace', + ]) + expect(parse(theme(value)), value).toBeTruthy(); + }); + + it('keeps the first theme of an id and reports the others', () => { + const reports: string[] = []; + const usurper = parse({ + id: 'nanoforge-dark', + label: 'Not the default', + extends: 'nanoforge-light', + colors: { accent: '#f00' }, + }); + const themes = resolveThemes( + [nanoforgeDark, nanoforgeLight, usurper].map(parse).concat(usurper), + (_id, error, theme) => reports.push(`${theme.label}: ${error.message}`), + ); + expect(themes.map((theme) => theme.label)).toEqual(['NanoForge Dark', 'NanoForge Light']); + expect(themes[0]!.colors).toEqual(nanoforgeDark.colors); + expect(reports).toEqual([ + 'Not the default: has the id of a theme already contributed', + 'Not the default: has the id of a theme already contributed', + ]); + }); + + it('names what is wrong: unknown tokens, missing colors, missing bases and cycles', () => { + expect(() => + parse({ id: 'x', label: 'X', extends: 'nanoforge-dark', colors: { acent: '#fff' } }), + ).toThrow(/acent/); + expect(() => parse({ id: 'x', label: 'X', kind: 'dark', colors: { bg: '#000' } })).toThrow( + /colors lacks surface/, + ); + const reports: string[] = []; + const themes = resolveThemes( + [ + parse({ id: 'a', label: 'A', extends: 'b' }), + parse({ id: 'b', label: 'B', extends: 'a' }), + parse({ id: 'c', label: 'C', extends: 'gone' }), + ], + (id, error) => reports.push(`${id}: ${error.message}`), + ); + expect(themes).toEqual([]); + expect(reports).toEqual([ + 'a: extends itself: a → b → a', + 'b: extends itself: b → a → b', + 'c: extends gone, which no installed plugin contributes (c)', + ]); + }); +}); + +describe('theme declarations', () => { + /** The declarations before themes could change tokens: the built-in themes must not move. */ + const before = (theme: ThemeDefinition) => + [ + `color-scheme: ${theme.kind};`, + ...Object.entries(theme.colors).map(([name, value]) => `--nf-color-${name}: ${value};`), + ...Object.entries(SHARED_TOKENS).map(([name, value]) => `--nf-${name}: ${value};`), + ].join('\n '); + + it('are unchanged for the built-in themes at the normal density', () => { + expect(themeDeclarations(nanoforgeDark)).toBe(before(nanoforgeDark)); + expect(themeDeclarations(nanoforgeLight, 'normal')).toBe(before(nanoforgeLight)); + }); + + it("take a theme's fonts and corners, and scale sizes with the density", () => { + expect(sharedTokens({ tokens: { 'font-ui': 'Comic Neue' } })['font-ui']).toBe('Comic Neue'); + expect(sharedTokens(undefined, 'compact')).toMatchObject({ + 'control-height': '22px', + 'row-height': '20px', + 'space-2': '7px', + }); + expect(sharedTokens(undefined, 'comfortable')).toMatchObject({ + 'control-height': '31px', + 'font-size-md': '13px', + }); + }); +}); diff --git a/packages/ui/test/theme/theme-service.svelte.spec.ts b/packages/ui/test/theme/theme-service.svelte.spec.ts new file mode 100644 index 00000000..a514955e --- /dev/null +++ b/packages/ui/test/theme/theme-service.svelte.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { ExtensionRegistry, type Logger, ObservableValue } from '@nanoforge-dev/editor-kernel'; + +import { resolveIconOf } from '../../src/components/icons'; +import { StyleService } from '../../src/style/style-service'; +import { + DEFAULT_ICON_THEME, + ICON_THEMES, + IconThemeContributionSchema, + IconThemeService, + iconThemeOf, +} from '../../src/theme/icon-theme'; +import { ThemeContributionSchema } from '../../src/theme/theme-contribution'; +import { THEMES, ThemeService } from '../../src/theme/theme-service'; +import { nanoforgeDark } from '../../src/theme/tokens'; +import { FILE_ICONS } from '../../src/workbench/extension-point/file.extension-point'; + +const BASE = 'http://127.0.0.1/plugins/bundled/@acme/icons/1.0.0/'; + +const recorder = () => { + const warnings: string[] = []; + const logger = { warn: (message: string) => warnings.push(message) } as unknown as Logger; + return { warnings, logger }; +}; + +const surface = () => + getComputedStyle(document.documentElement).getPropertyValue('--nf-color-surface').trim(); + +const ocean = ThemeContributionSchema.parse({ + id: 'ocean', + label: 'Ocean', + extends: 'nanoforge-dark', + colors: { surface: '#003355' }, +}); + +const acmeIcons = iconThemeOf( + IconThemeContributionSchema.parse({ + id: 'acme', + label: 'Acme', + icons: { 'file-code': 'code.svg' }, + files: [{ pattern: '**/*.ts', icon: 'file-code' }], + }), + BASE, +); + +describe('ThemeService', () => { + it('falls back on the default theme when the selected one goes', () => { + const extensions = new ExtensionRegistry(); + const themes = new ThemeService(extensions, new StyleService(), new ObservableValue('ocean')); + const contribution = extensions.contribute(THEMES, ocean, { owner: '@acme/ocean' }); + expect(themes.current.get().id).toBe('ocean'); + expect(surface()).toBe('#003355'); + contribution.dispose(); + expect(themes.current.get()).toMatchObject({ + id: 'nanoforge-dark', + colors: nanoforgeDark.colors, + }); + expect(document.documentElement.dataset.nfTheme).toBe('nanoforge-dark'); + expect(surface()).toBe(nanoforgeDark.colors.surface); + themes.dispose(); + }); + + it("keeps the default theme when a plugin's theme takes its id, and says so", () => { + const extensions = new ExtensionRegistry(); + const { warnings, logger } = recorder(); + const themes = new ThemeService( + extensions, + new StyleService(), + new ObservableValue('nanoforge-dark'), + document.documentElement, + { logger }, + ); + extensions.contribute(THEMES, { ...ocean, id: 'nanoforge-dark' }, { owner: '@acme/ocean' }); + expect(themes.themes.map((theme) => theme.id)).toEqual(['nanoforge-dark', 'nanoforge-light']); + expect(themes.current.get()).toMatchObject({ + id: 'nanoforge-dark', + colors: nanoforgeDark.colors, + }); + expect(surface()).toBe(nanoforgeDark.colors.surface); + expect(warnings).toEqual([ + 'Theme "nanoforge-dark" of @acme/ocean has the id of a theme already contributed', + ]); + themes.dispose(); + }); +}); + +describe('IconThemeService', () => { + it('falls back on the default icons when the selected icon theme goes', () => { + const extensions = new ExtensionRegistry(); + const service = new IconThemeService(extensions, new ObservableValue('acme')); + const contribution = extensions.contribute(ICON_THEMES, acmeIcons, { owner: '@acme/icons' }); + expect(service.current.get().id).toBe('acme'); + expect(resolveIconOf('file-code')).toEqual({ kind: 'url', url: `${BASE}code.svg` }); + contribution.dispose(); + expect(service.current.get()).toBe(DEFAULT_ICON_THEME); + expect(resolveIconOf('file-code')?.kind).toBe('component'); + expect(extensions.getValues(FILE_ICONS)).toEqual([]); + service.dispose(); + }); + + it("keeps the default icons when a plugin's icon theme takes their id, and says so", () => { + const extensions = new ExtensionRegistry(); + const { warnings, logger } = recorder(); + const service = new IconThemeService(extensions, new ObservableValue('nanoforge'), logger); + extensions.contribute(ICON_THEMES, { ...acmeIcons, id: 'nanoforge' }, { owner: '@acme/icons' }); + expect(service.themes).toEqual([DEFAULT_ICON_THEME]); + expect(service.current.get()).toBe(DEFAULT_ICON_THEME); + expect(resolveIconOf('file-code')?.kind).toBe('component'); + expect(warnings).toEqual([ + 'Icon theme "nanoforge" of @acme/icons has the id of an icon theme already contributed', + ]); + service.dispose(); + }); +}); diff --git a/packages/ui/test/workbench/changeset/diff-lines.spec.ts b/packages/ui/test/workbench/changeset/diff-lines.spec.ts new file mode 100644 index 00000000..04afc562 --- /dev/null +++ b/packages/ui/test/workbench/changeset/diff-lines.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { diffHunks, diffLines } from '../../../src/workbench/changeset/diff-lines'; + +const kinds = (before?: string, after?: string) => + diffLines(before, after).map((line) => `${line.kind}${line.text}`); + +describe('diffLines', () => { + it('keeps common lines and marks the rest removed or added', () => { + expect(kinds('a\nb\nc\n', 'a\nx\nc\n')).toEqual([' a', '-b', '+x', ' c']); + expect(kinds(undefined, 'new\nfile\n')).toEqual(['+new', '+file']); + expect(kinds('gone\n', undefined)).toEqual(['-gone']); + expect(kinds('same\n', 'same\n')).toEqual([' same']); + }); + + it('numbers lines in each text', () => { + const lines = diffLines('a\nb\n', 'z\na\nb\n'); + expect(lines[0]).toEqual({ kind: '+', text: 'z', new: 1 }); + expect(lines[1]).toEqual({ kind: ' ', text: 'a', old: 1, new: 2 }); + }); + + it('cuts long unchanged runs out of hunks', () => { + const before = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n'); + const after = before.replace('line 2\n', 'two\n').replace('line 17', 'seventeen'); + const hunks = diffHunks(diffLines(before, after), 1); + expect(hunks).toHaveLength(2); + expect(hunks[0]!.lines.map((line) => line.text)).toEqual(['line 1', 'line 2', 'two', 'line 3']); + }); +}); diff --git a/plugins/code-editor/src/monaco/monaco-theme.ts b/plugins/code-editor/src/monaco/monaco-theme.ts index 1ca2e591..c583b1e3 100644 --- a/plugins/code-editor/src/monaco/monaco-theme.ts +++ b/plugins/code-editor/src/monaco/monaco-theme.ts @@ -1,11 +1,20 @@ -import type { ThemeDefinition } from '@nanoforge-dev/editor-sdk/ui'; +import { + type SyntaxToken, + type ThemeDefinition, + parseColor, + toHex, +} from '@nanoforge-dev/editor-sdk/ui'; import type { monaco } from './monaco'; /** The editor's theme as a Monaco theme: the colors Monaco takes, from the theme tokens. */ export const monacoThemeOf = (theme: ThemeDefinition): monaco.editor.IStandaloneThemeData => { const { colors } = theme; - const hex = (value: string) => (/^#[0-9a-f]{3,8}$/i.test(value) ? value : undefined); + /** Any CSS color (oklch, rgb…) as the hex Monaco takes; undefined if it is not one. */ + const hex = (value: string | undefined) => { + const color = value ? parseColor(value) : undefined; + return color ? toHex(color) : undefined; + }; const entries: [string, string | undefined][] = [ ['editor.background', hex(colors.surface)], ['editor.foreground', hex(colors.text)], @@ -23,7 +32,27 @@ export const monacoThemeOf = (theme: ThemeDefinition): monaco.editor.IStandalone return { base: theme.kind === 'dark' ? 'vs-dark' : 'vs', inherit: true, - rules: [], + rules: Object.entries(theme.syntax ?? {}).flatMap(([token, value]) => { + const color = hex(value); + return color + ? MONACO_TOKENS[token as SyntaxToken].map((name) => ({ + token: name, + foreground: color.slice(1), + })) + : []; + }), colors: Object.fromEntries(entries.filter((entry): entry is [string, string] => !!entry[1])), }; }; + +/** Monaco's token names for each code color (TypeScript, JavaScript, JSON). */ +const MONACO_TOKENS: Record = { + keyword: ['keyword'], + string: ['string', 'string.key.json', 'string.value.json'], + number: ['number'], + comment: ['comment'], + type: ['type', 'type.identifier'], + variable: ['identifier', 'variable'], + regexp: ['regexp'], + delimiter: ['delimiter', 'operator'], +}; diff --git a/plugins/code-editor/src/service/code-editor-service.ts b/plugins/code-editor/src/service/code-editor-service.ts index c2375819..f5231b82 100644 --- a/plugins/code-editor/src/service/code-editor-service.ts +++ b/plugins/code-editor/src/service/code-editor-service.ts @@ -53,6 +53,9 @@ export class CodeEditorService implements Disposable { /** A document was loaded or released. */ readonly onDidChangeDocuments: Event = this._onDidChangeDocuments.event; + /** The code font of the theme last applied: Monaco measures again only when it changes. */ + private _codeFont: string | undefined; + constructor(private readonly _options: CodeEditorServiceOptions) { const { monaco, project } = _options; this._unsaved = createUnsavedStore(project.id); @@ -295,6 +298,12 @@ export class CodeEditorService implements Disposable { applyTheme(theme: ThemeDefinition): void { this._options.monaco.editor.defineTheme('nanoforge', monacoThemeOf(theme)); this._options.monaco.editor.setTheme('nanoforge'); + /** A theme's code font (`font-code`) changes the glyph widths Monaco measured. */ + const font = theme.tokens?.['font-code']; + if (font !== this._codeFont) { + this._codeFont = font; + this._options.monaco.editor.remeasureFonts(); + } } /** Runs an editor command, when the editor gave a way to. */ diff --git a/plugins/code-editor/src/widgets/script/EditorGroupView.svelte b/plugins/code-editor/src/widgets/script/EditorGroupView.svelte index 948d459b..08d9b864 100644 --- a/plugins/code-editor/src/widgets/script/EditorGroupView.svelte +++ b/plugins/code-editor/src/widgets/script/EditorGroupView.svelte @@ -139,8 +139,19 @@ return !current || current === window.document.body || !!groupElement?.contains(current); }; - onDestroy(() => { + /** + * Detaches the diff editor's models before disposing it: a diff still being computed would + * otherwise make widgets through its disposed services ("InstantiationService has been + * disposed"). + */ + const disposeDiff = () => { + diff?.setModel(null); diff?.dispose(); + diff = undefined; + }; + + onDestroy(() => { + disposeDiff(); editor?.dispose(); }); @@ -177,8 +188,7 @@ }); diff.setModel({ original, modified: active.model }); return () => { - diff?.dispose(); - diff = undefined; + disposeDiff(); original.dispose(); }; }); diff --git a/plugins/ecs/src/adoption/duplicate-checks.ts b/plugins/ecs/src/adoption/duplicate-checks.ts new file mode 100644 index 00000000..e2eb44ef --- /dev/null +++ b/plugins/ecs/src/adoption/duplicate-checks.ts @@ -0,0 +1,227 @@ +import { + type AppModel, + type CatalogItem, + type CatalogState, + type ClientProject, + type PluginContext, + type ProjectSuggestion, + type WorkspaceIo, + createLibrary, + librariesOf, + nameTaken, + recordChanges, + setLibraryUse, + workspaceIo, +} from '@nanoforge-dev/editor-sdk'; + +import { + IMPORTERS_ANALYZER, + REDIRECTED_IMPORTS_ANALYZER, + RELOCATED_TEXT_ANALYZER, +} from '../model/ecs.const'; +import { codeServices } from '../service/code-services'; +import { ecsData } from '../service/ecs-data'; + +const basename = (path: string) => path.split('/').at(-1) ?? path; + +const STRING = /("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/; + +/** + * A file's code, imports included, without its spacing, trailing commas and quote style: two + * copies of a component formatted differently compare equal. Strings are kept as they are + * (`"Big ball"` is not `"Bigball"`). + */ +const essence = (text: string) => + text + .split(STRING) + .map((part, index) => { + if (index % 2) + return part.startsWith("'") && !part.includes('"') ? `"${part.slice(1, -1)}"` : part; + return part + .replace(/\s+/g, ' ') + .replace(/ ?([^\w$ ]) ?/g, '$1') + .replace(/,([)\]}])/g, '$1'); + }) + .join('') + .trim(); + +/** + * Where the shared copy goes: a library all the apps use (or the first one), else a new + * `libs/shared` named after the project's scope (`@acme/pong` → `@acme/shared`), on a free name. + */ +const placement = ( + project: ClientProject, + apps: readonly AppModel[], + file: string, + io: WorkspaceIo, +) => { + const model = project.model.get(); + const libraries = librariesOf(model); + const existing = + libraries.find((library) => apps.every((app) => app.libraries.includes(library.name))) ?? + libraries[0]; + let library: Pick & { dirs: { components: string } }; + if (existing) library = existing; + else { + const scope = (model.name.match(/^@([^/]+)\//)?.[1] ?? model.name) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, ''); + let folder = 'shared'; + for ( + let n = 2; + io.exists(`libs/${folder}`) || nameTaken(`@${scope}/${folder}`, model, io.exists); + n++ + ) + folder = `shared-${n}`; + library = { + root: `libs/${folder}`, + name: `@${scope}/${folder}`, + dirs: { components: `libs/${folder}/src/components` }, + }; + } + let target = `${library.dirs.components}/${basename(file)}`; + for (let n = 2; io.exists(target); n++) + target = `${library.dirs.components}/${basename(file).replace(/(\.[^.]+)$/, `-${n}$1`)}`; + return { existing, library, target }; +}; + +/** How code imports a library's file: its package name and its path under `src/` (ADR 0004). */ +const librarySpecifier = (library: Pick, file: string) => { + const prefix = `${library.root}/src/`; + if (!file.startsWith(prefix)) return undefined; + return `${library.name}/${file.slice(prefix.length).replace(/\.[cm]?[jt]sx?$/, '')}`; +}; + +/** + * Components with the same name in several apps, each its own copy: identical copies, each alone + * in its file, move to a shared library that all of them use (created if the project has none). + */ +export const duplicateSuggestions = async ( + context: PluginContext, + project: ClientProject, + state: CatalogState | undefined, +): Promise => { + const { code } = codeServices(context); + if (!code) return []; + const model = project.model.get(); + const appOf = (item: CatalogItem) => + model.apps.find((app) => app.root === item.source.path && app.type !== 'lib'); + const items = (state?.items ?? []).filter( + (item) => + !item.readonly && + item.source.kind === 'app' && + ecsData(item)?.type === 'component' && + appOf(item), + ); + const perFile = new Map(); + for (const item of state?.items ?? []) + perFile.set(item.meta.source, (perFile.get(item.meta.source) ?? 0) + 1); + const byName = new Map(); + for (const item of items) + byName.set(item.meta.export, [...(byName.get(item.meta.export) ?? []), item]); + + const suggestions: ProjectSuggestion[] = []; + for (const [name, copies] of byName) { + const apps = [...new Set(copies.map((copy) => appOf(copy)!))]; + if (apps.length < 2) continue; + const appNames = apps.map((app) => app.name).join(' and '); + const base = { + id: `ecs.duplicate:${name}`, + title: `Share ${name}`, + app: appNames, + }; + const why = `${appNames} each have their own ${name}: a change to one copy is not made to the other.`; + const crowded = copies.filter((copy) => (perFile.get(copy.meta.source) ?? 0) > 1); + if (crowded.length) { + suggestions.push({ + ...base, + detail: `${why} ${crowded.map((copy) => basename(copy.meta.source)).join(' and ')} also ${crowded.length === 1 ? 'holds' : 'hold'} other items: split ${crowded.length === 1 ? 'it' : 'them'} first, then ${name} can move to a shared library.`, + }); + continue; + } + /** Each copy as it would read in the library: its imports count, from the same folder. */ + const relocate = (to: string) => + Promise.all( + copies.map((copy) => + code.analyze<{ text: string; imports: string[] }>( + copy.meta.source, + RELOCATED_TEXT_ANALYZER, + { to }, + ), + ), + ); + const probe = placement( + project, + apps, + copies[0]!.meta.source, + workspaceIo(project, { fresh: false }), + ); + const texts = (await relocate(probe.target)).map((relocated) => relocated.text); + if (new Set(texts.map(essence)).size > 1) { + suggestions.push({ + ...base, + detail: `${why} The copies differ (${copies.map((copy) => copy.meta.source).join(', ')}): make them the same, then ${name} can move to a shared library.`, + }); + continue; + } + suggestions.push({ + ...base, + detail: `${why} In a shared library, ${apps.length === 2 ? 'both' : 'all of them'} use one ${name}, and the editor offers it to each (ADR 0004).`, + fix: { + title: 'Move to a shared library', + changeset: async () => { + const io = workspaceIo(project, { fresh: false }); + const { existing, library, target } = placement( + project, + apps, + copies[0]!.meta.source, + io, + ); + const relocated = await relocate(target); + const appImport = relocated + .flatMap((copy) => copy.imports) + .find((path) => + model.apps.some((app) => app.type !== 'lib' && path.startsWith(`${app.root}/`)), + ); + if (appImport) + throw new Error( + `${name} imports ${appImport}, and a shared library can't import an app: move what it uses first.`, + ); + const specifier = librarySpecifier(library, target); + if (!specifier) + throw new Error( + `${library.name} does not keep its code in ${library.root}/src: move ${name} by hand.`, + ); + const redirects: { path: string; text: string }[] = []; + for (const copy of copies) { + const importers = await code.analyze(copy.meta.source, IMPORTERS_ANALYZER); + for (const importer of importers.filter((path) => path !== copy.meta.source)) + redirects.push({ + path: importer, + text: await code.analyze(importer, REDIRECTED_IMPORTS_ANALYZER, { + from: copy.meta.source, + specifier, + }), + }); + } + return recordChanges(io, `Move ${name} to ${library.name}`, async (memory) => { + if (existing) { + for (const app of apps.filter((app) => !app.libraries.includes(existing.name))) + await setLibraryUse(memory, app, existing, true); + } else + await createLibrary(memory, { + folder: library.root.slice('libs/'.length), + packageName: library.name, + usedBy: apps, + }); + await memory.write(target, relocated[0]!.text); + for (const { path, text } of redirects) await memory.write(path, text); + for (const copy of copies) await memory.remove(copy.meta.source); + }); + }, + }, + }); + } + return suggestions; +}; diff --git a/plugins/ecs/src/adoption/ecs-checks.ts b/plugins/ecs/src/adoption/ecs-checks.ts new file mode 100644 index 00000000..b4515023 --- /dev/null +++ b/plugins/ecs/src/adoption/ecs-checks.ts @@ -0,0 +1,151 @@ +import { + type AppModel, + type CatalogItem, + type CatalogState, + type ClientProject, + type Disposable, + type Observable, + PROJECT_CHECKS, + type PluginContext, + type ProjectSuggestion, + recordChanges, + workspaceIo, +} from '@nanoforge-dev/editor-sdk'; + +import { kebab } from '../create/create-item'; +import { IMPORTERS_ANALYZER, LOCAL_ITEMS_ANALYZER, SPLIT_ITEMS_ANALYZER } from '../model/ecs.const'; +import { codeServices } from '../service/code-services'; +import { ecsData } from '../service/ecs-data'; +import type { LocalItems } from '../worker/local-items'; +import type { SplitItemsResult } from '../worker/split-items'; +import { duplicateSuggestions } from './duplicate-checks'; + +const basename = (path: string) => path.split('/').at(-1) ?? path; +const list = (names: readonly string[]) => + names.length < 2 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; + +/** Resolves once the catalog has read every source (or after a while). */ +const loaded = (catalog: Observable, timeoutMs = 20_000) => + new Promise((resolve) => { + const done = (state: CatalogState | undefined) => { + clearTimeout(timer); + unsubscribe(); + resolve(state); + }; + const timer = setTimeout(() => done(catalog.get()), timeoutMs); + const unsubscribe = catalog.subscribe((state) => { + if (state && !state.loading) queueMicrotask(() => done(state)); + }); + }); + +/** + * The ECS plugin's checks of the "Adapt to the editor" report: components and systems defined in + * an entry file, several items in one file (both moved to files of their own), and item files + * that import the entry file (report only). + */ +export const registerEcsChecks = ( + context: PluginContext, + catalog: Observable, +): Disposable => { + const run = async (project: ClientProject): Promise => { + const { code } = codeServices(context); + if (!code) return []; + const state = await loaded(catalog); + const apps = project.model.get().apps; + const io = workspaceIo(project, { fresh: false }); + const suggestions: ProjectSuggestion[] = []; + const planned = new Set(); + /** A free path for an item's own file. */ + const target = (dir: string, name: string) => { + let path = `${dir}/${kebab(name)}.ts`; + for (let n = 2; io.exists(path) || planned.has(path); n++) + path = `${dir}/${kebab(name)}-${n}.ts`; + planned.add(path); + return path; + }; + const split = + (title: string, file: string, items: { name: string; to: string }[], entry = false) => + () => + code + .analyze(file, SPLIT_ITEMS_ANALYZER, { items, entry }) + .then((result) => + recordChanges(io, title, async (memory) => { + for (const { path, text } of [...result.files, ...result.importers]) + await memory.write(path, text); + if (result.source === null) await memory.remove(file); + else await memory.write(file, result.source); + }), + ); + + for (const app of apps.filter((candidate) => candidate.type !== 'lib')) { + const entry = app.entryFile; + if (!entry || !io.exists(entry)) continue; + const local = await code.analyze(entry, LOCAL_ITEMS_ANALYZER); + const names = [...local.components, ...local.systems]; + if (names.length) { + const items = [ + ...local.components.map((name) => ({ name, to: target(app.dirs.components, name) })), + ...local.systems.map((name) => ({ name, to: target(app.dirs.systems, name) })), + ]; + suggestions.push({ + id: `ecs.extract:${entry}`, + app: app.name, + title: `Move ${names.length === 1 ? names[0] : `${names.length} items`} out of ${basename(entry)}`, + detail: `${list(names)} ${names.length === 1 ? 'is' : 'are'} defined in ${entry}. The editor finds components and systems in their own files: it can't list ${names.length === 1 ? 'it' : 'them'} in the Components panel, document ${names.length === 1 ? 'it' : 'them'} in the Inspector, nor move ${names.length === 1 ? 'it' : 'them'} to a shared library. This gives each a file of its own.`, + fix: { + title: 'Move to files of their own', + changeset: split(`Move ${list(names)} out of ${basename(entry)}`, entry, items, true), + }, + }); + } + const importers = await code.analyze(entry, IMPORTERS_ANALYZER); + const itemFiles = new Set((state?.items ?? []).map((item) => item.meta.source)); + for (const file of importers.filter((path) => itemFiles.has(path))) + suggestions.push({ + id: `ecs.imports-entry:${file}`, + app: app.name, + title: `${basename(file)} imports ${basename(entry)}`, + detail: `${file} holds components or systems and imports the app's entry file (${entry}): they can't move to a shared library, and loading them runs main's module. Move what they use from it (a layer, a constant) to a module of its own.`, + }); + } + + const byFile = new Map(); + for (const item of state?.items ?? []) { + const type = ecsData(item)?.type; + if (item.readonly || (type !== 'component' && type !== 'system')) continue; + if (item.source.kind !== 'app' && item.source.kind !== 'lib') continue; + byFile.set(item.meta.source, [...(byFile.get(item.meta.source) ?? []), item]); + } + for (const [file, items] of byFile) { + if (items.length < 2) continue; + const app: AppModel | undefined = apps.find( + (candidate) => candidate.root === items[0]!.source.path, + ); + if (!app) continue; + const names = items.map((item) => item.meta.export); + suggestions.push({ + id: `ecs.split:${file}`, + app: app.name, + title: `Split ${basename(file)}`, + detail: `${list(names)} share ${file}. In files of their own, each can be moved to a shared library, renamed or removed alone, and its file says what it is.`, + fix: { + title: 'Split into one file each', + changeset: split( + `Split ${basename(file)}`, + file, + items.map((item) => ({ + name: item.meta.export, + to: target( + ecsData(item)?.type === 'system' ? app.dirs.systems : app.dirs.components, + item.meta.export, + ), + })), + ), + }, + }); + } + suggestions.push(...(await duplicateSuggestions(context, project, state))); + return suggestions; + }; + return context.contribute(PROJECT_CHECKS, { id: 'ecs.items', run }); +}; diff --git a/plugins/ecs/src/index.ts b/plugins/ecs/src/index.ts index 51f0d4c2..066c363e 100644 --- a/plugins/ecs/src/index.ts +++ b/plugins/ecs/src/index.ts @@ -21,6 +21,7 @@ import { registerIcons, } from '@nanoforge-dev/editor-sdk/ui'; +import { registerEcsChecks } from './adoption/ecs-checks'; import { LiveService, LiveServiceToken } from './live/live-service'; import { MOVE_IN_GAME_KEY, moveInGame } from './live/move-in-game'; import type { EntryModel, EntryOp } from './model/ecs-model.type'; @@ -45,6 +46,7 @@ export default definePlugin({ context.subscriptions.add(context.services.get(StyleServiceToken).inject(context.name, css)); const ecs = context.subscriptions.add(new EcsService(context)); + context.subscriptions.add(registerEcsChecks(context, ecs.catalogState)); context.provide(EcsServiceToken, ecs); const live = context.subscriptions.add(new LiveService(context, ecs)); context.provide(LiveServiceToken, live); diff --git a/plugins/ecs/src/model/ecs.const.ts b/plugins/ecs/src/model/ecs.const.ts index 80320324..6a7a9341 100644 --- a/plugins/ecs/src/model/ecs.const.ts +++ b/plugins/ecs/src/model/ecs.const.ts @@ -7,3 +7,9 @@ export const RELOCATED_TEXT_ANALYZER = 'ecs.relocated-text'; export const REWRITE_IMPORTS_TRANSFORMER = 'ecs.rewrite-imports'; export const BUNDLE_SPAWNS_ANALYZER = 'ecs.bundle-spawns'; export const ENTRY_TRANSFORMER = 'ecs.entry-file'; +/** Items an entry file declares itself (adoption). */ +export const LOCAL_ITEMS_ANALYZER = 'ecs.local-items'; +/** Moves items of a file to files of their own (adoption). */ +export const SPLIT_ITEMS_ANALYZER = 'ecs.split-items'; +/** A file's imports of another, pointed elsewhere, as text (adoption). */ +export const REDIRECTED_IMPORTS_ANALYZER = 'ecs.redirected-imports'; diff --git a/plugins/ecs/src/worker/import-lines.ts b/plugins/ecs/src/worker/import-lines.ts new file mode 100644 index 00000000..24e63e53 --- /dev/null +++ b/plugins/ecs/src/worker/import-lines.ts @@ -0,0 +1,58 @@ +import type { ImportDeclaration, Node } from 'ts-morph'; + +/** One binding an import brings: `{ a }`, `{ a as b }`, a default or a namespace. */ +export interface Binding { + readonly kind: 'named' | 'default' | 'namespace'; + /** The exported name (named bindings). */ + readonly imported?: string; + readonly local: string; + readonly typeOnly: boolean; +} + +/** Import lines in the file's quote style, grouped by module. */ +export const importLines = (needs: Map, quote: string): string[] => + [...needs] + .sort(([a], [b]) => Number(a.startsWith('.')) - Number(b.startsWith('.'))) + .map(([module, bindings]) => { + const unique = [...new Map(bindings.map((binding) => [binding.local, binding])).values()]; + const allTypes = unique.every((binding) => binding.typeOnly); + const parts: string[] = []; + const def = unique.find((binding) => binding.kind === 'default'); + const namespace = unique.find((binding) => binding.kind === 'namespace'); + const named = unique + .filter((binding) => binding.kind === 'named') + .sort((a, b) => a.local.localeCompare(b.local)) + .map( + (binding) => + `${binding.typeOnly && !allTypes ? 'type ' : ''}${binding.imported === binding.local ? binding.local : `${binding.imported} as ${binding.local}`}`, + ); + if (def) parts.push(def.local); + if (namespace) parts.push(`* as ${namespace.local}`); + if (named.length) parts.push(`{ ${named.join(', ')} }`); + return `import ${allTypes ? 'type ' : ''}${parts.join(', ')} from ${quote}${module}${quote};`; + }); + +/** The bindings an import declaration brings, with their nodes. */ +export const bindingsOf = (declaration: ImportDeclaration): (Binding & { node: Node })[] => { + const typeOnly = declaration.isTypeOnly(); + const bindings: (Binding & { node: Node })[] = []; + const def = declaration.getDefaultImport(); + if (def) bindings.push({ kind: 'default', local: def.getText(), typeOnly, node: def }); + const namespace = declaration.getNamespaceImport(); + if (namespace) + bindings.push({ kind: 'namespace', local: namespace.getText(), typeOnly, node: namespace }); + for (const specifier of declaration.getNamedImports()) { + const local = specifier.getAliasNode() ?? specifier.getNameNode(); + bindings.push({ + kind: 'named', + imported: specifier.getName(), + local: local.getText(), + typeOnly: typeOnly || specifier.isTypeOnly(), + node: local, + }); + } + return bindings; +}; + +/** Whether an import line imports a relative module. */ +export const isRelative = (line: string) => / from ["']\.|^import ["']\./.test(line); diff --git a/plugins/ecs/src/worker/index.ts b/plugins/ecs/src/worker/index.ts index 28cf9aeb..621c9faf 100644 --- a/plugins/ecs/src/worker/index.ts +++ b/plugins/ecs/src/worker/index.ts @@ -10,14 +10,24 @@ import { ENTRY_TRANSFORMER, IMPORTERS_ANALYZER, ITEM_DOCS_TRANSFORMER, + LOCAL_ITEMS_ANALYZER, + REDIRECTED_IMPORTS_ANALYZER, RELOCATED_TEXT_ANALYZER, REWRITE_IMPORTS_TRANSFORMER, + SPLIT_ITEMS_ANALYZER, } from '../model/ecs.const'; import { analyzeBundleSpawns } from './bundle-spawns'; import { analyzeEntry } from './entry-file'; import { transformItemDocs } from './item-docs'; +import { analyzeLocalItems } from './local-items'; import { ecsOwner } from './owner'; -import { analyzeImporters, analyzeRelocatedText, transformRewriteImports } from './refactor'; +import { + analyzeImporters, + analyzeRedirectedImports, + analyzeRelocatedText, + transformRewriteImports, +} from './refactor'; +import { analyzeSplitItems } from './split-items'; import { transformEntry } from './transforms'; export default defineWorkerPlugin({ @@ -30,5 +40,8 @@ export default defineWorkerPlugin({ sdk.registerAnalyzer(RELOCATED_TEXT_ANALYZER, analyzeRelocatedText); sdk.registerTransformer(REWRITE_IMPORTS_TRANSFORMER, transformRewriteImports); sdk.registerAnalyzer(BUNDLE_SPAWNS_ANALYZER, analyzeBundleSpawns); + sdk.registerAnalyzer(LOCAL_ITEMS_ANALYZER, analyzeLocalItems); + sdk.registerAnalyzer(SPLIT_ITEMS_ANALYZER, analyzeSplitItems); + sdk.registerAnalyzer(REDIRECTED_IMPORTS_ANALYZER, analyzeRedirectedImports); }, }); diff --git a/plugins/ecs/src/worker/local-items.ts b/plugins/ecs/src/worker/local-items.ts new file mode 100644 index 00000000..ce65faab --- /dev/null +++ b/plugins/ecs/src/worker/local-items.ts @@ -0,0 +1,42 @@ +import { Node, SyntaxKind } from 'ts-morph'; + +import type { AnalyzeContext } from '@nanoforge-dev/editor-sdk/worker'; + +/** Components and systems the analyzed file declares itself and uses. */ +export interface LocalItems { + readonly components: readonly string[]; + readonly systems: readonly string[]; +} + +/** + * Top-level classes of the analyzed file used as components (`addComponent(entity, new X(…))`) + * and functions used as systems (`addSystem(x)`): items defined in an entry file, which the + * catalog (component and system files only) does not list. + */ +export const analyzeLocalItems = (context: AnalyzeContext): LocalItems => { + const components = new Set(); + const systems = new Set(); + const local = (node: Node | undefined) => { + if (!node || !Node.isIdentifier(node)) return undefined; + const declaration = node.getSymbol()?.getDeclarations()[0]; + if (!declaration || declaration.getSourceFile() !== context.file) return undefined; + const top = Node.isVariableDeclaration(declaration) + ? declaration.getVariableStatement() + : declaration; + return top && Node.isSourceFile(top.getParent()) ? node.getText() : undefined; + }; + for (const call of context.file.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee)) continue; + const [first, second] = call.getArguments(); + if (callee.getName() === 'addComponent' && second && Node.isNewExpression(second)) { + const name = local(second.getExpression()); + if (name) components.add(name); + } + if (callee.getName() === 'addSystem') { + const name = local(first); + if (name) systems.add(name); + } + } + return { components: [...components].sort(), systems: [...systems].sort() }; +}; diff --git a/plugins/ecs/src/worker/refactor.ts b/plugins/ecs/src/worker/refactor.ts index cf37d28f..9cf26d58 100644 --- a/plugins/ecs/src/worker/refactor.ts +++ b/plugins/ecs/src/worker/refactor.ts @@ -3,7 +3,8 @@ import type { ExportDeclaration, ImportDeclaration, SourceFile } from 'ts-morph' import type { AnalyzeContext, TransformContext } from '@nanoforge-dev/editor-sdk/worker'; const ROOT = '/project/'; -const projectPath = (file: SourceFile) => file.getFilePath().replace(ROOT, ''); +/** A file's path in the project (the worker's files live under `/project/`). */ +export const projectPath = (file: SourceFile): string => file.getFilePath().replace(ROOT, ''); const moduleDeclarations = (file: SourceFile): (ImportDeclaration | ExportDeclaration)[] => [ ...file.getImportDeclarations(), @@ -65,3 +66,27 @@ export const transformRewriteImports = (context: TransformContext, op: unknown): context.edit.replace(specifier, `${quote}${context.moduleSpecifier(to)}${quote}`); } }; + +/** + * The analyzed file's text with its imports and re-exports of `from` (a project file) pointed at + * `specifier`: read-only, for a changeset (a component moved to a shared library). + */ +export const analyzeRedirectedImports = (context: AnalyzeContext, args: unknown): string => { + const { from, specifier } = args as { from: string; specifier: string }; + const replacements: { start: number; end: number; text: string }[] = []; + for (const declaration of moduleDeclarations(context.file)) { + const target = declaration.getModuleSpecifierSourceFile(); + const node = declaration.getModuleSpecifier(); + if (!target || !node || projectPath(target) !== from) continue; + const quote = node.getText()[0]; + replacements.push({ + start: node.getStart(), + end: node.getEnd(), + text: `${quote}${specifier}${quote}`, + }); + } + let text = context.text; + for (const replacement of replacements.sort((a, b) => b.start - a.start)) + text = text.slice(0, replacement.start) + replacement.text + text.slice(replacement.end); + return text; +}; diff --git a/plugins/ecs/src/worker/source-edits.ts b/plugins/ecs/src/worker/source-edits.ts new file mode 100644 index 00000000..dba30c46 --- /dev/null +++ b/plugins/ecs/src/worker/source-edits.ts @@ -0,0 +1,23 @@ +/** A replacement of `start`…`end` of a text. */ +export interface Edit { + readonly start: number; + readonly end: number; + readonly text: string; +} + +/** Applies edits from the end of the text. */ +export const applyEdits = (text: string, edits: readonly Edit[]) => { + let result = text; + for (const edit of [...edits].sort((a, b) => b.start - a.start)) + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + return result; +}; + +/** A removed import's range: from its start, with the line break before it (no blank line left). */ +export const removalStart = (text: string, start: number) => { + const lineBreak = text.lastIndexOf('\n', start - 1); + return lineBreak >= 0 && !text.slice(lineBreak + 1, start).trim() ? lineBreak : start; +}; + +/** At most one blank line in a row, and one line break at the end. */ +export const tidy = (text: string) => `${text.replace(/\n{3,}/g, '\n\n').trim()}\n`; diff --git a/plugins/ecs/src/worker/split-items.ts b/plugins/ecs/src/worker/split-items.ts new file mode 100644 index 00000000..b672f08a --- /dev/null +++ b/plugins/ecs/src/worker/split-items.ts @@ -0,0 +1,276 @@ +import { Node, type Statement, SyntaxKind } from 'ts-morph'; + +import { type AnalyzeContext, CodeError } from '@nanoforge-dev/editor-sdk/worker'; + +import { type Binding, bindingsOf, importLines, isRelative } from './import-lines'; +import { projectPath } from './refactor'; +import { type Edit, applyEdits, removalStart, tidy } from './source-edits'; +import { declaredNames, isExported, movedText, topStatement } from './top-statements'; + +/** + * Items to move out of the analyzed file: export name → the new file's project path. `entry`: the + * file is the app's entry, which imports the moved items: they must not import it back. + */ +export interface SplitItemsArgs { + readonly items: readonly { readonly name: string; readonly to: string }[]; + readonly entry?: boolean; +} + +/** The texts a split leaves: new files, the file itself (null: it goes) and its importers. */ +export interface SplitItemsResult { + readonly files: readonly { readonly path: string; readonly text: string }[]; + readonly source: string | null; + readonly importers: readonly { readonly path: string; readonly text: string }[]; +} + +/** + * Moves top-level items (components, systems…) of the analyzed file to files of their own: + * each takes only the imports it uses; what stays in the file and is used by a moved item is + * exported and imported; the file keeps what it still uses, and its importers import each item + * from its new file. Read-only: returns the texts. + */ +export const analyzeSplitItems = (context: AnalyzeContext, args: unknown): SplitItemsResult => { + const { items, entry = false } = args as SplitItemsArgs; + const file = context.file; + const path = context.path; + const quote = + file.getImportDeclarations()[0]?.getModuleSpecifier().getText()[0] ?? + (context.text.includes("'") && !context.text.includes('"') ? "'" : '"'); + const statements = file.getStatements(); + const targetOf = new Map(items.map((item) => [item.name, item.to])); + const moved = new Map(); + for (const { name, to } of items) { + const statement = statements.find((candidate) => declaredNames(candidate).includes(name)); + if (!statement) throw new CodeError(`${name} is not declared at the top of ${path}.`); + moved.set(statement, to); + } + /** + * Helpers (top-level declarations) used only by items moving to one same file go with them, + * as they were (not exported): the new file must not import them back from here. + */ + const companions = new Set(); + const references = (from: Statement, to: Statement) => { + const names = declaredNames(to); + return from.getDescendantsOfKind(SyntaxKind.Identifier).some((identifier) => { + if (!names.includes(identifier.getText())) return false; + const declaration = identifier.getSymbol()?.getDeclarations()[0]; + return !!declaration && topStatement(declaration) === to; + }); + }; + for (let changed = true; changed;) { + changed = false; + for (const helper of statements) { + if ( + moved.has(helper) || + isExported(helper) || + Node.isImportDeclaration(helper) || + !declaredNames(helper).length + ) + continue; + const users = statements.filter((user) => user !== helper && references(user, helper)); + const targets = new Set(users.map((user) => moved.get(user))); + if (!users.length || targets.size !== 1 || targets.has(undefined)) continue; + moved.set(helper, [...targets][0]!); + companions.add(helper); + changed = true; + } + } + const movedOf = (statement: Statement | undefined) => + statement ? moved.get(statement) : undefined; + const exportInFile = new Set(); + /** What the moved items would import back from an entry file. */ + const fromEntry = new Set(); + + /** What `node`'s identifiers refer to outside of `scope`: imports and top-level declarations. */ + const needsOf = (scope: Node, from: string, ownNames: readonly string[]) => { + const needs = new Map(); + const need = (module: string, binding: Binding) => + needs.set(module, [...(needs.get(module) ?? []), binding]); + for (const identifier of scope.getDescendantsOfKind(SyntaxKind.Identifier)) { + const declaration = identifier.getSymbol()?.getDeclarations()[0]; + if (!declaration || declaration.getSourceFile() !== file) continue; + if (scope.containsRange(declaration.getPos(), declaration.getEnd())) continue; + const importDeclaration = declaration.getFirstAncestorByKind(SyntaxKind.ImportDeclaration); + if (importDeclaration) { + const binding = bindingsOf(importDeclaration).find( + (candidate) => candidate.local === identifier.getText(), + ); + if (!binding) continue; + const target = importDeclaration.getModuleSpecifierSourceFile(); + const value = importDeclaration.getModuleSpecifierValue(); + const module = + value.startsWith('.') && target + ? context.moduleSpecifier(projectPath(target), from) + : value; + need(module, binding); + continue; + } + const statement = topStatement(declaration); + if (!statement || !declaredNames(statement).includes(identifier.getText())) continue; + if (ownNames.includes(identifier.getText())) continue; + const to = movedOf(statement); + if (to) { + if (to !== from) + need(context.moduleSpecifier(to, from), { + kind: 'named', + imported: identifier.getText(), + local: identifier.getText(), + typeOnly: false, + }); + continue; + } + if (entry) fromEntry.add(identifier.getText()); + if (!isExported(statement)) exportInFile.add(statement); + need(context.moduleSpecifier(path, from), { + kind: 'named', + imported: identifier.getText(), + local: identifier.getText(), + typeOnly: false, + }); + } + return needs; + }; + + /** Imports without bindings (`import "./register"`): each new file keeps them. */ + const sideEffects = file + .getImportDeclarations() + .filter((declaration) => !bindingsOf(declaration).length && !declaration.isTypeOnly()); + const sideEffectLines = (to: string) => + sideEffects.map((declaration) => { + const value = declaration.getModuleSpecifierValue(); + if (!value.startsWith('.')) return `import ${quote}${value}${quote};`; + const target = declaration.getModuleSpecifierSourceFile(); + if (!target) + throw new CodeError( + `${path} imports ${value} for its effects, and the moved items need it too: move them by hand.`, + ); + return `import ${quote}${context.moduleSpecifier(projectPath(target), to)}${quote};`; + }); + + const byTarget = new Map(); + for (const [statement, to] of [...moved].sort(([a], [b]) => a.getPos() - b.getPos())) + byTarget.set(to, [...(byTarget.get(to) ?? []), statement]); + const files = [...byTarget].map(([to, group]) => { + const own = group.flatMap(declaredNames); + const needs = new Map(); + for (const statement of group) + for (const [module, bindings] of needsOf(statement, to, own)) + needs.set(module, [...(needs.get(module) ?? []), ...bindings]); + const lines = [...sideEffectLines(to), ...importLines(needs, quote)].sort( + (a, b) => Number(isRelative(a)) - Number(isRelative(b)), + ); + const firstRelative = lines.findIndex(isRelative); + const imports = + firstRelative > 0 + ? [...lines.slice(0, firstRelative), '', ...lines.slice(firstRelative)] + : lines; + const body = group + .map((statement) => movedText(statement, !companions.has(statement))) + .join('\n\n'); + return { path: to, text: tidy(`${imports.join('\n')}${imports.length ? '\n\n' : ''}${body}`) }; + }); + + if (fromEntry.size) + throw new CodeError( + `The moved items use ${[...fromEntry].sort().join(', ')} of ${path}, which imports them: move ${fromEntry.size === 1 ? 'it' : 'them'} out of ${path} first (to a module of ${fromEntry.size === 1 ? 'its' : 'their'} own).`, + ); + + /** The file without the moved items. */ + const edits: Edit[] = []; + for (const statement of moved.keys()) + edits.push({ start: statement.getFullStart(), end: statement.getEnd(), text: '' }); + for (const statement of exportInFile) + edits.push({ start: statement.getStart(), end: statement.getStart(), text: 'export ' }); + const kept = statements.filter( + (statement) => !moved.has(statement) && !Node.isImportDeclaration(statement), + ); + const usedOutsideMoved = (node: Node) => + kept.some((statement) => + statement + .getDescendantsOfKind(SyntaxKind.Identifier) + .some( + (identifier) => + identifier.getText() === node.getText() && identifier.getSymbol() === node.getSymbol(), + ), + ); + for (const declaration of file.getImportDeclarations()) { + const bindings = bindingsOf(declaration); + if (!bindings.length) continue; + const used = bindings.filter((binding) => usedOutsideMoved(binding.node)); + if (used.length === bindings.length) continue; + edits.push({ + start: used.length + ? declaration.getStart() + : removalStart(context.text, declaration.getStart()), + end: declaration.getEnd(), + text: used.length + ? importLines(new Map([[declaration.getModuleSpecifierValue(), used]]), quote)[0]! + : '', + }); + } + const added = new Map(); + for (const [statement, to] of moved) + for (const name of declaredNames(statement)) { + const node = + Node.isClassDeclaration(statement) || Node.isFunctionDeclaration(statement) + ? statement.getNameNode() + : statement.getFirstDescendantByKind(SyntaxKind.Identifier); + if (node && usedOutsideMoved(node)) + added.set(context.moduleSpecifier(to, path), [ + ...(added.get(context.moduleSpecifier(to, path)) ?? []), + { kind: 'named', imported: name, local: name, typeOnly: false }, + ]); + } + if (added.size) { + const last = file.getImportDeclarations().at(-1); + const lines = importLines(added, quote).join('\n'); + edits.push( + last + ? { start: last.getEnd(), end: last.getEnd(), text: `\n${lines}` } + : { start: 0, end: 0, text: `${lines}\n\n` }, + ); + } + const source = kept.length ? tidy(applyEdits(context.text, edits)) : null; + + /** Files importing a moved item from this one. */ + const importers: { path: string; text: string }[] = []; + for (const other of context.project.getSourceFiles()) { + const otherPath = projectPath(other); + if (other === file || otherPath.startsWith('node_modules/')) continue; + const otherEdits: Edit[] = []; + for (const declaration of other.getImportDeclarations()) { + if (declaration.getModuleSpecifierSourceFile() !== file) continue; + const bindings = bindingsOf(declaration); + if (bindings.some((binding) => binding.kind !== 'named')) + throw new CodeError( + `${otherPath} imports ${path} as a whole: change it to import names, then try again.`, + ); + const leaving = bindings.filter((binding) => targetOf.has(binding.imported!)); + if (!leaving.length) continue; + const staying = bindings.filter((binding) => !targetOf.has(binding.imported!)); + const needs = new Map(); + if (staying.length) needs.set(declaration.getModuleSpecifierValue(), staying); + for (const binding of leaving) { + const module = context.moduleSpecifier(targetOf.get(binding.imported!)!, otherPath); + needs.set(module, [...(needs.get(module) ?? []), binding]); + } + const otherQuote = declaration.getModuleSpecifier().getText()[0] ?? quote; + otherEdits.push({ + start: declaration.getStart(), + end: declaration.getEnd(), + text: importLines(needs, otherQuote).join('\n'), + }); + } + for (const declaration of other.getExportDeclarations()) + if ( + declaration.getModuleSpecifierSourceFile() === file && + declaration.getNamedExports().some((specifier) => targetOf.has(specifier.getName())) + ) + throw new CodeError( + `${otherPath} re-exports items of ${path}: move those exports by hand, then try again.`, + ); + if (otherEdits.length) + importers.push({ path: otherPath, text: applyEdits(other.getFullText(), otherEdits) }); + } + return { files, source, importers }; +}; diff --git a/plugins/ecs/src/worker/top-statements.ts b/plugins/ecs/src/worker/top-statements.ts new file mode 100644 index 00000000..5e7e9286 --- /dev/null +++ b/plugins/ecs/src/worker/top-statements.ts @@ -0,0 +1,33 @@ +import { Node, type Statement } from 'ts-morph'; + +/** The top-level statement a node belongs to. */ +export const topStatement = (node: Node): Statement | undefined => { + let current: Node | undefined = node; + while (current && !Node.isSourceFile(current.getParent())) current = current.getParent(); + return current as Statement | undefined; +}; + +/** The names a top-level statement declares. */ +export const declaredNames = (statement: Statement): string[] => { + if (Node.isClassDeclaration(statement) || Node.isFunctionDeclaration(statement)) + return statement.getName() ? [statement.getName()!] : []; + if (Node.isVariableStatement(statement)) + return statement.getDeclarations().map((declaration) => declaration.getName()); + if (Node.isInterfaceDeclaration(statement) || Node.isTypeAliasDeclaration(statement)) + return [statement.getName()]; + if (Node.isEnumDeclaration(statement)) return [statement.getName()]; + return []; +}; + +/** Whether a top-level statement is exported. */ +export const isExported = (statement: Statement) => + Node.isExportable(statement) && statement.isExported(); + +/** The statement's text with its doc comment, exported, without the blank lines before it. */ +export const movedText = (statement: Statement, exported = true) => { + const full = statement.getFullText(); + const at = statement.getStart() - statement.getFullStart(); + const text = + !exported || isExported(statement) ? full : `${full.slice(0, at)}export ${full.slice(at)}`; + return text.replace(/^(?:[ \t]*\r?\n)+/, '').trimEnd(); +}; diff --git a/plugins/ecs/test/adoption/duplicates.spec.ts b/plugins/ecs/test/adoption/duplicates.spec.ts new file mode 100644 index 00000000..cff0e104 --- /dev/null +++ b/plugins/ecs/test/adoption/duplicates.spec.ts @@ -0,0 +1,197 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import type { CatalogState, ClientProject, PluginContext } from '@nanoforge-dev/editor-sdk'; +import { CodeServiceToken, DocumentServiceToken } from '@nanoforge-dev/editor-sdk'; + +import { duplicateSuggestions } from '../../src/adoption/duplicate-checks'; +import { + IMPORTERS_ANALYZER, + REDIRECTED_IMPORTS_ANALYZER, + RELOCATED_TEXT_ANALYZER, +} from '../../src/model/ecs.const'; +import { + analyzeImporters, + analyzeRedirectedImports, + analyzeRelocatedText, +} from '../../src/worker/refactor'; +import { FIXTURES, engineOf, expectTree, readTree } from '../fixtures/adoption/golden'; + +const app = (side: 'client' | 'server') => ({ + id: `apps/${side}`, + name: `pong-${side}`, + type: side, + root: `apps/${side}`, + language: 'ts', + dirs: { + assets: `apps/${side}/assets`, + components: `apps/${side}/src/components`, + systems: `apps/${side}/src/systems`, + scenes: `apps/${side}/src/scenes`, + }, + entryFile: `apps/${side}/src/main.ts`, + engineLibs: {}, + libraries: [], +}); +const position = (side: string) => ({ + ref: `app:${side}#Position`, + source: { kind: 'app', name: `pong-${side}`, path: `apps/${side}` }, + meta: { + export: 'Position', + source: `apps/${side}/src/components/position.ts`, + '@nanoforge/ecs': { type: 'component' }, + }, + readonly: false, +}); + +/** A plugin context and an open project over the fixture's files, the analyzers in a real engine. */ +const setup = (files: Map, name = 'pong') => { + const engine = engineOf(files); + engine.registerAnalyzer(RELOCATED_TEXT_ANALYZER, analyzeRelocatedText); + engine.registerAnalyzer(IMPORTERS_ANALYZER, analyzeImporters); + engine.registerAnalyzer(REDIRECTED_IMPORTS_ANALYZER, analyzeRedirectedImports); + const services = new Map([ + [ + CodeServiceToken, + { + analyze: async (path: string, id: string, args?: unknown) => engine.analyze(path, id, args), + }, + ], + [DocumentServiceToken, { getText: async (path: string) => files.get(path)! }], + ]); + const context = { + services: { tryGet: (token: unknown) => services.get(token) }, + } as unknown as PluginContext; + const folders = new Set( + [...files.keys()].flatMap((path) => + path + .split('/') + .slice(0, -1) + .map((_, i, parts) => parts.slice(0, i + 1).join('/')), + ), + ); + const entries = [ + ...[...files.keys()].map((path) => ({ path, kind: 'file' })), + ...[...folders].map((path) => ({ path, kind: 'folder' })), + ]; + const model = { + id: 'p', + name, + location: '/pong', + apps: [app('client'), app('server')], + diagnostics: [], + }; + const project = { + model: { get: () => model }, + fs: { + entries, + entry: (path: string) => entries.find((entry) => entry.path === path), + readText: async (path: string) => ({ text: files.get(path)! }), + }, + } as unknown as ClientProject; + return { context, project }; +}; + +describe('client and server copies of a component', () => { + it('moves identical copies to a new shared library both apps use', async () => { + const files = readTree(join(FIXTURES, 'duplicates', 'input')); + const { context, project } = setup(files); + const state = { + items: [position('client'), position('server')], + diagnostics: [], + loading: false, + } as unknown as CatalogState; + const [suggestion] = await duplicateSuggestions(context, project, state); + expect(suggestion).toMatchObject({ id: 'ecs.duplicate:Position', title: 'Share Position' }); + const changeset = await suggestion!.fix!.changeset(); + for (const file of changeset.files) { + if (file.after === undefined) files.delete(file.path); + else files.set(file.path, file.after); + } + expectTree('duplicates', files); + }); + + it('says why when the copies differ', async () => { + const files = readTree(join(FIXTURES, 'duplicates', 'input')); + files.set( + 'apps/server/src/components/position.ts', + 'export class Position {\n name = "Position";\n z = 0;\n}\n', + ); + const { context, project } = setup(files); + const state = { + items: [position('client'), position('server')], + diagnostics: [], + loading: false, + } as unknown as CatalogState; + const [suggestion] = await duplicateSuggestions(context, project, state); + expect(suggestion?.fix).toBeUndefined(); + expect(suggestion?.detail).toContain('The copies differ'); + }); + + const STATE = { + items: [position('client'), position('server')], + diagnostics: [], + loading: false, + } as unknown as CatalogState; + const CLIENT = 'apps/client/src/components/position.ts'; + const SERVER = 'apps/server/src/components/position.ts'; + + it('tells strings apart that differ only by their spaces', async () => { + const files = readTree(join(FIXTURES, 'duplicates', 'input')); + files.set(CLIENT, 'export class Position {\n name = "Big ball";\n}\n'); + files.set(SERVER, "export class Position {\n name = 'Bigball';\n}\n"); + const { context, project } = setup(files); + const [suggestion] = await duplicateSuggestions(context, project, STATE); + expect(suggestion?.fix).toBeUndefined(); + expect(suggestion?.detail).toContain('The copies differ'); + }); + + it('tells apart copies that import different modules', async () => { + const files = readTree(join(FIXTURES, 'duplicates', 'input')); + files.set( + CLIENT, + 'import { Vector2 } from "@nanoforge-dev/graphics-2d";\n\nexport class Position {\n at = new Vector2();\n}\n', + ); + files.set( + SERVER, + 'import { Vector2 } from "../math/vector2";\n\nexport class Position {\n at = new Vector2();\n}\n', + ); + files.set('apps/server/src/math/vector2.ts', 'export class Vector2 {}\n'); + const { context, project } = setup(files); + const [suggestion] = await duplicateSuggestions(context, project, STATE); + expect(suggestion?.fix).toBeUndefined(); + expect(suggestion?.detail).toContain('The copies differ'); + }); + + it("refuses copies that import an app's file", async () => { + const files = readTree(join(FIXTURES, 'duplicates', 'input')); + files.set('apps/client/src/size.ts', 'export const SIZE = 2;\n'); + files.set( + CLIENT, + 'import { SIZE } from "../size";\n\nexport class Position {\n size = SIZE;\n}\n', + ); + files.set( + SERVER, + 'import { SIZE } from "../../../client/src/size";\n\nexport class Position {\n size = SIZE;\n}\n', + ); + const { context, project } = setup(files); + const [suggestion] = await duplicateSuggestions(context, project, STATE); + await expect(suggestion!.fix!.changeset()).rejects.toThrow( + /imports apps\/client\/src\/size.ts, and a shared library can't import an app/, + ); + }); + + it("names the new library after the project's scope, on a free name", async () => { + const files = readTree(join(FIXTURES, 'duplicates', 'input')); + files.set('nf_modules/@acme/shared/nanoforge.manifest.json', '{}\n'); + const { context, project } = setup(files, '@acme/pong'); + const [suggestion] = await duplicateSuggestions(context, project, STATE); + const changeset = await suggestion!.fix!.changeset(); + const library = changeset.files.find((file) => /^libs\/[^/]+\/package\.json$/.test(file.path)); + expect(library?.path).toBe('libs/shared-2/package.json'); + expect(JSON.parse(library!.after!)).toMatchObject({ name: '@acme/shared-2' }); + expect(changeset.files.map((file) => file.path)).toContain( + 'libs/shared-2/src/components/position.ts', + ); + }); +}); diff --git a/plugins/ecs/test/adoption/split-items.spec.ts b/plugins/ecs/test/adoption/split-items.spec.ts new file mode 100644 index 00000000..5b36ad7e --- /dev/null +++ b/plugins/ecs/test/adoption/split-items.spec.ts @@ -0,0 +1,160 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { analyzeLocalItems } from '../../src/worker/local-items'; +import { type SplitItemsResult, analyzeSplitItems } from '../../src/worker/split-items'; +import { FIXTURES, engineOf, expectTree, readTree } from '../fixtures/adoption/golden'; + +const SPLIT = 'adoption.split'; + +/** Runs a split on a tree and returns the tree it leaves. */ +const splitFiles = ( + files: Map, + file: string, + items: { name: string; to: string }[], + entry = false, +) => { + const engine = engineOf(files); + engine.registerAnalyzer(SPLIT, analyzeSplitItems); + const result = engine.analyze(file, SPLIT, { items, entry }) as SplitItemsResult; + for (const { path, text } of [...result.files, ...result.importers]) files.set(path, text); + if (result.source === null) files.delete(file); + else files.set(file, result.source); + return files; +}; + +/** Runs a split on the fixture's input and returns the tree it leaves. */ +const split = ( + fixture: string, + file: string, + items: { name: string; to: string }[], + entry = false, +) => splitFiles(readTree(join(FIXTURES, fixture, 'input')), file, items, entry); + +const DIR = 'apps/client/src/components'; +const COMPONENTS = `${DIR}/components.ts`; + +describe('splitting items into files of their own', () => { + it('splits a file of several components, each with only the imports it uses', () => { + const dir = 'apps/client/src/components'; + expectTree( + 'split', + split('split', `${dir}/components.ts`, [ + { name: 'Position', to: `${dir}/position.ts` }, + { name: 'Velocity', to: `${dir}/velocity.ts` }, + { name: 'CircleComponent', to: `${dir}/circle-component.ts` }, + ]), + ); + }); + + it('extracts a component and a system defined in main.ts', () => { + expectTree( + 'extract', + split( + 'split', + 'apps/client/src/main.ts', + [ + { name: 'Gravity', to: 'apps/client/src/components/gravity.ts' }, + { name: 'fall', to: 'apps/client/src/systems/fall.ts' }, + ], + true, + ), + ); + }); + + it('finds the items an entry file declares and uses, not the ones it imports', () => { + const engine = engineOf(readTree(join(FIXTURES, 'split', 'input'))); + engine.registerAnalyzer('local', analyzeLocalItems); + expect(engine.analyze('apps/client/src/main.ts', 'local')).toEqual({ + components: ['Gravity'], + systems: ['fall'], + }); + }); + + it('leaves an exported helper where it is: other files import it', () => { + const files = splitFiles( + new Map([ + [ + COMPONENTS, + 'export const clamp = (value: number) => Math.max(0, value);\n\nexport class Position {\n x = 0;\n}\n\nexport class Velocity {\n x = clamp(1);\n}\n', + ], + [ + 'apps/client/src/systems/move.ts', + 'import { clamp } from "../components/components";\n\nexport const move = () => clamp(2);\n', + ], + ]), + COMPONENTS, + [ + { name: 'Position', to: `${DIR}/position.ts` }, + { name: 'Velocity', to: `${DIR}/velocity.ts` }, + ], + ); + expect(files.get(COMPONENTS)).toBe( + 'export const clamp = (value: number) => Math.max(0, value);\n', + ); + expect(files.get(`${DIR}/velocity.ts`)).toBe( + 'import { clamp } from "./components";\n\nexport class Velocity {\n x = clamp(1);\n}\n', + ); + expect(files.get('apps/client/src/systems/move.ts')).toBe( + 'import { clamp } from "../components/components";\n\nexport const move = () => clamp(2);\n', + ); + }); + + it("keeps the file's header comment and the comments of the imports it prunes", () => { + const files = splitFiles( + new Map([ + [ + COMPONENTS, + '/** Shapes of the game. */\n\n// @ts-expect-error untyped\nimport { a, b } from "./shapes";\nimport { c } from "./colors";\n\nexport class Position {\n x = a + c;\n}\n\nexport class Velocity {\n x = b;\n}\n\nexport const keep = a;\n', + ], + [`${DIR}/shapes.ts`, 'export const a = 1;\nexport const b = 2;\n'], + [`${DIR}/colors.ts`, 'export const c = 3;\n'], + ]), + COMPONENTS, + [ + { name: 'Position', to: `${DIR}/position.ts` }, + { name: 'Velocity', to: `${DIR}/velocity.ts` }, + ], + ); + expect(files.get(COMPONENTS)).toBe( + '/** Shapes of the game. */\n\n// @ts-expect-error untyped\nimport { a } from "./shapes";\n\nexport const keep = a;\n', + ); + }); + + it('copies imports without bindings into each new file', () => { + const files = splitFiles( + new Map([ + [ + COMPONENTS, + 'import "@acme/polyfill";\nimport "./register";\n\nexport class Position {\n x = 0;\n}\n\nexport class Velocity {\n x = 0;\n}\n', + ], + [`${DIR}/register.ts`, 'export {};\n'], + ]), + COMPONENTS, + [ + { name: 'Position', to: `${DIR}/position.ts` }, + { name: 'Velocity', to: `apps/client/src/physics/velocity.ts` }, + ], + ); + expect(files.has(COMPONENTS)).toBe(false); + expect(files.get(`${DIR}/position.ts`)).toBe( + 'import "@acme/polyfill";\n\nimport "./register";\n\nexport class Position {\n x = 0;\n}\n', + ); + expect(files.get('apps/client/src/physics/velocity.ts')).toContain( + 'import "../components/register";', + ); + }); + + it('refuses to make items moved out of the entry file import it back', () => { + const main = + 'import { Layer } from "@nanoforge-dev/graphics-2d";\n\nexport const layer = new Layer();\n\nexport class Sprite {\n constructor() {\n layer.add(this);\n }\n}\n\nexport const main = () => new Sprite();\n'; + expect(() => + splitFiles( + new Map([['apps/client/src/main.ts', main]]), + 'apps/client/src/main.ts', + [{ name: 'Sprite', to: `${DIR}/sprite.ts` }], + true, + ), + ).toThrow(/use layer of apps\/client\/src\/main.ts, which imports them/); + }); +}); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/nanoforge.config.ts b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/nanoforge.config.ts new file mode 100644 index 00000000..5690555c --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/nanoforge.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "client", + libs: ["../../libs/shared"], +}); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/package.json b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/package.json new file mode 100644 index 00000000..c2d93895 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/package.json @@ -0,0 +1,8 @@ +{ + "name": "pong-client", + "private": true, + "type": "module", + "dependencies": { + "@pong/shared": "workspace:*" + } +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/src/main.ts b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/src/main.ts new file mode 100644 index 00000000..e7379781 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/client/src/main.ts @@ -0,0 +1,3 @@ +import { Position } from "@pong/shared/components/position"; + +export const main = () => new Position(1, 2); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/nanoforge.config.ts b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/nanoforge.config.ts new file mode 100644 index 00000000..51ad161d --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/nanoforge.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "server", + libs: ["../../libs/shared"], +}); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/package.json b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/package.json new file mode 100644 index 00000000..c4bea485 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/package.json @@ -0,0 +1,8 @@ +{ + "name": "pong-server", + "private": true, + "type": "module", + "dependencies": { + "@pong/shared": "workspace:*" + } +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/src/main.ts b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/src/main.ts new file mode 100644 index 00000000..e7379781 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/apps/server/src/main.ts @@ -0,0 +1,3 @@ +import { Position } from "@pong/shared/components/position"; + +export const main = () => new Position(1, 2); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/nanoforge.config.ts b/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/nanoforge.config.ts new file mode 100644 index 00000000..297a34cf --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/nanoforge.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "lib", + dir: { components: "src/components", systems: "src/systems", scenes: "src/scenes" }, +}); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/package.json b/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/package.json new file mode 100644 index 00000000..4cea430f --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/package.json @@ -0,0 +1,6 @@ +{ + "name": "@pong/shared", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/src/components/.gitkeep b/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/src/components/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/src/components/position.ts b/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/src/components/position.ts new file mode 100644 index 00000000..712f1279 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/src/components/position.ts @@ -0,0 +1,8 @@ +/** Where an entity is. */ +export class Position { + name = "Position"; + constructor( + public x: number, + public y: number, + ) {} +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/src/systems/.gitkeep b/plugins/ecs/test/fixtures/adoption/duplicates/expected/libs/shared/src/systems/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/nanoforge.config.ts b/plugins/ecs/test/fixtures/adoption/duplicates/expected/nanoforge.config.ts new file mode 100644 index 00000000..6f9f2854 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/nanoforge.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "workspace", + packages: ["apps/*", "libs/*"], +}); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/package.json b/plugins/ecs/test/fixtures/adoption/duplicates/expected/package.json new file mode 100644 index 00000000..0143299f --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/package.json @@ -0,0 +1,5 @@ +{ + "name": "pong", + "private": true, + "workspaces": ["apps/*"] +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/expected/tsconfig.json b/plugins/ecs/test/fixtures/adoption/duplicates/expected/tsconfig.json new file mode 100644 index 00000000..f9f9d222 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/expected/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "paths": { "@pong/shared/*": ["./libs/shared/src/*"] }, + "strict": true + } +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/nanoforge.config.ts b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/nanoforge.config.ts new file mode 100644 index 00000000..77607cbd --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/nanoforge.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "client", +}); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/package.json b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/package.json new file mode 100644 index 00000000..467daa99 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/package.json @@ -0,0 +1,5 @@ +{ + "name": "pong-client", + "private": true, + "type": "module" +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/src/components/position.ts b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/src/components/position.ts new file mode 100644 index 00000000..712f1279 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/src/components/position.ts @@ -0,0 +1,8 @@ +/** Where an entity is. */ +export class Position { + name = "Position"; + constructor( + public x: number, + public y: number, + ) {} +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/src/main.ts b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/src/main.ts new file mode 100644 index 00000000..2be5e10c --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/client/src/main.ts @@ -0,0 +1,3 @@ +import { Position } from "./components/position"; + +export const main = () => new Position(1, 2); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/nanoforge.config.ts b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/nanoforge.config.ts new file mode 100644 index 00000000..43a3fef2 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/nanoforge.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "server", +}); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/package.json b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/package.json new file mode 100644 index 00000000..9591810e --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/package.json @@ -0,0 +1,5 @@ +{ + "name": "pong-server", + "private": true, + "type": "module" +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/src/components/position.ts b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/src/components/position.ts new file mode 100644 index 00000000..7351edb6 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/src/components/position.ts @@ -0,0 +1,5 @@ +/** Where an entity is. */ +export class Position { + name = "Position"; + constructor(public x: number, public y: number) {} +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/src/main.ts b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/src/main.ts new file mode 100644 index 00000000..2be5e10c --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/apps/server/src/main.ts @@ -0,0 +1,3 @@ +import { Position } from "./components/position"; + +export const main = () => new Position(1, 2); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/nanoforge.config.ts b/plugins/ecs/test/fixtures/adoption/duplicates/input/nanoforge.config.ts new file mode 100644 index 00000000..99f24783 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/nanoforge.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "@nanoforge-dev/config"; + +export default defineConfig({ + type: "workspace", + packages: ["apps/*"], +}); diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/package.json b/plugins/ecs/test/fixtures/adoption/duplicates/input/package.json new file mode 100644 index 00000000..0143299f --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/package.json @@ -0,0 +1,5 @@ +{ + "name": "pong", + "private": true, + "workspaces": ["apps/*"] +} diff --git a/plugins/ecs/test/fixtures/adoption/duplicates/input/tsconfig.json b/plugins/ecs/test/fixtures/adoption/duplicates/input/tsconfig.json new file mode 100644 index 00000000..aee0ec94 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/duplicates/input/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "strict": true + } +} diff --git a/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/components/components.ts b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/components/components.ts new file mode 100644 index 00000000..76ece75f --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/components/components.ts @@ -0,0 +1,27 @@ +import type { Circle } from "@nanoforge-dev/graphics-2d"; + +import { layer } from "../main"; + +/** Where an entity is. */ +export class Position { + name = "Position"; + constructor( + public x: number, + public y: number, + ) {} +} + +export class Velocity { + name = "Velocity"; + constructor( + public x: number, + public y: number, + ) {} +} + +export class CircleComponent { + name = "CircleComponent"; + constructor(public component: Circle) { + layer.add(component); + } +} diff --git a/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/components/gravity.ts b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/components/gravity.ts new file mode 100644 index 00000000..08df2e31 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/components/gravity.ts @@ -0,0 +1,5 @@ +/** How fast things fall, in pixels per tick. */ +export class Gravity { + name = "Gravity"; + constructor(public strength: number) {} +} diff --git a/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/main.ts b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/main.ts new file mode 100644 index 00000000..da51cfc0 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/main.ts @@ -0,0 +1,24 @@ +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Layer } from "@nanoforge-dev/graphics-2d"; + +import { CircleComponent, Position, Velocity } from "./components/components"; +import { move } from "./systems/systems"; +import { Gravity } from "./components/gravity"; +import { fall } from "./systems/fall"; + +export const layer = new Layer(); + +export const main = async () => { + const app = NanoforgeFactory.createClient({ tickRate: 60 }); + const ecs = new EcsLibrary(); + app.use(ecs); + const registry = ecs.registry; + const ball = registry.spawnEntity(); + registry.addComponent(ball, new Position(0, 0)); + registry.addComponent(ball, new Velocity(1, 1)); + registry.addComponent(ball, new Gravity(2)); + registry.addComponent(ball, new CircleComponent(undefined as never)); + registry.addSystem(move); + registry.addSystem(fall); +}; diff --git a/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/systems/fall.ts b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/systems/fall.ts new file mode 100644 index 00000000..a4d2afe1 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/systems/fall.ts @@ -0,0 +1,10 @@ +import { Velocity } from "../components/components"; +import { Gravity } from "../components/gravity"; + +const clamp = (value: number) => Math.max(0, value); + +export function fall(registry: { getZipper: (components: unknown[]) => { Velocity: Velocity; Gravity: Gravity }[] }) { + for (const { Velocity: velocity, Gravity: gravity } of registry.getZipper([Velocity, Gravity])) { + velocity.y = clamp(velocity.y + gravity.strength); + } +} diff --git a/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/systems/systems.ts b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/systems/systems.ts new file mode 100644 index 00000000..f759b94f --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/src/systems/systems.ts @@ -0,0 +1,9 @@ +import type { Registry } from "@nanoforge-dev/ecs"; + +import { Position, Velocity } from "../components/components"; + +export const move = (registry: Registry) => { + for (const { Position: position, Velocity: velocity } of registry.getZipper([Position, Velocity])) { + position.x += velocity.x; + } +}; diff --git a/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/tsconfig.json b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/tsconfig.json new file mode 100644 index 00000000..60c6acdc --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/extract/expected/apps/client/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true, "module": "esnext", "moduleResolution": "bundler" } } diff --git a/plugins/ecs/test/fixtures/adoption/golden.ts b/plugins/ecs/test/fixtures/adoption/golden.ts new file mode 100644 index 00000000..bfa1b027 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/golden.ts @@ -0,0 +1,45 @@ +import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { expect } from 'vitest'; + +import { CodeEngine } from '@nanoforge-dev/editor-code/engine'; + +export const FIXTURES = import.meta.dirname; + +/** Every file of a folder, by path relative to it. */ +export const readTree = (root: string): Map => { + const files = new Map(); + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else files.set(relative(root, path), readFileSync(path, 'utf8')); + } + }; + walk(root); + return files; +}; + +/** A code engine on a fixture's `input` tree. */ +export const engineOf = (files: Map): CodeEngine => { + const engine = new CodeEngine(); + engine.setFiles([...files].map(([path, text]) => ({ path, text }))); + return engine; +}; + +/** + * Compares a tree with the fixture's `expected` folder; `UPDATE_GOLDEN=1` writes it instead + * (then read the diff before committing it). + */ +export const expectTree = (fixture: string, files: Map): void => { + const root = join(FIXTURES, fixture, 'expected'); + if (process.env.UPDATE_GOLDEN) { + rmSync(root, { recursive: true, force: true }); + for (const [path, text] of files) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + } + return; + } + expect(Object.fromEntries([...files].sort())).toEqual(Object.fromEntries([...readTree(root)].sort())); +}; diff --git a/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/circle-component.ts b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/circle-component.ts new file mode 100644 index 00000000..5215d5f8 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/circle-component.ts @@ -0,0 +1,10 @@ +import type { Circle } from "@nanoforge-dev/graphics-2d"; + +import { layer } from "../main"; + +export class CircleComponent { + name = "CircleComponent"; + constructor(public component: Circle) { + layer.add(component); + } +} diff --git a/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/position.ts b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/position.ts new file mode 100644 index 00000000..712f1279 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/position.ts @@ -0,0 +1,8 @@ +/** Where an entity is. */ +export class Position { + name = "Position"; + constructor( + public x: number, + public y: number, + ) {} +} diff --git a/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/velocity.ts b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/velocity.ts new file mode 100644 index 00000000..9dff16a5 --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/components/velocity.ts @@ -0,0 +1,7 @@ +export class Velocity { + name = "Velocity"; + constructor( + public x: number, + public y: number, + ) {} +} diff --git a/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/main.ts b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/main.ts new file mode 100644 index 00000000..45374ecd --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/main.ts @@ -0,0 +1,38 @@ +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Layer } from "@nanoforge-dev/graphics-2d"; + +import { CircleComponent } from "./components/circle-component"; +import { Position } from "./components/position"; +import { Velocity } from "./components/velocity"; +import { move } from "./systems/systems"; + +export const layer = new Layer(); + +/** How fast things fall, in pixels per tick. */ +export class Gravity { + name = "Gravity"; + constructor(public strength: number) {} +} + +const clamp = (value: number) => Math.max(0, value); + +export function fall(registry: { getZipper: (components: unknown[]) => { Velocity: Velocity; Gravity: Gravity }[] }) { + for (const { Velocity: velocity, Gravity: gravity } of registry.getZipper([Velocity, Gravity])) { + velocity.y = clamp(velocity.y + gravity.strength); + } +} + +export const main = async () => { + const app = NanoforgeFactory.createClient({ tickRate: 60 }); + const ecs = new EcsLibrary(); + app.use(ecs); + const registry = ecs.registry; + const ball = registry.spawnEntity(); + registry.addComponent(ball, new Position(0, 0)); + registry.addComponent(ball, new Velocity(1, 1)); + registry.addComponent(ball, new Gravity(2)); + registry.addComponent(ball, new CircleComponent(undefined as never)); + registry.addSystem(move); + registry.addSystem(fall); +}; diff --git a/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/systems/systems.ts b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/systems/systems.ts new file mode 100644 index 00000000..31c6277a --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/src/systems/systems.ts @@ -0,0 +1,10 @@ +import type { Registry } from "@nanoforge-dev/ecs"; + +import { Position } from "../components/position"; +import { Velocity } from "../components/velocity"; + +export const move = (registry: Registry) => { + for (const { Position: position, Velocity: velocity } of registry.getZipper([Position, Velocity])) { + position.x += velocity.x; + } +}; diff --git a/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/tsconfig.json b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/tsconfig.json new file mode 100644 index 00000000..60c6acdc --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/expected/apps/client/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true, "module": "esnext", "moduleResolution": "bundler" } } diff --git a/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/components/components.ts b/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/components/components.ts new file mode 100644 index 00000000..76ece75f --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/components/components.ts @@ -0,0 +1,27 @@ +import type { Circle } from "@nanoforge-dev/graphics-2d"; + +import { layer } from "../main"; + +/** Where an entity is. */ +export class Position { + name = "Position"; + constructor( + public x: number, + public y: number, + ) {} +} + +export class Velocity { + name = "Velocity"; + constructor( + public x: number, + public y: number, + ) {} +} + +export class CircleComponent { + name = "CircleComponent"; + constructor(public component: Circle) { + layer.add(component); + } +} diff --git a/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/main.ts b/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/main.ts new file mode 100644 index 00000000..9b98980d --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/main.ts @@ -0,0 +1,36 @@ +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Layer } from "@nanoforge-dev/graphics-2d"; + +import { CircleComponent, Position, Velocity } from "./components/components"; +import { move } from "./systems/systems"; + +export const layer = new Layer(); + +/** How fast things fall, in pixels per tick. */ +export class Gravity { + name = "Gravity"; + constructor(public strength: number) {} +} + +const clamp = (value: number) => Math.max(0, value); + +export function fall(registry: { getZipper: (components: unknown[]) => { Velocity: Velocity; Gravity: Gravity }[] }) { + for (const { Velocity: velocity, Gravity: gravity } of registry.getZipper([Velocity, Gravity])) { + velocity.y = clamp(velocity.y + gravity.strength); + } +} + +export const main = async () => { + const app = NanoforgeFactory.createClient({ tickRate: 60 }); + const ecs = new EcsLibrary(); + app.use(ecs); + const registry = ecs.registry; + const ball = registry.spawnEntity(); + registry.addComponent(ball, new Position(0, 0)); + registry.addComponent(ball, new Velocity(1, 1)); + registry.addComponent(ball, new Gravity(2)); + registry.addComponent(ball, new CircleComponent(undefined as never)); + registry.addSystem(move); + registry.addSystem(fall); +}; diff --git a/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/systems/systems.ts b/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/systems/systems.ts new file mode 100644 index 00000000..f759b94f --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/input/apps/client/src/systems/systems.ts @@ -0,0 +1,9 @@ +import type { Registry } from "@nanoforge-dev/ecs"; + +import { Position, Velocity } from "../components/components"; + +export const move = (registry: Registry) => { + for (const { Position: position, Velocity: velocity } of registry.getZipper([Position, Velocity])) { + position.x += velocity.x; + } +}; diff --git a/plugins/ecs/test/fixtures/adoption/split/input/apps/client/tsconfig.json b/plugins/ecs/test/fixtures/adoption/split/input/apps/client/tsconfig.json new file mode 100644 index 00000000..60c6acdc --- /dev/null +++ b/plugins/ecs/test/fixtures/adoption/split/input/apps/client/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true, "module": "esnext", "moduleResolution": "bundler" } } diff --git a/plugins/file-manager/src/service/file-manager-service.ts b/plugins/file-manager/src/service/file-manager-service.ts index 59b62d42..cd44e332 100644 --- a/plugins/file-manager/src/service/file-manager-service.ts +++ b/plugins/file-manager/src/service/file-manager-service.ts @@ -8,7 +8,6 @@ import { basename, dirname, editorsFor, - matchesGlob, } from '@nanoforge-dev/editor-sdk'; import { FILE_ICONS, @@ -20,6 +19,7 @@ import { import { isPackagePath } from '../filter/file-filter'; import type { Clipboard, FileManagerServiceOptions } from './file-manager-service.type'; import { FileOperations } from './file-operations'; +import { iconOfEntry } from './icon-of-entry'; export const NAME = /^[^/\\:*?"<>|]+$/; export const validateName = (value: string) => @@ -104,12 +104,7 @@ export class FileManagerService implements Disposable { /** Icon of an entry: contributed icons (highest priority first), else a default. */ iconOf(entry: Pick, expanded = false): string { - if (entry.kind === 'directory') return expanded ? 'folder-open' : 'folder'; - const icon = this._options.extensions - .getValues(FILE_ICONS) - .filter((candidate) => matchesGlob(candidate.pattern, entry.path)) - .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0]; - return icon?.icon ?? 'file'; + return iconOfEntry(this._options.extensions.getValues(FILE_ICONS), entry, expanded); } open(path: string): Promise { diff --git a/plugins/file-manager/src/service/icon-of-entry.ts b/plugins/file-manager/src/service/icon-of-entry.ts new file mode 100644 index 00000000..b97b496f --- /dev/null +++ b/plugins/file-manager/src/service/icon-of-entry.ts @@ -0,0 +1,23 @@ +import { type FileEntry, matchesGlob } from '@nanoforge-dev/editor-sdk'; +import type { FileIcon } from '@nanoforge-dev/editor-sdk/ui'; + +/** + * The icon of a file or folder: the matching contribution of the highest priority (folders match + * only `folder` ones, with `iconOpen` while expanded), else `file`, `folder` or `folder-open`. + */ +export const iconOfEntry = ( + icons: readonly FileIcon[], + entry: Pick, + expanded = false, +): string => { + const folder = entry.kind === 'directory'; + const icon = icons + .filter( + (candidate) => !!candidate.folder === folder && matchesGlob(candidate.pattern, entry.path), + ) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0]; + if (!folder) return icon?.icon ?? 'file'; + return ( + (expanded ? icon?.iconOpen : undefined) ?? icon?.icon ?? (expanded ? 'folder-open' : 'folder') + ); +}; diff --git a/plugins/file-manager/test/service/icon-of-entry.spec.ts b/plugins/file-manager/test/service/icon-of-entry.spec.ts new file mode 100644 index 00000000..e26af0da --- /dev/null +++ b/plugins/file-manager/test/service/icon-of-entry.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { iconOfEntry } from '../../src/service/icon-of-entry'; + +const icons = [ + { pattern: '**/*.ts', icon: 'file-code' }, + { pattern: '**/*.ts', icon: 'ts-logo', priority: 1000 }, + { pattern: '**/assets', icon: 'folder-images', iconOpen: 'folder-images-open', folder: true }, +]; + +describe('iconOfEntry', () => { + it('takes the highest priority icon of a file, else a file', () => { + expect(iconOfEntry(icons, { path: 'src/main.ts', kind: 'file' })).toBe('ts-logo'); + expect(iconOfEntry(icons, { path: 'README.md', kind: 'file' })).toBe('file'); + }); + + it('gives folders their own icons, open while expanded, else the folder icons', () => { + expect(iconOfEntry(icons, { path: 'apps/client/assets', kind: 'directory' })).toBe( + 'folder-images', + ); + expect(iconOfEntry(icons, { path: 'apps/client/assets', kind: 'directory' }, true)).toBe( + 'folder-images-open', + ); + expect(iconOfEntry(icons, { path: 'src', kind: 'directory' }, true)).toBe('folder-open'); + expect(iconOfEntry(icons, { path: 'assets.ts', kind: 'directory' })).toBe('folder'); + }); +}); diff --git a/plugins/scene/src/adoption/scene-check.ts b/plugins/scene/src/adoption/scene-check.ts new file mode 100644 index 00000000..bfa7c74f --- /dev/null +++ b/plugins/scene/src/adoption/scene-check.ts @@ -0,0 +1,164 @@ +import { + type ClientProject, + CodeServiceToken, + type Disposable, + PROJECT_CHECKS, + type PluginContext, + type ProjectSuggestion, + recordChanges, + runnableApps, + workspaceIo, +} from '@nanoforge-dev/editor-sdk'; + +import { MAIN_TO_SCENE_ANALYZER } from '../model/scene.const'; +import { kebab } from '../template/scene-template'; +import type { MainToSceneResult } from '../worker/main-to-scene'; + +const SCENE_MODULE = '@nanoforge-dev/scene'; + +type PackageJson = Record | undefined>; +const SECTIONS = ['dependencies', 'devDependencies'] as const; + +/** A path without `.` and `..` segments (leading `..` kept), as segments. */ +const segments = (path: string) => { + const result: string[] = []; + for (const part of path.split('/')) { + if (!part || part === '.') continue; + if (part === '..' && result.length && result.at(-1) !== '..') result.pop(); + else result.push(part); + } + return result; +}; + +/** `to` relative to the folder `from` (both relative to the project). */ +const relativePath = (from: string, to: string) => { + const a = segments(from); + const b = segments(to); + let common = 0; + while (common < a.length && common < b.length && a[common] === b[common]) common++; + return [...a.slice(common).map(() => '..'), ...b.slice(common)].join('/') || '.'; +}; + +/** + * The scene module's spec for the app at `appRoot`, from a `package.json` of the project that + * declares it (a `link:` or `file:` path is rebased on the app's folder). The engine's core spec + * is not one: the module is not on npm, and core's link points at another folder. + */ +export const sceneModuleSpec = ( + packages: readonly { readonly path: string; readonly text: string }[], + appRoot: string, +): string | undefined => { + for (const { path, text } of packages) { + let json: PackageJson; + try { + json = JSON.parse(text) as PackageJson; + } catch { + continue; + } + const spec = SECTIONS.map((key) => json[key]?.[SCENE_MODULE]).find(Boolean); + if (!spec) continue; + const local = /^(link|file):(.*)$/.exec(spec); + if (!local || local[2]!.startsWith('/')) return spec; + const folder = path.split('/').slice(0, -1).join('/'); + return `${local[1]}:${relativePath(appRoot, `${folder}/${local[2]}`)}`; + } + return undefined; +}; + +/** `package.json` with the scene module beside the engine's core. */ +export const withSceneModule = (text: string, spec: string): string => { + const json = JSON.parse(text) as PackageJson; + if (SECTIONS.some((key) => json[key]?.[SCENE_MODULE])) return text; + const key = SECTIONS.find((section) => json[section]?.['@nanoforge-dev/core']) ?? 'dependencies'; + const entries = { ...json[key], [SCENE_MODULE]: spec }; + json[key] = Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b))); + return `${JSON.stringify(json, null, 2)}\n`; +}; + +/** + * The scene plugin's check of the Adapt to the editor report: an app whose main makes its + * entities itself, without scenes, gets them moved into a scene (`Game`) loaded at the start. + * Offered only where `@nanoforge-dev/scene` is installed (it is not on npm yet). + */ +export const registerSceneCheck = (context: PluginContext): Disposable => + context.contribute(PROJECT_CHECKS, { + id: 'scene.from-main', + run: async (project: ClientProject): Promise => { + const code = context.services.tryGet(CodeServiceToken); + if (!code) return []; + const io = workspaceIo(project, { fresh: false }); + const suggestions: ProjectSuggestion[] = []; + for (const app of runnableApps(project.model.get())) { + const entry = app.entryFile; + if (!entry || !io.exists(entry) || !app.engineLibs['@nanoforge-dev/ecs']) continue; + if (app.engineLibs[SCENE_MODULE]) continue; + const text = await io.read(entry); + if (!/\.spawnEntity\(/.test(text) || /\bSceneLibrary\b/.test(text)) continue; + /** + * Only where the module is installed (linked from an engine checkout): it is not on npm + * yet, and a fresh project (nf new, the tutorial's) could not install it. + */ + if (!app.availableEngineLibs.includes(SCENE_MODULE)) continue; + let className = 'Game'; + for (let n = 2; io.exists(`${app.dirs.scenes}/${kebab(className)}.ts`); n++) + className = `Game${n}`; + const scenePath = `${app.dirs.scenes}/${kebab(className)}.ts`; + suggestions.push({ + id: `scene.from-main:${app.id}`, + app: app.name, + title: "Make main's entities a scene", + detail: `${entry} makes the game's entities and systems itself: they are there for the whole game. In scenes (a menu, levels, a pause screen), each part makes its own and goes away when another is loaded, and the Scenes panel shows them. This moves them into a scene, ${className}, loaded when the game starts.`, + fix: { + title: 'Move into a scene', + changeset: async () => { + const result = await code.analyze(entry, MAIN_TO_SCENE_ANALYZER, { + scenePath, + className, + side: app.type === 'server' ? 'server' : 'client', + }); + const packageJson = `${app.root ? `${app.root}/` : ''}package.json`; + const others = io + .files() + .filter( + (path) => + (path === 'package.json' || path.endsWith('/package.json')) && + path !== packageJson && + !/(^|\/)(node_modules|nf_modules)\//.test(path), + ); + const spec = sceneModuleSpec( + await Promise.all( + others.map(async (path) => ({ path, text: await io.read(path) })), + ), + app.root, + ); + const changeset = await recordChanges( + io, + `Move ${app.name}'s entities into ${className}`, + async (memory) => { + await memory.write(scenePath, result.scene); + if (spec && memory.exists(packageJson)) + await memory.write( + packageJson, + withSceneModule(await memory.read(packageJson), spec), + ); + await memory.write(entry, result.main); + }, + ); + return { + ...changeset, + notes: [ + `${SCENE_MODULE} is not on npm yet: until the engine publishes it, the project installs it from an engine checkout (linked), or its dependencies fail to install.`, + ...(spec + ? [] + : [ + `No package.json of the project names ${SCENE_MODULE}, though it is installed: add it to ${packageJson} by hand (a link: to your engine checkout's modules/scene), or a fresh install drops it.`, + ]), + ], + }; + }, + }, + }); + } + return suggestions; + }, + }); diff --git a/plugins/scene/src/index.ts b/plugins/scene/src/index.ts index 59548cf8..00f86f4a 100644 --- a/plugins/scene/src/index.ts +++ b/plugins/scene/src/index.ts @@ -9,6 +9,7 @@ import { registerIcons, } from '@nanoforge-dev/editor-sdk/ui'; +import { registerSceneCheck } from './adoption/scene-check'; import { ECS_SOURCES, type EcsSource } from './extension/ecs-source.extension-point'; import { ICONS } from './icons'; import { SceneLiveService, SceneLiveServiceToken } from './live/scene-live-service'; @@ -28,6 +29,7 @@ export default definePlugin({ context.subscriptions.add(registerIcons(ICONS)); const scenes = context.subscriptions.add(new SceneService(context)); + context.subscriptions.add(registerSceneCheck(context)); context.provide(SceneServiceToken, scenes); const live = context.subscriptions.add(new SceneLiveService(context)); context.provide(SceneLiveServiceToken, live); diff --git a/plugins/scene/src/model/scene.const.ts b/plugins/scene/src/model/scene.const.ts index 130b6f31..a8ba8c1e 100644 --- a/plugins/scene/src/model/scene.const.ts +++ b/plugins/scene/src/model/scene.const.ts @@ -11,3 +11,5 @@ export const VARS_TRANSFORMER = 'scene.vars-file'; /** The file a new app's vars are declared in. */ export const VARS_FILE = 'scene-vars.ts'; +/** main's entities and systems moved into a new scene, as text (adoption). */ +export const MAIN_TO_SCENE_ANALYZER = 'scene.main-to-scene'; diff --git a/plugins/scene/src/worker/import-line.ts b/plugins/scene/src/worker/import-line.ts new file mode 100644 index 00000000..5d685178 --- /dev/null +++ b/plugins/scene/src/worker/import-line.ts @@ -0,0 +1,52 @@ +import type { ImportDeclaration, Node } from 'ts-morph'; + +/** One binding an import brings, with its node. */ +export interface Binding { + readonly kind: 'named' | 'default' | 'namespace'; + readonly imported?: string; + readonly local: string; + readonly typeOnly: boolean; + readonly node: Node; +} + +/** The bindings an import declaration brings. */ +export const bindingsOf = (declaration: ImportDeclaration): Binding[] => { + const typeOnly = declaration.isTypeOnly(); + const bindings: Binding[] = []; + const def = declaration.getDefaultImport(); + if (def) bindings.push({ kind: 'default', local: def.getText(), typeOnly, node: def }); + const namespace = declaration.getNamespaceImport(); + if (namespace) + bindings.push({ kind: 'namespace', local: namespace.getText(), typeOnly, node: namespace }); + for (const specifier of declaration.getNamedImports()) { + const local = specifier.getAliasNode() ?? specifier.getNameNode(); + bindings.push({ + kind: 'named', + imported: specifier.getName(), + local: local.getText(), + typeOnly: typeOnly || specifier.isTypeOnly(), + node: local, + }); + } + return bindings; +}; + +/** An import line of `module` for `bindings`, in the file's quote style. */ +export const importLine = (module: string, bindings: readonly Binding[], quote: string): string => { + const unique = [...new Map(bindings.map((binding) => [binding.local, binding])).values()]; + const allTypes = unique.every((binding) => binding.typeOnly); + const parts: string[] = []; + const def = unique.find((binding) => binding.kind === 'default'); + const namespace = unique.find((binding) => binding.kind === 'namespace'); + const named = unique + .filter((binding) => binding.kind === 'named') + .sort((a, b) => a.local.localeCompare(b.local)) + .map( + (binding) => + `${binding.typeOnly && !allTypes ? 'type ' : ''}${binding.imported === binding.local ? binding.local : `${binding.imported} as ${binding.local}`}`, + ); + if (def) parts.push(def.local); + if (namespace) parts.push(`* as ${namespace.local}`); + if (named.length) parts.push(`{ ${named.join(', ')} }`); + return `import ${allTypes ? 'type ' : ''}${parts.join(', ')} from ${quote}${module}${quote};`; +}; diff --git a/plugins/scene/src/worker/index.ts b/plugins/scene/src/worker/index.ts index b2c2f1b9..c84c8eee 100644 --- a/plugins/scene/src/worker/index.ts +++ b/plugins/scene/src/worker/index.ts @@ -7,6 +7,7 @@ import { defineWorkerPlugin } from '@nanoforge-dev/editor-sdk/worker'; import { CLASSES_ANALYZER, LIBRARY_TRANSFORMER, + MAIN_TO_SCENE_ANALYZER, REFERENCES_ANALYZER, REPLACE_TRANSFORMER, SCENES_ANALYZER, @@ -21,12 +22,14 @@ import { transformReplace, transformSceneFile, } from './edits'; +import { analyzeMainToScene } from './main-to-scene'; import { analyzeScenes } from './scenes'; import { analyzeVars, transformVars } from './vars'; export default defineWorkerPlugin({ activate(sdk) { sdk.registerAnalyzer(SCENES_ANALYZER, analyzeScenes); + sdk.registerAnalyzer(MAIN_TO_SCENE_ANALYZER, analyzeMainToScene); sdk.registerAnalyzer(REFERENCES_ANALYZER, analyzeReferences); sdk.registerAnalyzer(CLASSES_ANALYZER, analyzeClasses); sdk.registerTransformer(LIBRARY_TRANSFORMER, transformLibrary); diff --git a/plugins/scene/src/worker/main-to-scene.ts b/plugins/scene/src/worker/main-to-scene.ts new file mode 100644 index 00000000..3cee62b9 --- /dev/null +++ b/plugins/scene/src/worker/main-to-scene.ts @@ -0,0 +1,298 @@ +import { Node, type Statement, SyntaxKind } from 'ts-morph'; + +import { type AnalyzeContext, CodeError } from '@nanoforge-dev/editor-sdk/worker'; + +import { type Binding, bindingsOf, importLine } from './import-line'; +import { projectPath } from './scenes'; +import { applyEdits, dedent, ownStart, removalStart, trailingEnd } from './source-edits'; + +/** Where the scene goes, and its class. */ +export interface MainToSceneArgs { + readonly scenePath: string; + readonly className: string; + readonly side: 'client' | 'server'; +} + +export interface MainToSceneResult { + readonly scene: string; + readonly main: string; + /** The entities and systems moved (their variables, the systems' names). */ + readonly entities: readonly string[]; + readonly systems: readonly string[]; +} + +const REGISTRY_CALLS = new Set(['spawnEntity', 'addComponent', 'addSystem']); + +/** The registry call a statement makes (`registry.addSystem(…)`), with the registry's text. */ +const registryCall = (statement: Statement) => { + let call: Node | undefined; + if (Node.isExpressionStatement(statement)) call = statement.getExpression(); + else if (Node.isVariableStatement(statement)) { + const declarations = statement.getDeclarations(); + if (declarations.length === 1) call = declarations[0]!.getInitializer(); + } + if (!call || !Node.isCallExpression(call)) return undefined; + const callee = call.getExpression(); + if (!Node.isPropertyAccessExpression(callee) || !REGISTRY_CALLS.has(callee.getName())) + return undefined; + if (Node.isVariableStatement(statement) && callee.getName() !== 'spawnEntity') return undefined; + return { method: callee.getName(), registry: callee.getExpression(), call }; +}; + +/** + * Moves what `main` makes with the registry (`spawnEntity`, `addComponent`, `addSystem`) into the + * `setup` of a new scene class, and registers `SceneLibrary` with it as the initial scene. + * Read-only: returns the scene's text and main's. Refused when those statements use variables of + * main itself (other than the registry). + */ +export const analyzeMainToScene = (context: AnalyzeContext, args: unknown): MainToSceneResult => { + const { scenePath, className, side } = args as MainToSceneArgs; + const file = context.file; + const main = + file.getFunction('main') ?? + file.getVariableDeclaration('main')?.getInitializerIfKind(SyntaxKind.ArrowFunction) ?? + file.getVariableDeclaration('main')?.getInitializerIfKind(SyntaxKind.FunctionExpression); + const body = main?.getBody(); + if (!main || !body || !Node.isBlock(body)) + throw new CodeError(`${context.path} has no main function.`); + const statements = body.getStatements(); + const moved = statements.filter((statement) => registryCall(statement)); + if (!moved.length) throw new CodeError('main makes no entity nor system.'); + const registries = new Set(moved.map((statement) => registryCall(statement)!.registry.getText())); + if (registries.size > 1) + throw new CodeError(`main uses several registries (${[...registries].join(', ')}).`); + const registryText = [...registries][0]!; + const registryNode = registryCall(moved[0]!)!.registry; + const registryDeclaration = Node.isIdentifier(registryNode) + ? registryNode.getSymbol()?.getDeclarations()[0] + : undefined; + /** + * A registry named by a variable becomes setup's parameter, under the same name: the moved + * code is kept as it is. Another expression (`ecs.registry`) is replaced where it occurs. + */ + const registryParam = Node.isIdentifier(registryNode) ? registryText : 'registry'; + const occurrences = Node.isIdentifier(registryNode) + ? [] + : moved.flatMap((statement) => + statement + .getDescendants() + .filter( + (node) => + node.getKind() === registryNode.getKind() && + node.getText() === registryText && + node.getSymbol() === registryNode.getSymbol(), + ), + ); + const inRegistry = (node: Node) => + occurrences.some((occurrence) => occurrence.containsRange(node.getPos(), node.getEnd())); + + const quote = file.getImportDeclarations()[0]?.getModuleSpecifier().getText()[0] ?? '"'; + const needs = new Map(); + const need = (module: string, binding: Binding) => + needs.set(module, [...(needs.get(module) ?? []), binding]); + const locals = new Set(); + const insideMoved = (node: Node) => + moved.some((statement) => statement.containsRange(node.getPos(), node.getEnd())); + const fromMain = new Set(); + for (const statement of moved) + for (const identifier of statement.getDescendantsOfKind(SyntaxKind.Identifier)) { + if (inRegistry(identifier)) continue; + const declaration = identifier.getSymbol()?.getDeclarations()[0]; + if (!declaration || declaration.getSourceFile() !== file) continue; + if (declaration === registryDeclaration) continue; + if (insideMoved(declaration)) continue; + const importDeclaration = declaration.getFirstAncestorByKind(SyntaxKind.ImportDeclaration); + if (importDeclaration) { + const binding = bindingsOf(importDeclaration).find( + (candidate) => candidate.local === identifier.getText(), + ); + if (!binding) continue; + const target = importDeclaration.getModuleSpecifierSourceFile(); + const value = importDeclaration.getModuleSpecifierValue(); + need( + value.startsWith('.') && target + ? context.moduleSpecifier(projectPath(target), scenePath) + : value, + binding, + ); + continue; + } + if (main.containsRange(declaration.getPos(), declaration.getEnd())) { + locals.add(identifier.getText()); + continue; + } + const top = declaration.getFirstAncestor((node) => Node.isSourceFile(node.getParent())); + if (top && Node.isExportable(top) && top.isExported()) fromMain.add(identifier.getText()); + else locals.add(identifier.getText()); + } + locals.delete(registryText); + /** What stays in main must not use what moves (an entity's variable). */ + const stillUsed = new Set(); + for (const statement of statements.filter((candidate) => !moved.includes(candidate))) + for (const identifier of statement.getDescendantsOfKind(SyntaxKind.Identifier)) { + const declaration = identifier.getSymbol()?.getDeclarations()[0]; + if (declaration && insideMoved(declaration)) stillUsed.add(identifier.getText()); + } + if (stillUsed.size) + throw new CodeError( + `main still uses ${[...stillUsed].join(', ')} after making ${stillUsed.size === 1 ? 'it' : 'them'}: move that code into the scene by hand, or keep these entities in main.`, + ); + if (fromMain.size) + throw new CodeError( + `main's entities use ${[...fromMain].sort().join(', ')} of ${context.path}, which imports the scene: move ${fromMain.size === 1 ? 'it' : 'them'} to a module of ${fromMain.size === 1 ? 'its' : 'their'} own first.`, + ); + if (locals.size) + throw new CodeError( + `main's entities use ${[...locals].join(', ')}, made in main itself: move ${locals.size === 1 ? 'it' : 'them'} out of main (or into the scene by hand) first.`, + ); + + const lines = [...needs].map(([module, bindings]) => importLine(module, bindings, quote)); + const packages = lines.filter((line) => !/ from ["']\./.test(line)); + const relative = lines.filter((line) => / from ["']\./.test(line)); + const setup = moved + .map((statement) => { + const start = ownStart(statements, statement); + const raw = applyEdits( + context.text.slice(start, trailingEnd(statement)), + occurrences + .filter((node) => statement.containsRange(node.getPos(), node.getEnd())) + .map((node) => ({ + start: node.getStart() - start, + end: node.getEnd() - start, + text: registryParam, + })), + ); + const text = dedent(raw.replace(/^(?:[ \t]*\r?\n)+/, '').replace(/^[ \t]+/, '')); + return text + .split('\n') + .map((line) => (line ? ` ${line}` : line)) + .join('\n'); + }) + .join('\n'); + const scene = [ + `import type { Registry } from "@nanoforge-dev/ecs/${side}";`, + 'import { EcsScene } from "@nanoforge-dev/ecs/scene";', + ...packages, + ...(relative.length ? ['', ...relative] : []), + '', + '/**', + ` * The game as main made it: its entities and systems, loaded when the game starts.`, + ' *', + ' * @scene', + ` * @side ${side}`, + ' */', + `export class ${className} extends EcsScene {`, + ` override setup(${registryParam}: Registry) {`, + setup, + ' }', + '}', + '', + ] + .join('\n') + .replace(/(import [^\n]+\n)(?=\/\*\*)/, '$1\n'); + + /** main without the moved statements (nor `const registry = …` once unused), with SceneLibrary. */ + const edits: { start: number; end: number; text: string }[] = moved.map((statement) => ({ + start: ownStart(statements, statement), + end: trailingEnd(statement), + text: '', + })); + const registryStatement = registryDeclaration?.getFirstAncestorByKind( + SyntaxKind.VariableStatement, + ); + if ( + registryStatement && + statements.includes(registryStatement) && + !statements.some( + (statement) => + statement !== registryStatement && + !moved.includes(statement) && + statement + .getDescendantsOfKind(SyntaxKind.Identifier) + .some((identifier) => identifier.getSymbol() === registryNode.getSymbol()), + ) + ) + edits.push({ + start: ownStart(statements, registryStatement), + end: trailingEnd(registryStatement), + text: '', + }); + const kept = file.getStatements().filter((statement) => !Node.isImportDeclaration(statement)); + const usedOutside = (node: Node) => + kept.some((statement) => + statement + .getDescendantsOfKind(SyntaxKind.Identifier) + .some( + (identifier) => + identifier.getSymbol() === node.getSymbol() && + identifier.getText() === node.getText() && + !insideMoved(identifier), + ), + ); + for (const declaration of file.getImportDeclarations()) { + const bindings = bindingsOf(declaration); + if (!bindings.length) continue; + const used = bindings.filter((binding) => usedOutside(binding.node)); + if (used.length === bindings.length) continue; + edits.push({ + start: used.length + ? declaration.getStart() + : removalStart(context.text, declaration.getStart()), + end: declaration.getEnd(), + text: used.length ? importLine(declaration.getModuleSpecifierValue(), used, quote) : '', + }); + } + const uses = statements.filter( + (statement) => + Node.isExpressionStatement(statement) && /^\s*app\.use\(/.test(statement.getText()), + ); + const lastUse = uses.at(-1); + if (!lastUse) + throw new CodeError('main registers no library with app.use(…): add the SceneLibrary by hand.'); + const lineStart = context.text.lastIndexOf('\n', lastUse.getStart()) + 1; + const indent = context.text.slice(lineStart, lastUse.getStart()); + edits.push({ + start: trailingEnd(lastUse), + end: trailingEnd(lastUse), + text: `\n${indent}app.use(new SceneLibrary({ initial: ${className}, scenes: { ${className} } }));`, + }); + const imports = file.getImportDeclarations(); + const lastPackage = [...imports] + .reverse() + .find((declaration) => !declaration.getModuleSpecifierValue().startsWith('.')); + const lastImport = imports.at(-1); + edits.push({ + start: (lastPackage ?? lastImport)?.getEnd() ?? 0, + end: (lastPackage ?? lastImport)?.getEnd() ?? 0, + text: `\n${importLine('@nanoforge-dev/scene', [{ kind: 'named', imported: 'SceneLibrary', local: 'SceneLibrary', typeOnly: false, node: file }], quote)}`, + }); + const relativeLeft = imports.some( + (declaration) => + declaration.getModuleSpecifierValue().startsWith('.') && + bindingsOf(declaration).some((binding) => usedOutside(binding.node)), + ); + edits.push({ + start: lastImport?.getEnd() ?? 0, + end: lastImport?.getEnd() ?? 0, + text: `\n${relativeLeft ? '' : '\n'}${importLine(context.moduleSpecifier(scenePath, context.path), [{ kind: 'named', imported: className, local: className, typeOnly: false, node: file }], quote)}`, + }); + const mainText = `${applyEdits(context.text, edits) + .replace(/\n{3,}/g, '\n\n') + .trimEnd()}\n`; + return { + scene, + main: mainText, + entities: moved + .filter((statement) => Node.isVariableStatement(statement)) + .map((statement) => + (statement as Statement & { getDeclarations(): { getName(): string }[] }) + .getDeclarations()[0]! + .getName(), + ), + systems: moved + .filter((statement) => registryCall(statement)!.method === 'addSystem') + .map((statement) => + (registryCall(statement)!.call as Node).getText().replace(/^.*addSystem\(|\)$/g, ''), + ), + }; +}; diff --git a/plugins/scene/src/worker/source-edits.ts b/plugins/scene/src/worker/source-edits.ts new file mode 100644 index 00000000..0d196aff --- /dev/null +++ b/plugins/scene/src/worker/source-edits.ts @@ -0,0 +1,45 @@ +import type { Node, Statement } from 'ts-morph'; + +/** Text without the indentation its lines share. */ +export const dedent = (text: string) => { + const lines = text.split('\n'); + const indents = lines + .slice(1) + .filter((line) => line.trim()) + .map((line) => line.match(/^\s*/)![0].length); + const cut = indents.length ? Math.min(...indents) : 0; + return [lines[0]!, ...lines.slice(1).map((line) => line.slice(Math.min(cut, line.length)))].join( + '\n', + ); +}; + +/** Applies edits from the end; at one same start, a removal goes before an insertion. */ +export const applyEdits = ( + text: string, + edits: readonly { start: number; end: number; text: string }[], +) => { + let result = text; + for (const edit of [...edits].sort( + (a, b) => b.start - a.start || b.end - b.start - (a.end - a.start), + )) + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + return result; +}; + +/** Where a statement ends, its same-line comment (`call(); // why`) included. */ +export const trailingEnd = (node: Node) => + node.getTrailingCommentRanges().at(-1)?.getEnd() ?? node.getEnd(); + +/** Where a statement of a block starts: its leading trivia, without the previous one's comment. */ +export const ownStart = (statements: readonly Statement[], statement: Statement) => { + const previous = statements[statements.indexOf(statement) - 1]; + return previous + ? Math.max(statement.getFullStart(), trailingEnd(previous)) + : statement.getFullStart(); +}; + +/** A removed import's range: from its start, with the line break before it (no blank line left). */ +export const removalStart = (text: string, start: number) => { + const lineBreak = text.lastIndexOf('\n', start - 1); + return lineBreak >= 0 && !text.slice(lineBreak + 1, start).trim() ? lineBreak : start; +}; diff --git a/plugins/scene/test/adoption/main-to-scene.spec.ts b/plugins/scene/test/adoption/main-to-scene.spec.ts new file mode 100644 index 00000000..05db487b --- /dev/null +++ b/plugins/scene/test/adoption/main-to-scene.spec.ts @@ -0,0 +1,111 @@ +import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { CodeEngine } from '@nanoforge-dev/editor-code/engine'; + +import { type MainToSceneResult, analyzeMainToScene } from '../../src/worker/main-to-scene'; + +const FIXTURES = join(import.meta.dirname, '../fixtures/adoption'); +const FIXTURE = join(FIXTURES, 'scene'); +const MAIN = 'apps/client/src/main.ts'; +const SCENE = 'apps/client/src/scenes/game.ts'; + +const readTree = (root: string) => { + const files = new Map(); + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else files.set(relative(root, path), readFileSync(path, 'utf8')); + } + }; + walk(root); + return files; +}; + +const convert = (files: Map) => { + const engine = new CodeEngine(); + engine.setFiles([...files].map(([path, text]) => ({ path, text }))); + engine.registerAnalyzer('scene', analyzeMainToScene); + return engine.analyze(MAIN, 'scene', { + scenePath: SCENE, + className: 'Game', + side: 'client', + }) as MainToSceneResult; +}; + +describe("main's entities into a scene", () => { + /** Converts a tree and compares it with the fixture's `expected` (`UPDATE_GOLDEN=1` writes it). */ + const expectConverted = (fixture: string, files: Map) => { + const result = convert(files); + files.set(SCENE, result.scene); + files.set(MAIN, result.main); + const expected = join(FIXTURES, fixture, 'expected'); + if (process.env.UPDATE_GOLDEN) { + rmSync(expected, { recursive: true, force: true }); + for (const [path, text] of files) { + mkdirSync(dirname(join(expected, path)), { recursive: true }); + writeFileSync(join(expected, path), text); + } + return result; + } + expect(Object.fromEntries([...files].sort())).toEqual( + Object.fromEntries([...readTree(expected)].sort()), + ); + return result; + }; + + it('moves the spawns and systems to a scene that main registers', () => { + const result = expectConverted('scene', readTree(join(FIXTURE, 'input'))); + expect(result).toMatchObject({ entities: ['circle'], systems: ['draw2D'] }); + }); + + it("keeps the registry's own name, and the comments of the statements it moves", () => { + expectConverted('registry-alias', readTree(join(FIXTURES, 'registry-alias', 'input'))); + }); + + it('replaces a registry used without a variable where it occurs', () => { + const files = readTree(join(FIXTURE, 'input')); + files.set( + MAIN, + files + .get(MAIN)! + .replace(' const registry = ecs.registry;\n\n', '') + .replaceAll('registry.', 'ecs.registry.'), + ); + expectConverted('scene', files); + }); + + it('refuses entities that use what main exports: main imports the scene', () => { + const files = readTree(join(FIXTURE, 'input')); + files.set( + MAIN, + files + .get(MAIN)! + .replace('export const main', 'export const SIZE = 500;\n\nexport const main') + .replace('new Position2D(500, 500)', 'new Position2D(SIZE, 500)'), + ); + expect(() => convert(files)).toThrow( + /use SIZE of apps\/client\/src\/main.ts, which imports the scene/, + ); + }); + + it("refuses entities that use main's own variables", () => { + const files = readTree(join(FIXTURE, 'input')); + files.set( + MAIN, + files.get(MAIN)!.replace('new Position2D(500, 500)', 'new Position2D(app.width, 500)'), + ); + expect(() => convert(files)).toThrow(/use app, made in main itself/); + }); + + it('refuses when main keeps using an entity it made', () => { + const files = readTree(join(FIXTURE, 'input')); + files.set( + MAIN, + files.get(MAIN)!.replace(' await app.run();', ' console.log(circle);\n await app.run();'), + ); + expect(() => convert(files)).toThrow(/main still uses circle/); + }); +}); diff --git a/plugins/scene/test/adoption/scene-check.spec.ts b/plugins/scene/test/adoption/scene-check.spec.ts new file mode 100644 index 00000000..a81efa02 --- /dev/null +++ b/plugins/scene/test/adoption/scene-check.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { sceneModuleSpec, withSceneModule } from '../../src/adoption/scene-check'; + +const pkg = (dependencies: Record) => + JSON.stringify({ name: 'pong-client', dependencies }); + +describe('the scene module in package.json', () => { + it("never takes the engine core's spec", () => { + expect( + sceneModuleSpec( + [ + { + path: 'apps/server/package.json', + text: pkg({ '@nanoforge-dev/core': 'link:../../../engine/packages/core' }), + }, + ], + 'apps/client', + ), + ).toBeUndefined(); + }); + + it("rebases another package.json's link on the app's folder", () => { + expect( + sceneModuleSpec( + [ + { + path: 'package.json', + text: pkg({ '@nanoforge-dev/scene': 'link:../engine/modules/scene' }), + }, + ], + 'apps/client', + ), + ).toBe('link:../../../engine/modules/scene'); + expect( + sceneModuleSpec( + [{ path: 'apps/server/package.json', text: pkg({ '@nanoforge-dev/scene': '^1.2.0' }) }], + 'apps/client', + ), + ).toBe('^1.2.0'); + }); + + it('writes the spec it is given, sorted with the others', () => { + const text = withSceneModule( + pkg({ + '@nanoforge-dev/core': 'link:../engine/packages/core', + '@nanoforge-dev/ecs': '^1.0.0', + }), + 'link:../engine/modules/scene', + ); + expect(Object.entries(JSON.parse(text).dependencies)).toEqual([ + ['@nanoforge-dev/core', 'link:../engine/packages/core'], + ['@nanoforge-dev/ecs', '^1.0.0'], + ['@nanoforge-dev/scene', 'link:../engine/modules/scene'], + ]); + }); +}); diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/drawable-circle-2d.component.ts b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/drawable-circle-2d.component.ts new file mode 100644 index 00000000..44a872c4 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/drawable-circle-2d.component.ts @@ -0,0 +1 @@ +export class DrawableCircle2D { name = "DrawableCircle2D"; constructor(public radius: number, public visible: boolean, public fill: string) {} } diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/position-2d.component.ts b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/position-2d.component.ts new file mode 100644 index 00000000..53857af2 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/position-2d.component.ts @@ -0,0 +1 @@ +export class Position2D { name = "Position2D"; constructor(public x: number, public y: number) {} } diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/vector.ts b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/vector.ts new file mode 100644 index 00000000..d51bdf4e --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/components/vector.ts @@ -0,0 +1,6 @@ +export class Vector { + static zero(): Vector { + return new Vector(0, 0); + } + constructor(public x: number, public y: number) {} +} diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/main.ts b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/main.ts new file mode 100644 index 00000000..dc687cd0 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/main.ts @@ -0,0 +1,20 @@ +import { type ClientRunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Graphics2DLibrary } from "@nanoforge-dev/graphics-2d"; +import { SceneLibrary } from "@nanoforge-dev/scene"; + +import { Game } from "./scenes/game"; + +export const main = async (options: ClientRunOptions): Promise => { + const app = NanoforgeFactory.createClient({ tickRate: 60 }); + + const ecs = new EcsLibrary(); + app.use(ecs); + app.use(new Graphics2DLibrary()); // draws the circles + app.use(new SceneLibrary({ initial: Game, scenes: { Game } })); + + await app.init(options); + + await app.run(); +}; diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/scenes/game.ts b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/scenes/game.ts new file mode 100644 index 00000000..6deba1c0 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/scenes/game.ts @@ -0,0 +1,22 @@ +import type { Registry } from "@nanoforge-dev/ecs/client"; +import { EcsScene } from "@nanoforge-dev/ecs/scene"; + +import { Position2D } from "../components/position-2d.component"; +import { Vector } from "../components/vector"; +import { DrawableCircle2D } from "../components/drawable-circle-2d.component"; +import { draw2D } from "../systems/draw-2d.system"; + +/** + * The game as main made it: its entities and systems, loaded when the game starts. + * + * @scene + * @side client + */ +export class Game extends EcsScene { + override setup(r: Registry) { + const player = r.spawnEntity(); // the player + r.addComponent(player, new Position2D(Vector.zero().x, 500)); + r.addComponent(player, new DrawableCircle2D(75, true, "color r.red")); + r.addSystem(draw2D); // after the others + } +} diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/systems/draw-2d.system.ts b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/systems/draw-2d.system.ts new file mode 100644 index 00000000..f284f456 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/expected/apps/client/src/systems/draw-2d.system.ts @@ -0,0 +1 @@ +export function draw2D() {} diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/drawable-circle-2d.component.ts b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/drawable-circle-2d.component.ts new file mode 100644 index 00000000..44a872c4 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/drawable-circle-2d.component.ts @@ -0,0 +1 @@ +export class DrawableCircle2D { name = "DrawableCircle2D"; constructor(public radius: number, public visible: boolean, public fill: string) {} } diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/position-2d.component.ts b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/position-2d.component.ts new file mode 100644 index 00000000..53857af2 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/position-2d.component.ts @@ -0,0 +1 @@ +export class Position2D { name = "Position2D"; constructor(public x: number, public y: number) {} } diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/vector.ts b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/vector.ts new file mode 100644 index 00000000..d51bdf4e --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/components/vector.ts @@ -0,0 +1,6 @@ +export class Vector { + static zero(): Vector { + return new Vector(0, 0); + } + constructor(public x: number, public y: number) {} +} diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/main.ts b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/main.ts new file mode 100644 index 00000000..81dca67c --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/main.ts @@ -0,0 +1,29 @@ +import { type ClientRunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Graphics2DLibrary } from "@nanoforge-dev/graphics-2d"; + +import { DrawableCircle2D } from "./components/drawable-circle-2d.component"; +import { Position2D } from "./components/position-2d.component"; +import { Vector } from "./components/vector"; +import { draw2D } from "./systems/draw-2d.system"; + +export const main = async (options: ClientRunOptions): Promise => { + const app = NanoforgeFactory.createClient({ tickRate: 60 }); + + const ecs = new EcsLibrary(); + app.use(ecs); + app.use(new Graphics2DLibrary()); // draws the circles + + await app.init(options); + + const r = ecs.registry; + + const player = r.spawnEntity(); // the player + r.addComponent(player, new Position2D(Vector.zero().x, 500)); + r.addComponent(player, new DrawableCircle2D(75, true, "color r.red")); + + r.addSystem(draw2D); // after the others + + await app.run(); +}; diff --git a/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/systems/draw-2d.system.ts b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/systems/draw-2d.system.ts new file mode 100644 index 00000000..f284f456 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/registry-alias/input/apps/client/src/systems/draw-2d.system.ts @@ -0,0 +1 @@ +export function draw2D() {} diff --git a/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/components/drawable-circle-2d.component.ts b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/components/drawable-circle-2d.component.ts new file mode 100644 index 00000000..44a872c4 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/components/drawable-circle-2d.component.ts @@ -0,0 +1 @@ +export class DrawableCircle2D { name = "DrawableCircle2D"; constructor(public radius: number, public visible: boolean, public fill: string) {} } diff --git a/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/components/position-2d.component.ts b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/components/position-2d.component.ts new file mode 100644 index 00000000..53857af2 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/components/position-2d.component.ts @@ -0,0 +1 @@ +export class Position2D { name = "Position2D"; constructor(public x: number, public y: number) {} } diff --git a/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/main.ts b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/main.ts new file mode 100644 index 00000000..638da2be --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/main.ts @@ -0,0 +1,20 @@ +import { type ClientRunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Graphics2DLibrary } from "@nanoforge-dev/graphics-2d"; +import { SceneLibrary } from "@nanoforge-dev/scene"; + +import { Game } from "./scenes/game"; + +export const main = async (options: ClientRunOptions): Promise => { + const app = NanoforgeFactory.createClient({ tickRate: 60 }); + + const ecs = new EcsLibrary(); + app.use(ecs); + app.use(new Graphics2DLibrary()); + app.use(new SceneLibrary({ initial: Game, scenes: { Game } })); + + await app.init(options); + + await app.run(); +}; diff --git a/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/scenes/game.ts b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/scenes/game.ts new file mode 100644 index 00000000..c03c5ef5 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/scenes/game.ts @@ -0,0 +1,21 @@ +import type { Registry } from "@nanoforge-dev/ecs/client"; +import { EcsScene } from "@nanoforge-dev/ecs/scene"; + +import { Position2D } from "../components/position-2d.component"; +import { DrawableCircle2D } from "../components/drawable-circle-2d.component"; +import { draw2D } from "../systems/draw-2d.system"; + +/** + * The game as main made it: its entities and systems, loaded when the game starts. + * + * @scene + * @side client + */ +export class Game extends EcsScene { + override setup(registry: Registry) { + const circle = registry.spawnEntity(); + registry.addComponent(circle, new Position2D(500, 500)); + registry.addComponent(circle, new DrawableCircle2D(75, true, "red")); + registry.addSystem(draw2D); + } +} diff --git a/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/systems/draw-2d.system.ts b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/systems/draw-2d.system.ts new file mode 100644 index 00000000..f284f456 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/expected/apps/client/src/systems/draw-2d.system.ts @@ -0,0 +1 @@ +export function draw2D() {} diff --git a/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/components/drawable-circle-2d.component.ts b/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/components/drawable-circle-2d.component.ts new file mode 100644 index 00000000..44a872c4 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/components/drawable-circle-2d.component.ts @@ -0,0 +1 @@ +export class DrawableCircle2D { name = "DrawableCircle2D"; constructor(public radius: number, public visible: boolean, public fill: string) {} } diff --git a/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/components/position-2d.component.ts b/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/components/position-2d.component.ts new file mode 100644 index 00000000..53857af2 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/components/position-2d.component.ts @@ -0,0 +1 @@ +export class Position2D { name = "Position2D"; constructor(public x: number, public y: number) {} } diff --git a/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/main.ts b/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/main.ts new file mode 100644 index 00000000..e35ae76b --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/main.ts @@ -0,0 +1,28 @@ +import { type ClientRunOptions } from "@nanoforge-dev/common"; +import { NanoforgeFactory } from "@nanoforge-dev/core"; +import { EcsLibrary } from "@nanoforge-dev/ecs/client"; +import { Graphics2DLibrary } from "@nanoforge-dev/graphics-2d"; + +import { DrawableCircle2D } from "./components/drawable-circle-2d.component"; +import { Position2D } from "./components/position-2d.component"; +import { draw2D } from "./systems/draw-2d.system"; + +export const main = async (options: ClientRunOptions): Promise => { + const app = NanoforgeFactory.createClient({ tickRate: 60 }); + + const ecs = new EcsLibrary(); + app.use(ecs); + app.use(new Graphics2DLibrary()); + + await app.init(options); + + const registry = ecs.registry; + + const circle = registry.spawnEntity(); + registry.addComponent(circle, new Position2D(500, 500)); + registry.addComponent(circle, new DrawableCircle2D(75, true, "red")); + + registry.addSystem(draw2D); + + await app.run(); +}; diff --git a/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/systems/draw-2d.system.ts b/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/systems/draw-2d.system.ts new file mode 100644 index 00000000..f284f456 --- /dev/null +++ b/plugins/scene/test/fixtures/adoption/scene/input/apps/client/src/systems/draw-2d.system.ts @@ -0,0 +1 @@ +export function draw2D() {} diff --git a/plugins/settings-ui/src/dialogs/settings/AppearancePanel.svelte b/plugins/settings-ui/src/dialogs/settings/AppearancePanel.svelte new file mode 100644 index 00000000..1d53a0d7 --- /dev/null +++ b/plugins/settings-ui/src/dialogs/settings/AppearancePanel.svelte @@ -0,0 +1,219 @@ + + +
+
+

Theme

+ +
+
+ {#each themes as candidate (candidate.id)} + {@const checked = value(theme) === candidate.id} + + {/each} +
+ +
+

Density

+
+ {#each DENSITIES as choice (choice.value)} + + {/each} +
+
+ +
+

Icons

+