From 4871d1943e26506cf1d75787c65b0a546a0fc35c Mon Sep 17 00:00:00 2001 From: Exelo Date: Fri, 30 Oct 2026 15:09:25 +0100 Subject: [PATCH] fix: improve plugin ecosystem and possibilities --- .github/workflows/alpha-release.yml | 11 +- .github/workflows/pre-release.yml | 11 +- .github/workflows/release.yml | 57 ++++++- CLAUDE.md | 17 ++- apps/editor/e2e/appearance.spec.ts | 15 +- apps/editor/e2e/command-palette.spec.ts | 29 +++- apps/editor/e2e/console.spec.ts | 30 +++- apps/editor/e2e/ecs-live.spec.ts | 6 +- apps/editor/e2e/ecs.spec.ts | 108 +++++++++++++- apps/editor/e2e/file-manager.spec.ts | 37 ++++- apps/editor/e2e/git.spec.ts | 36 +++++ apps/editor/e2e/inspectors.spec.ts | 30 +++- apps/editor/e2e/scene.spec.ts | 8 + apps/editor/playwright.config.ts | 2 + coverage-floors.json | 34 ++--- docs/docs/editor/code-editor.mdx | 2 +- docs/docs/editor/components.mdx | 5 +- docs/docs/editor/files.mdx | 2 +- docs/docs/editor/git.mdx | 3 +- docs/docs/editor/layout.mdx | 2 +- docs/docs/editor/play.mdx | 8 +- docs/docs/editor/scenes.mdx | 2 +- docs/docs/editor/settings.mdx | 2 +- docs/docs/plugins/extension-points.mdx | 3 +- docs/docs/plugins/manifest.mdx | 2 +- docs/specs/ecs.md | 2 +- examples/counter-plugin/package.json | 2 +- knip.json | 4 + packages/protocol/src/git/git.contract.ts | 6 +- packages/protocol/src/git/git.schema.ts | 5 +- packages/protocol/src/project/app-at.ts | 13 ++ packages/protocol/src/project/index.ts | 1 + packages/protocol/test/project/app-at.spec.ts | 20 +++ packages/sdk/.cliff-jumperrc.json | 1 - packages/sdk/README.md | 2 + packages/sdk/cliff.toml | 2 +- packages/sdk/package.json | 30 +++- packages/sdk/scripts/build-types.ts | 141 ++++++++++++++++-- packages/sdk/scripts/build-ui-stub.ts | 42 ++++++ packages/sdk/src/index.ts | 8 +- packages/sdk/src/ui.ts | 2 + packages/sdk/test/index.spec.ts | 1 + packages/sdk/tsconfig.types.json | 12 +- packages/sdk/tsdown.config.ts | 7 +- packages/sdk/turbo.json | 14 ++ packages/server-core/src/git/git-service.ts | 34 ++++- .../rpc/implementation/git.implementation.ts | 4 +- .../server-core/test/git/git-service.spec.ts | 35 ++++- .../settings/src/definition/core-settings.ts | 5 +- packages/ui/src/components/combobox.svelte | 24 +-- .../ui/src/components/context-menu.svelte | 42 +++--- packages/ui/src/components/dialog.svelte | 26 ++-- packages/ui/src/components/index.ts | 1 + packages/ui/src/components/save-as-file.ts | 11 ++ packages/ui/src/components/select.svelte | 26 ++-- packages/ui/src/components/tooltip.svelte | 20 +-- packages/ui/src/theme/theme-service.ts | 16 +- .../keybinding/keybinding-service.ts | 74 ++++++++- .../ui/src/workbench/keybinding/keystroke.ts | 3 + .../ui/src/workbench/setup/setup-shell.ts | 15 +- packages/ui/test/style.svelte.spec.ts | 22 ++- .../workbench/keybinding/keybindings.spec.ts | 28 ++++ plugins/code-editor/src/index.ts | 3 +- .../src/service/code-editor.type.ts | 2 + .../src/session/code-editor-session.ts | 3 + .../src/widgets/script/ScriptScreen.svelte | 11 +- .../command-palette/nanoforge.manifest.json | 42 ++++++ plugins/console/nanoforge.manifest.json | 8 +- plugins/console/src/icons.ts | 3 +- plugins/console/src/index.ts | 6 + plugins/console/src/store/console-store.ts | 23 +++ .../src/widgets/console/ConsoleView.svelte | 39 ++++- .../src/widgets/console/console-filters.ts | 10 +- .../src/widgets/problems/ProblemsView.svelte | 78 +++++++++- .../console/test/store/console-store.spec.ts | 23 +++ plugins/ecs/src/create/create-item.ts | 2 +- plugins/ecs/src/create/item-file-templates.ts | 27 ++++ .../entity-action.extension-point.ts | 22 +++ plugins/ecs/src/index.ts | 9 ++ plugins/ecs/src/layout/layout-diagnostics.ts | 99 ++++++++++++ plugins/ecs/src/live/live-service.ts | 9 +- plugins/ecs/src/model/asset-paths.ts | 25 ++++ plugins/ecs/src/service/ecs-service.ts | 30 +++- .../src/widgets/browser/ItemDetails.svelte | 5 + .../component-panel/ComponentPanel.svelte | 7 +- .../component-panel/ParamEditor.svelte | 22 ++- .../widgets/component-panel/component-docs.ts | 10 +- .../src/widgets/hierarchy/Hierarchy.svelte | 31 +++- .../widgets/hierarchy/LiveEntitiesTree.svelte | 10 +- .../hierarchy/entity-action-entries.ts | 45 ++++++ .../widgets/inspector/ComponentCard.svelte | 14 ++ .../src/widgets/inspector/Inspector.svelte | 3 + .../widgets/inspector/LiveInspector.svelte | 1 + .../src/widgets/inspector/ParamField.svelte | 18 +++ .../src/widgets/inspector/asset-context.ts | 12 ++ .../test/create/item-file-templates.spec.ts | 34 +++++ .../test/layout/layout-diagnostics.spec.ts | 42 ++++++ plugins/ecs/test/live/live-service.spec.ts | 17 ++- plugins/ecs/test/model/asset-paths.spec.ts | 23 +++ .../widgets/entity-action-entries.spec.ts | 60 ++++++++ plugins/file-manager/src/index.ts | 1 + plugins/file-manager/src/menu/file-menu.ts | 2 +- .../src/service/file-manager-service.ts | 23 ++- .../src/service/file-manager-service.type.ts | 3 + plugins/file-manager/test/fixtures/service.ts | 7 +- .../file-manager/test/menu/file-menu.spec.ts | 20 +++ plugins/git/src/actions/create-actions.ts | 17 ++- plugins/git/src/store/git-store.ts | 12 +- .../git/src/widgets/commit/ChangesTab.svelte | 32 +++- .../git/src/widgets/commit/StashTab.svelte | 19 ++- .../git/src/widgets/log/CommitDetails.svelte | 6 + plugins/git/src/widgets/log/LogView.svelte | 23 ++- plugins/git/test/store/git-store.spec.ts | 23 ++- .../inspectors/src/export/inspector-export.ts | 56 +++++++ plugins/inspectors/src/icons.ts | 2 + plugins/inspectors/src/recorder/recorder.ts | 7 + .../src/widgets/network/NetworkView.svelte | 60 +++++++- .../src/widgets/profiler/ProfilerView.svelte | 45 +++++- .../test/export/inspector-export.spec.ts | 50 +++++++ .../inspectors/test/recorder/recorder.spec.ts | 5 + plugins/scene/src/widgets/vars/Vars.svelte | 21 ++- .../dialogs/settings/AppearancePanel.svelte | 82 ++++++---- .../dialogs/settings/ImportExportPage.svelte | 14 +- .../dialogs/settings/SettingsDialog.svelte | 6 +- pnpm-lock.yaml | 52 ++++++- pnpm-workspace.yaml | 1 + scripts/check-workspaces.mjs | 1 + .../example-plugin/nanoforge.manifest.json | 6 + tooling/example-plugin/src/index.ts | 43 +++++- tooling/vite-plugin/.cliff-jumperrc.json | 1 - tooling/vite-plugin/cliff.toml | 2 +- tooling/vite-plugin/package.json | 4 +- 132 files changed, 2331 insertions(+), 306 deletions(-) create mode 100644 packages/protocol/src/project/app-at.ts create mode 100644 packages/protocol/test/project/app-at.spec.ts create mode 100644 packages/sdk/scripts/build-ui-stub.ts create mode 100644 packages/sdk/turbo.json create mode 100644 packages/ui/src/components/save-as-file.ts create mode 100644 plugins/ecs/src/create/item-file-templates.ts create mode 100644 plugins/ecs/src/extension/entity-action.extension-point.ts create mode 100644 plugins/ecs/src/layout/layout-diagnostics.ts create mode 100644 plugins/ecs/src/model/asset-paths.ts create mode 100644 plugins/ecs/src/widgets/hierarchy/entity-action-entries.ts create mode 100644 plugins/ecs/src/widgets/inspector/asset-context.ts create mode 100644 plugins/ecs/test/create/item-file-templates.spec.ts create mode 100644 plugins/ecs/test/layout/layout-diagnostics.spec.ts create mode 100644 plugins/ecs/test/model/asset-paths.spec.ts create mode 100644 plugins/ecs/test/widgets/entity-action-entries.spec.ts create mode 100644 plugins/inspectors/src/export/inspector-export.ts create mode 100644 plugins/inspectors/test/export/inspector-export.spec.ts diff --git a/.github/workflows/alpha-release.yml b/.github/workflows/alpha-release.yml index 26c015fa..0443bea8 100644 --- a/.github/workflows/alpha-release.yml +++ b/.github/workflows/alpha-release.yml @@ -3,6 +3,15 @@ name: Alpha Release on: workflow_dispatch: inputs: + package: + description: The package to release (the SDK and the vite-plugin are versioned on their own) + type: choice + required: true + default: '@nanoforge-dev/editor' + options: + - '@nanoforge-dev/editor' + - '@nanoforge-dev/editor-sdk' + - '@nanoforge-dev/editor-vite-plugin' dry_run: description: Perform a dry run? type: boolean @@ -35,7 +44,7 @@ jobs: - name: Release alpha uses: ./node_modules/@nanoforge-dev/actions/actions/release-dev with: - package: '@nanoforge-dev/editor' + package: ${{ inputs.package }} tag: alpha dry: ${{ inputs.dry_run }} env: diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 0524548e..55fa8b79 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -3,6 +3,15 @@ name: Pre-Release on: workflow_dispatch: inputs: + package: + description: The package to release (the SDK and the vite-plugin are versioned on their own) + type: choice + required: true + default: '@nanoforge-dev/editor' + options: + - '@nanoforge-dev/editor' + - '@nanoforge-dev/editor-sdk' + - '@nanoforge-dev/editor-vite-plugin' version: description: New version for the packages type: string @@ -40,7 +49,7 @@ jobs: - name: Create release PR uses: ./node_modules/@nanoforge-dev/actions/actions/create-packages-release-pr with: - packages: '@nanoforge-dev/editor' + packages: ${{ inputs.package }} version: ${{ inputs.version }} dry: ${{ inputs.dry_run }} env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6e0334e..6e257c9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: release: name: Release runs-on: ubuntu-latest - if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/') + if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/editor@') steps: - name: Checkout repository uses: actions/checkout@v7 @@ -48,23 +48,70 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The SDK and the vite-plugin are versioned on their own (releases/editor-sdk@1.2.0), tagged + # @nanoforge-dev/@. Only the editor's release pushes the image and the docs. + release-single-package: + name: Release single package + runs-on: ubuntu-latest + if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/') && !startsWith(github.head_ref, 'releases/editor@') + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Prepare + uses: ./.github/actions/prepare + env: + NODE_ENV: development + PUBLIC_MODE: OFFLINE + + - name: Get package name + id: package + shell: bash + env: + BRANCH: ${{ github.head_ref }} + run: | + name="${BRANCH#releases/}" + echo "name=@nanoforge-dev/${name%%@*}" >> "$GITHUB_OUTPUT" + + - name: Release package + uses: ./node_modules/@nanoforge-dev/actions/actions/release-packages + with: + packages: ${{ steps.package.outputs.name }} + latest: false + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + beta-release: - name: Beta release + name: Beta release (${{ matrix.package }}) runs-on: ubuntu-latest if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && !startsWith(github.head_ref, 'releases/') + strategy: + fail-fast: false + matrix: + include: + - package: '@nanoforge-dev/editor' + paths: '^(apps|packages|plugins|tooling)/|^(pnpm-lock|pnpm-workspace)\.yaml$|^turbo\.json$' + # The SDK bundles the editor packages it re-exports. + - package: '@nanoforge-dev/editor-sdk' + paths: '^packages/|^(pnpm-lock|pnpm-workspace)\.yaml$' + - package: '@nanoforge-dev/editor-vite-plugin' + paths: '^tooling/vite-plugin/|^packages/kernel/|^(pnpm-lock|pnpm-workspace)\.yaml$' steps: - name: Checkout repository uses: actions/checkout@v7 with: fetch-depth: 0 - # A pull request that changes no code (docs, CI, issue forms) publishes no beta. + # A pull request that changes none of the package's code (docs, CI, issue forms) publishes no beta of it. - name: Check for code changes id: changes shell: bash + env: + PATHS: ${{ matrix.paths }} run: | if git diff --name-only "${{ github.event.pull_request.merge_commit_sha }}^" "${{ github.event.pull_request.merge_commit_sha }}" \ - | grep -qE '^(apps|packages|plugins|tooling)/|^(pnpm-lock|pnpm-workspace)\.yaml$|^turbo\.json$'; then + | grep -qE "$PATHS"; then echo "code=true" >> "$GITHUB_OUTPUT" else echo "code=false" >> "$GITHUB_OUTPUT" @@ -81,7 +128,7 @@ jobs: if: steps.changes.outputs.code == 'true' uses: ./node_modules/@nanoforge-dev/actions/actions/release-dev with: - package: '@nanoforge-dev/editor' + package: ${{ matrix.package }} tag: beta env: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index f8b4df64..c06f6a3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ The NanoForge editor, rewritten from scratch. The full plan, with status per pha Ask the user before building anything that depends on them. - **Repository standards follow the engine** (`../engine`, `origin/main`): - - `package.json` fields in the engine's order (`$schema`, `name`, `version`, `description`, `keywords`, `homepage`, `bugs`, `license`, `contributors`, `files`, `main`, `module`, `types`, `exports`, `type`, `directories`, `repository`, `funding`, `scripts`, `dependencies`, `peerDependencies`, `devDependencies`, `packageManager`, `engines`, `private`, `publishConfig`, `lint-staged`), dependencies sorted, Node 26 and pnpm 12; + - `package.json` fields in the engine's order (`$schema`, `name`, `version`, `description`, `keywords`, `homepage`, `bugs`, `license`, `contributors`, `files`, `main`, `module`, `types`, `exports`, `type`, `directories`, `repository`, `funding`, `scripts`, `dependencies`, `peerDependencies`, `peerDependenciesMeta`, `devDependencies`, `packageManager`, `engines`, `private`, `publishConfig`, `lint-staged`), dependencies sorted, Node 26 and pnpm 12; - every workspace has the engine's `README.md` (badges, About, Links, Contributing, Help), `LICENSE` and `.prettierignore`; published ones (`apps/editor`, `packages/sdk`, `tooling/vite-plugin`) also `CHANGELOG.md`, `cliff.toml` and `.cliff-jumperrc.json`; - each workspace has a `:` label (`.github/labels.yml`, `labeler.yml`, `issue-labeler.yml`) and a choice in the issue forms. A new workspace gets all of them; - the editor keeps single quotes (its Prettier override of the engine config). @@ -103,7 +103,7 @@ pnpm e2e:last-failure # error-context.md of the last failed test, its last fra - `viewport` (Game and Scene screens) - `history-panel` - `settings-ui` - - `ecs` + - `ecs` (its extension points: `ecs.sources`, `ecs.fieldEditors`, `ecs.paramPresets`, `ecs.entityActions`, whose `when` sees `ecsEntity` and `ecsComponent:`; a component's layout warnings are diagnostics of source `@nanoforge/ecs`) - `console` (Console and Problems panels) - `command-palette` (the palette, and the Keyboard shortcuts page of Settings) - `inspectors` (Profiler, Network and World panels) @@ -155,6 +155,13 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na 3. its name is accepted as a commit scope automatically. - **Plugins only import the SDK** (ESLint enforces it). If a plugin needs something from core, export it from the SDK: add a token in the owning package, provide it in `apps/editor/src/lib/editor/create-editor.ts`, re-export it, and update the snapshot. - **Dialogs from a plugin:** mount a Svelte component with `mount`/`unmount` from a command (see `plugins/settings-ui/src/index.ts`). +- **Published SDK and vite-plugin (M7).** + - They are versioned on their own and tagged `@nanoforge-dev/@` (the editor keeps bare `` tags). Pre-release and alpha take the package; a `releases/editor@…` branch releases the editor (image and docs), any other `releases/@…` that package; betas run per package, each when its own code changed. + - The SDK bundles every editor package it reaches (tsdown `alwaysBundle`, subpaths included). `scripts/build-types.ts` emits the declarations they reach (Svelte components through svelte2tsx), rewrites their specifiers to relative paths, prunes the rest and type-checks what is left: a clash in emitted declarations, or an import of a package that is not published, fails the build. + - `./ui` is published as types plus a stub whose exports throw when used (`scripts/build-ui-stub.ts`): plugins built with the vite-plugin take the editor's own module, and their unit tests mock it. + - The acceptance test: `pnpm pack` both, install the tarballs in a copy of `examples/counter-plugin` outside the workspace, then svelte-check, `vite build`, and a node import of `/`, `/worker` and `/ui`. +- **Code editor side panels:** `codeEditor.open(path, { sidePanel: 'ecs.component' })` opens a file with that panel shown (_Edit layout_). +- **Kit helpers:** `saveAsFile(name, content, type)` hands the browser a file to save (exports of the Console, inspectors and settings). ### Folder trust (local editors) @@ -202,6 +209,8 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na - **Actions and shortcuts:** - An _action_ is a command with its arguments (`actionId`). The palette and the keymap page list `listActions`: titled commands, plus menu entries that give arguments. - A command that needs arguments sets `palette: false` (in `commands.register` or in the manifest), or it shows in the palette and does nothing. + - A modifier twice is a double tap (`Shift Shift`, the IntelliJ preset's palette): a modifier pressed and released alone twice within 500 ms. A single tap never starts a chord. + - A browser tab never gets Ctrl+T, Ctrl+N, Ctrl+Shift+N, Ctrl+W, Ctrl+F4 or Ctrl+F5 (and Linux browsers keep Alt+1…9): don't bind them, even in a preset. - Write default shortcuts with `Mod` (Ctrl, or Cmd on macOS). The keymap is layered: `KEYBINDINGS`, the preset, the account's `keymap.overrides`, this machine's. Edit lists with `setShortcut`, never by hand. - The kit's `Select` trigger is a `button` named by its label (not a `combobox`): `getByRole('button', { name: 'Preset' })`. @@ -256,6 +265,8 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na - **Formatting:** each workspace runs its own Prettier (`lint`, `format`, its `lint-staged`) with its own `.prettierignore`; the root one skips `apps/`, `packages/`, `plugins/` and `tooling/`. A folder Prettier must not touch (test fixtures: golden tests compare their text) goes in that workspace's `.prettierignore`. - **Doc comments:** TypeScript counts a comment right after `(` or `,` as that token's trailing comment. Read param docs with `leadingDocComments` (editor-meta), not ts-morph's `getLeadingCommentRanges`. - **Worker mirror:** it follows document changes asynchronously. `CodeService.analyze`/`transform` mirror the file's current text first; code that talks to the engine directly must too. +- **Theme in e2e:** `appearance.theme` defaults to `system` (light or dark as the system is), so `playwright.config.ts` sets `colorScheme: 'dark'`: the suite runs on the dark theme. A test of the light one emulates it (`page.emulateMedia`). +- **`when` clauses:** the left of `in` is a key, never a string (`'Position' in list` does not parse): give a key per member instead (`ecsComponent:Position`, like `engineLib:`). Lists of contributions evaluate each `when` in a `try`, so one bad clause hides its entry, not the menu. - **Saved layout and widget state in e2e:** they outlive a test (server-side, saved after 500 ms). A test that changes tabs or expands folders resets them at the end and waits before the page closes (see `e2e/ecs.spec.ts`). - **Plugin activation order changes timing:** adding an `onStartup` plugin delays the others, which exposed focus races. Test the full suite after adding one. - **Workspace changes (Project screen):** @@ -272,6 +283,8 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na - **File manager:** - Fast repeated commands race: they run through a serialized queue. - After a drag and drop, focus goes back to the tree (`refocus`), so Ctrl+Z reaches the Files history rather than the layout. + - `FILE_TEMPLATES` entries are offered where their `when` holds: it sees `fileFolder` and `appType` (`client`, `server`, `lib`, empty outside apps; `appAt` finds the app of a path). The ECS plugin's _Component_ and _System_ need an app. +- **Asset params:** an `@asset .png` param picks among the app's assets, written as the game loads them: the CLI copies the assets folder to the build's root, so `/textures/hero.png` is `/textures/hero.png`. ## Status (2026-10-02) diff --git a/apps/editor/e2e/appearance.spec.ts b/apps/editor/e2e/appearance.spec.ts index d2dbcae4..abd7fb22 100644 --- a/apps/editor/e2e/appearance.spec.ts +++ b/apps/editor/e2e/appearance.spec.ts @@ -21,7 +21,7 @@ const restore = async (page: Page) => { 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: 'Match the system' }).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(); @@ -41,7 +41,7 @@ test('themes, density and icons from a plugin, chosen in Settings › Appearance 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( + await expect(themes.getByRole('radio', { name: 'Match the system' })).toHaveAttribute( 'aria-checked', 'true', ); @@ -79,8 +79,13 @@ test('themes, density and icons from a plugin, chosen in Settings › Appearance } finally { await restore(page); } - await expect - .poll(() => page.evaluate(() => document.documentElement.dataset.nfTheme)) - .toBe('nanoforge-dark'); + const theme = () => page.evaluate(() => document.documentElement.dataset.nfTheme); + await expect.poll(theme).toBe('nanoforge-dark'); expect(await variable(page, '--nf-control-height')).toBe('26px'); + + /** Match the system (the default) follows the light or dark mode of the system as it changes. */ + await page.emulateMedia({ colorScheme: 'light' }); + await expect.poll(theme).toBe('nanoforge-light'); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect.poll(theme).toBe('nanoforge-dark'); }); diff --git a/apps/editor/e2e/command-palette.spec.ts b/apps/editor/e2e/command-palette.spec.ts index dbc7292b..ff797895 100644 --- a/apps/editor/e2e/command-palette.spec.ts +++ b/apps/editor/e2e/command-palette.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject, resetLayout, saved, showPanel } from './helpers'; +import { openProject, panelTab, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -212,3 +212,30 @@ test('keymap: chords, conflicts, conditions, removals, presets, kept on this mac await expect(previous.locator('kbd')).toHaveCount(0); await settings(page).getByRole('button', { name: 'Cancel' }).click(); }); + +test('keymap: the IntelliJ preset, Shift Shift and Alt+1', async ({ page }) => { + await open(page); + await openKeymap(page); + await settings(page).getByRole('button', { name: 'Preset' }).click(); + await page.getByRole('option', { name: 'IntelliJ' }).click(); + await find(page, 'command palette'); + await expect(shortcut(page, 'View: Command palette…')).toContainText('Shift Shift'); + await settings(page).getByRole('button', { name: 'OK' }).click(); + await expect(settings(page)).toHaveCount(0); + + await showPanel(page, 'Hierarchy'); + await page.locator('[data-nf-part="screen"]').click(); + await page.keyboard.press('Alt+1'); + await expect(panelTab(page, 'Files')).toHaveAttribute('aria-selected', 'true'); + + /** One tap types on; two in a row open the palette. */ + await page.keyboard.press('Shift'); + await page.waitForTimeout(700); + await page.keyboard.press('Shift'); + await expect(box(page)).toHaveCount(0); + await page.keyboard.press('Shift'); + await page.keyboard.press('Shift'); + await expect(box(page)).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(box(page)).toHaveCount(0); +}); diff --git a/apps/editor/e2e/console.spec.ts b/apps/editor/e2e/console.spec.ts index 55591c75..59c6af7f 100644 --- a/apps/editor/e2e/console.spec.ts +++ b/apps/editor/e2e/console.spec.ts @@ -69,6 +69,21 @@ test('console: groups, levels, search, repeats, values and file links', async ({ await expect(hello).toContainText('Hello state'); await page.getByLabel('Search console').fill(''); + /** Ctrl+Alt+E: errors only, then every level again. */ + await page.keyboard.press('Control+Alt+e'); + await expect(level(page, 'Errors')).toHaveAttribute('aria-pressed', 'true'); + await expect(level(page, 'Info')).toHaveAttribute('aria-pressed', 'false'); + await expect(hello.filter({ hasText: 'Hello state' })).toHaveCount(0); + await page.keyboard.press('Control+Alt+e'); + await expect(level(page, 'Info')).toHaveAttribute('aria-pressed', 'true'); + await expect(hello.filter({ hasText: 'Hello state' })).toHaveCount(1); + + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save shown lines' }).click(); + const file = await download; + expect(file.suggestedFilename()).toMatch(/^console-[\d-]+\.log$/); + expect(readFileSync((await file.path())!, 'utf8')).toContain('Hello state'); + await page.getByRole('button', { name: 'Sources' }).click(); await page.getByRole('menuitem', { name: 'Plugins' }).click(); await page.getByRole('menuitem', { name: '@acme/hello' }).click(); @@ -129,7 +144,20 @@ test('problems: a file that is not open, grouped by file, opens at its position' .click(); await page.getByLabel('Search problems').fill(''); - await problem.click(); + /** One Tab stop: arrows go through files and problems, Enter opens one. */ + const header = file.getByRole('button', { name: /scratch\/broken\.ts/ }); + await header.focus(); + await page.keyboard.press('ArrowRight'); + await expect(problem).toBeFocused(); + await expect(header).toHaveAttribute('tabindex', '-1'); + await page.keyboard.press('ArrowLeft'); + await expect(header).toBeFocused(); + await page.keyboard.press('ArrowLeft'); + await expect(problem).toHaveCount(0); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowDown'); + await expect(problem).toBeFocused(); + await page.keyboard.press('Enter'); await expect(page.getByRole('tab', { name: 'broken.ts' })).toBeVisible(); await expect(page.locator('.monaco-editor .active-line-number')).toHaveText('2'); diff --git a/apps/editor/e2e/ecs-live.spec.ts b/apps/editor/e2e/ecs-live.spec.ts index 69c662e3..253c0451 100644 --- a/apps/editor/e2e/ecs-live.spec.ts +++ b/apps/editor/e2e/ecs-live.spec.ts @@ -129,8 +129,12 @@ test('moves an entity by dragging it in the Game screen, and applies it to the c await expect.poll(async () => Number(await x.inputValue())).toBeLessThan(870); expect(Number(await x.inputValue())).toBeGreaterThan(840); expect(readFileSync(MAIN, 'utf8')).toBe(original); - await page.getByRole('button', { name: 'Apply to code' }).click(); + /** The Hierarchy applies every entity's changes while the game plays. */ + await showPanel(page, 'Hierarchy'); + const applyAll = page.getByRole('button', { name: 'Apply all changes to code' }); + await applyAll.click(); await expect.poll(() => readFileSync(MAIN, 'utf8')).toMatch(/new Position\(8[4-6]\d, 0\)/); + await expect(applyAll).toBeDisabled(); await move.click(); await expect(overlay).toHaveCount(0); diff --git a/apps/editor/e2e/ecs.spec.ts b/apps/editor/e2e/ecs.spec.ts index c197c88d..e186cc07 100644 --- a/apps/editor/e2e/ecs.spec.ts +++ b/apps/editor/e2e/ecs.spec.ts @@ -93,16 +93,18 @@ test('edits the layout of a component from its code page', async ({ page }) => { 'Group 1', ); await panel.getByRole('button', { name: `Preset of ${param}` }).click(); - await page.getByRole('option', { name: 'vector' }).click(); + /** Presets of plugins (`ecs.paramPresets`) are offered with the built-in ones. */ + await expect(page.getByRole('option', { name: 'Range' })).toBeVisible(); + await page.getByRole('option', { name: 'Vector' }).click(); await expect(panel.getByRole('button', { name: `Preset of ${param}` })).toContainText( - 'vector', + 'Vector', ); } await panel.getByRole('button', { name: 'Preset of y' }).focus(); await page.keyboard.press('Control+z'); await expect(panel.getByRole('button', { name: 'Preset of y' })).toContainText('None'); await page.keyboard.press('Control+y'); - await expect(panel.getByRole('button', { name: 'Preset of y' })).toContainText('vector'); + await expect(panel.getByRole('button', { name: 'Preset of y' })).toContainText('Vector'); await expect( panel @@ -277,6 +279,106 @@ test('opens a component in code at its declaration, shown in the Component panel await expect(page.locator('.monaco-editor .view-lines')).toContainText('export class Position'); }); +test('edit layout opens the component file with its Component panel', async ({ page }) => { + await entity(page, 'ball').click(); + await showPanel(page, 'Inspector'); + const card = page.getByRole('region', { name: 'Position' }); + await card.getByRole('button', { name: 'Position actions' }).click(); + await page.getByRole('menuitem', { name: 'Edit layout' }).click(); + const panel = page.getByRole('complementary', { name: 'Component panel' }); + await expect(panel.getByRole('button', { name: 'Component to edit' })).toContainText('Position'); + await panel.getByRole('button', { name: 'Hide the Component panel' }).click(); + await expect(panel).toHaveCount(0); + + const components = page.getByRole('region', { name: 'Components', exact: true }); + await showPanel(page, 'Components'); + await components.getByRole('button', { name: /^C Velocity\b/ }).click(); + await components.getByRole('button', { name: 'Edit layout' }).click(); + await expect(panel.getByRole('button', { name: 'Component to edit' })).toContainText('Velocity'); +}); + +test('picks an asset of the accepted type for an asset param', async ({ page }) => { + const SKIN = join(PONG, 'apps/client/src/components/skin.ts'); + const NOTES = join(PONG, 'apps/client/assets/notes.txt'); + writeFileSync( + SKIN, + [ + '/**', + ' * A picture drawn for the entity.', + ' *', + ' * @component', + ' * @side client', + ' */', + 'export class Skin {', + ' name = "Skin";', + '', + ' constructor(', + ' /** The picture. @asset .png */', + ' public image = "",', + ' ) {}', + '}', + '', + ].join('\n'), + ); + writeFileSync(NOTES, 'not a picture\n'); + try { + await entity(page, 'ball').click(); + await showPanel(page, 'Inspector'); + await page.getByRole('combobox', { name: 'Add component' }).click(); + await page.keyboard.type('Skin'); + /** The catalog reads the new file in the background. */ + await page.getByRole('option', { name: /^Skin/ }).click({ timeout: 15_000 }); + const card = page.getByRole('region', { name: 'Skin' }); + await expect(card.getByRole('combobox', { name: 'image' })).toBeVisible(); + await card.getByRole('button', { name: 'Show options' }).click(); + await expect(page.getByRole('option', { name: '/logo.png' })).toBeVisible(); + await expect(page.getByRole('option', { name: '/notes.txt' })).toHaveCount(0); + await page.getByRole('option', { name: '/logo.png' }).click(); + await expect.poll(main).toContain('new Skin("/logo.png")'); + } finally { + rmSync(SKIN, { force: true }); + rmSync(NOTES, { force: true }); + } +}); + +test('a layout warning of a component shows in Problems, at its declaration', async ({ page }) => { + const COMPONENTS = join(PONG, 'apps/client/src/components/components.ts'); + const before = readFileSync(COMPONENTS, 'utf8'); + const position = + 'export class Position {\n name = "Position";\n x: number;\n y: number;\n\n constructor(x: number'; + expect(before).toContain(position); + writeFileSync( + COMPONENTS, + before.replace( + position, + position.replace('constructor(x: number', 'constructor(/** @preset vector.x */ x: number'), + ), + ); + try { + await showPanel(page, 'Problems'); + const file = page + .getByRole('list', { name: 'Problems' }) + .getByRole('listitem', { name: 'apps/client/src/components/components.ts' }); + const warning = file.getByRole('button', { name: /Position: The preset "vector" needs/ }); + await expect(warning).toBeVisible({ timeout: 15_000 }); + await warning.click(); + await expect(page.locator('.monaco-editor .active-line-number')).toHaveText( + String(before.slice(0, before.indexOf('export class Position')).split('\n').length), + ); + } finally { + writeFileSync(COMPONENTS, before); + } + await expect( + page.getByRole('button', { name: /Position: The preset "vector" needs/ }), + ).toHaveCount(0, { timeout: 15_000 }); +}); + +test('runs an action another plugin adds to entities', async ({ page }) => { + await entity(page, 'ball').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Say hello to it' }).click(); + await expect(page.getByText('Hello, ball!').last()).toBeVisible(); +}); + test('switches the app, and adds and removes a system', async ({ page }) => { const SERVER = join(PONG, 'apps/server/src/main.ts'); const serverBefore = readFileSync(SERVER, 'utf8'); diff --git a/apps/editor/e2e/file-manager.spec.ts b/apps/editor/e2e/file-manager.spec.ts index ec722832..e1928506 100644 --- a/apps/editor/e2e/file-manager.spec.ts +++ b/apps/editor/e2e/file-manager.spec.ts @@ -1,7 +1,7 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject } from './helpers'; +import { openProject, saved } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -131,6 +131,39 @@ test('creates from a template and imports dropped files', async ({ page }) => { await expect.poll(() => existsSync(join(PONG, 'scratch/target/imported.txt'))).toBe(true); }); +test('creates a component from the ECS template, only in an app', async ({ page }) => { + const created = join(PONG, 'apps/client/src/components/power-up.ts'); + try { + await open(page); + await expand(page, 'scratch'); + await row(page, 'scratch').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'New' }).click(); + await expect(page.getByRole('menuitem', { name: 'Greeting…' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Component…' })).toHaveCount(0); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + + await expand(page, 'apps', 'client', 'src'); + await row(page, 'components').click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'New' }).click(); + await page.getByRole('menuitem', { name: 'Component…' }).click(); + await page.getByRole('dialog').getByRole('textbox').fill('PowerUp'); + await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click(); + await expect.poll(() => existsSync(created)).toBe(true); + const text = readFileSync(created, 'utf8'); + expect(text).toContain('export class PowerUp'); + expect(text).toContain('@side client'); + await expect(page.getByRole('tab', { name: /power-up\.ts/ })).toBeVisible(); + /** Expanded folders are saved with the panel: fold them back for the next tests. */ + await row(page, 'apps').click(); + await page.keyboard.press('ArrowLeft'); + await expect(row(page, 'apps')).toHaveAttribute('aria-expanded', 'false'); + await saved(page); + } finally { + rmSync(created, { force: true }); + } +}); + test('shows installed packages read-only', async ({ page }) => { await open(page); const packages = page.getByRole('region', { name: 'Packages' }); diff --git a/apps/editor/e2e/git.spec.ts b/apps/editor/e2e/git.spec.ts index fcd3fe1b..652cdc5c 100644 --- a/apps/editor/e2e/git.spec.ts +++ b/apps/editor/e2e/git.spec.ts @@ -326,6 +326,42 @@ test('amends the last commit from its message, and offers earlier messages', asy await message(page).fill(''); }); +test('shows the commit body, copies a path, keeps the commit and branch shown', async ({ + page, +}) => { + initRepository(); + git(PONG, 'branch', 'feature'); + writeFileSync(join(PONG, FILE), 'second\n'); + git(PONG, 'commit', '--quiet', '-am', 'second commit', '-m', 'Why: the body says.'); + await open(page); + + writeFileSync(join(PONG, OTHER_FILE), 'changed\n'); + const other = row(page, 'Changes', OTHER_FILE); + await other.click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Copy path' }).click(); + await expect(page.getByText(`Copied ${OTHER_FILE}`).last()).toBeVisible(); + + await showPanel(page, 'Git'); + const second = commits(page).getByRole('row', { name: /second commit/ }); + await second.click(); + const details = logPanel(page).getByRole('complementary', { name: 'Commit details' }); + await expect(details.getByText('Why: the body says.')).toBeVisible(); + const local = logPanel(page).getByRole('list', { name: 'Local branches' }); + await local.getByRole('button', { name: 'feature', exact: true }).click(); + await expect(second).toHaveCount(0); + const first = commits(page).getByRole('row', { name: /first commit/ }); + await first.click(); + await expect(first).toHaveAttribute('aria-selected', 'true'); + await saved(page); + + await page.reload(); + await expect(first).toHaveAttribute('aria-selected', 'true'); + await expect(commits(page).getByRole('row', { name: /second commit/ })).toHaveCount(0); + await expect(details.getByRole('heading', { name: 'first commit' })).toBeVisible(); + await logPanel(page).getByRole('button', { name: 'HEAD (Current Branch)' }).click(); + await expect(commits(page).getByRole('row', { name: /second commit/ })).toBeVisible(); +}); + test('stashes changes and pops them back', async ({ page }) => { initRepository(); await open(page); diff --git a/apps/editor/e2e/inspectors.spec.ts b/apps/editor/e2e/inspectors.spec.ts index dd4812a9..58229083 100644 --- a/apps/editor/e2e/inspectors.spec.ts +++ b/apps/editor/e2e/inspectors.spec.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { openProject, resetLayout, showPanel } from './helpers'; @@ -139,6 +139,34 @@ test.describe('with the engine', () => { page.getByRole('status').filter({ hasText: /^\s*0 of \d+ packets/ }), ).toBeVisible(); + /** Pause keeps the list as it was, Export saves what it shows, Clear starts it over. */ + await direction.getByRole('button', { name: 'All', exact: true }).click(); + await page + .getByRole('group', { name: 'Transport' }) + .getByRole('button', { name: 'All', exact: true }) + .click(); + await page.getByRole('textbox', { name: 'Search payloads' }).fill(''); + const network = page.getByRole('region', { name: 'Network', exact: true }); + const total = async () => + Number(/of (\d+) packets/.exec((await count.textContent()) ?? '')?.[1]); + await network.getByRole('button', { name: 'Pause', exact: true }).click(); + const paused = await total(); + await page.waitForTimeout(1000); + expect(await total()).toBe(paused); + const download = page.waitForEvent('download'); + await network.getByRole('button', { name: 'Export' }).click(); + await page.getByRole('menuitem', { name: 'Save as CSV' }).click(); + const csv = await download; + expect(csv.suggestedFilename()).toMatch(/^network-[\d-]+\.csv$/); + const rows = readFileSync((await csv.path())!, 'utf8') + .trim() + .split('\n'); + expect(rows[0]).toBe('time,direction,transport,client,size,payload hex,payload text'); + expect(rows).toHaveLength(paused + 1); + await network.getByRole('button', { name: 'Resume' }).click(); + await network.getByRole('button', { name: 'Clear', exact: true }).click(); + await expect.poll(total).toBeLessThan(paused); + await stop(page); await leave(page); }); diff --git a/apps/editor/e2e/scene.spec.ts b/apps/editor/e2e/scene.spec.ts index 7785028b..417a3099 100644 --- a/apps/editor/e2e/scene.spec.ts +++ b/apps/editor/e2e/scene.spec.ts @@ -221,8 +221,16 @@ test.describe('scenes', () => { .poll(() => readFileSync(join(SCENES, 'run.ts'), 'utf8')) .toContain('vars.init("balls", 3)'); + /** An unused var goes at once; a used one only once the warning is confirmed. */ + await list.getByRole('listitem', { name: 'combo', exact: true }).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Remove' }).click(); + await expect.poll(vars).not.toContain('combo'); await list.getByRole('listitem', { name: 'balls', exact: true }).click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Remove' }).click(); + const warning = page.getByText(/^balls is used \d+ times in the code$/).last(); + await expect(warning).toBeVisible(); + expect(vars()).toContain('balls: number;'); + await page.getByRole('button', { name: 'Remove anyway' }).last().click(); await expect.poll(vars).not.toContain('balls'); const undeclared = page.getByRole('list', { name: 'Undeclared vars' }); await expect(undeclared.getByRole('listitem', { name: 'balls', exact: true })).toBeVisible(); diff --git a/apps/editor/playwright.config.ts b/apps/editor/playwright.config.ts index 6e3d1287..5f06e666 100644 --- a/apps/editor/playwright.config.ts +++ b/apps/editor/playwright.config.ts @@ -42,6 +42,8 @@ export default defineConfig({ use: { baseURL: `http://127.0.0.1:${PORT}`, viewport: { width: 1440, height: 860 }, + /** The theme follows the system by default: the tests run on the dark one. */ + colorScheme: 'dark', trace: 'retain-on-failure', }, projects: [ diff --git a/coverage-floors.json b/coverage-floors.json index 64d9bdd6..8bbaff82 100644 --- a/coverage-floors.json +++ b/coverage-floors.json @@ -48,26 +48,26 @@ "branches": 75 }, "@nanoforge-dev/editor-plugin-console": { - "lines": 66, - "statements": 65, - "functions": 55, - "branches": 74 + "lines": 70, + "statements": 69, + "functions": 61, + "branches": 75 }, "@nanoforge-dev/editor-plugin-ecs": { "lines": 62, "statements": 59, - "functions": 52, + "functions": 53, "branches": 54 }, "@nanoforge-dev/editor-plugin-file-manager": { "lines": 63, "statements": 60, "functions": 52, - "branches": 62 + "branches": 63 }, "@nanoforge-dev/editor-plugin-git": { "lines": 39, - "statements": 37, + "statements": 38, "functions": 25, "branches": 43 }, @@ -78,10 +78,10 @@ "branches": 99 }, "@nanoforge-dev/editor-plugin-inspectors": { - "lines": 74, - "statements": 74, - "functions": 66, - "branches": 82 + "lines": 77, + "statements": 77, + "functions": 71, + "branches": 83 }, "@nanoforge-dev/editor-plugin-packages": { "lines": 13, @@ -114,10 +114,10 @@ "branches": 70 }, "@nanoforge-dev/editor-protocol": { - "lines": 18, - "statements": 20, - "functions": 87, - "branches": 92 + "lines": 22, + "statements": 24, + "functions": 90, + "branches": 93 }, "@nanoforge-dev/editor-rpc": { "lines": 86, @@ -146,14 +146,14 @@ "@nanoforge-dev/editor-settings": { "lines": 76, "statements": 73, - "functions": 64, + "functions": 65, "branches": 70 }, "@nanoforge-dev/editor-ui": { "lines": 47, "statements": 47, "functions": 43, - "branches": 39 + "branches": 40 }, "@nanoforge-dev/editor-vite-plugin": { "lines": 83, diff --git a/docs/docs/editor/code-editor.mdx b/docs/docs/editor/code-editor.mdx index 40e2a87e..30fddda6 100644 --- a/docs/docs/editor/code-editor.mdx +++ b/docs/docs/editor/code-editor.mdx @@ -43,7 +43,7 @@ Definitions inside engine packages (`@nanoforge-dev/*`) and installed packages o ## Errors -TypeScript errors are underlined in the code and listed in the **Problems** panel, for every file of the project, open or not. A failed build adds its errors there too. +TypeScript errors are underlined in the code and listed in the **Problems** panel, for every file of the project, open or not. A failed build adds its errors there too. In the panel, the arrow keys go through files and problems (Left and Right fold a file), and `Enter` opens one. ## Formatting diff --git a/docs/docs/editor/components.mdx b/docs/docs/editor/components.mdx index ae574830..4ca8c8a6 100644 --- a/docs/docs/editor/components.mdx +++ b/docs/docs/editor/components.mdx @@ -89,15 +89,16 @@ export class Position { | Tag | Effect | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `@group Name` | Puts the parameter in a foldable group. Declare the group in the class comment to give it a color and a description. | -| `@preset vector.x` / `vector.y` | Shows several parameters as one control. Presets: `vector`, `size`, `color`. | +| `@preset vector.x` / `vector.y` | Shows several parameters as one control. Presets: `vector`, `size`, `color`, and those of plugins. | | `@label Text` | The name shown instead of the parameter's. | | `@color orange` | Colors the name. | | `@hidden` | Hidden by default; _Show hidden_ in the inspector reveals it. | +| `@asset .png .jpg` | A text parameter that names an asset: the inspector offers the app's assets of these types (`/textures/hero.png`). | | The comment's text | Shown behind the ⓘ next to the name. | ### Without writing tags -Open the component's file: the **Component** panel beside the code (a toggle in the editor's toolbar, or _Edit layout_ in the inspector's ⋯ menu) has a control for each of these, and a preview. It writes the tags for you. +Open the component's file: the **Component** panel beside the code (a toggle in the editor's toolbar, or _Edit layout_ in the inspector's ⋯ menu and in the Components panel) has a control for each of these, and a preview. It writes the tags for you. A tag that can't apply (a preset missing a slot) is a warning on the component, in the panel, in the code and in **Problems**. ## Two components with the same name diff --git a/docs/docs/editor/files.mdx b/docs/docs/editor/files.mdx index 9ed3734e..183d16e5 100644 --- a/docs/docs/editor/files.mdx +++ b/docs/docs/editor/files.mdx @@ -12,7 +12,7 @@ Double-click a file, or select it and press `Enter`. Code and text files open in ## Create -The **+** button, or right-click a folder and **New**: a file, a folder, or a template. Plugins add templates: the ECS plugin adds _Component_ and _System_, which create the file with its documentation tags. +The **+** button, or right-click a folder and **New**: a file, a folder, or a template. Plugins add templates: the ECS plugin adds _Component_ and _System_ in the folders of an app or a shared library, which create the file with its documentation tags (`@side` from the app). ## Organize diff --git a/docs/docs/editor/git.mdx b/docs/docs/editor/git.mdx index efc1e940..d18136ce 100644 --- a/docs/docs/editor/git.mdx +++ b/docs/docs/editor/git.mdx @@ -27,6 +27,7 @@ The **Commit** panel on the left lists what changed since the last commit, as fi | Undo your changes to files | Select them, then **Rollback**. It asks first: it cannot be undone. | | Start tracking a new file | Right-click › Add to VCS | | Ignore a file | Right-click › Add to .gitignore | +| Copy a file's path | Right-click › Copy path | | Put changes aside | The **Stash** tab: Stash changes, then Apply, Pop or Drop | Checking a file only chooses what is committed. Selecting rows (click, `Ctrl`+click, `Shift`+click, or a click on a group to select all its files) is what Rollback and the other actions act on. @@ -47,7 +48,7 @@ The **Git** panel at the bottom has three parts: - **Branches**: the current one, local ones, and remote ones. Double-click a branch to check it out. Right-click for _New branch from…_, _Update_, _Push…_, _Delete_. - **Commits** of the selected branch, with a filter by text, author and date. -- **Changed files** of the selected commit, and its details. Double-click a file to compare. +- **Changed files** of the selected commit, and its details: the whole message, hash, author, date. Double-click a file to compare. The panel keeps the selected branch and commit. _VCS › New branch…_ creates a branch from the current commit and switches to it. diff --git a/docs/docs/editor/layout.mdx b/docs/docs/editor/layout.mdx index 14026369..68c70d43 100644 --- a/docs/docs/editor/layout.mdx +++ b/docs/docs/editor/layout.mdx @@ -51,4 +51,4 @@ With _Settings › Editor › Game view › Maximize on play_, the docks and str ## Theme -_View › Toggle light/dark theme_, or _Settings › Appearance_ to choose a theme. Plugins can add themes. +The editor follows the light or dark mode of your system until you choose a theme (_Match the system_ in _Settings › Appearance_). _View › Toggle light/dark theme_, or _Settings › Appearance_ to choose one. Plugins can add themes. diff --git a/docs/docs/editor/play.mdx b/docs/docs/editor/play.mdx index fd5c9eba..bda60e8b 100644 --- a/docs/docs/editor/play.mdx +++ b/docs/docs/editor/play.mdx @@ -61,6 +61,8 @@ The **Console** shows what the game prints, the output of builds, and messages o - A logged **object** shows as one line that expands into a tree. - A **file path** in a line is a link: it opens the file at that line. Lines of the game show where they were logged (`main.ts:12`). - Identical lines in a row are grouped with a count. +- The Console follows new lines, except while the pointer is over them. `Ctrl`+`Alt`+`E` shows only errors, and every level again. +- **Save shown lines** (the download button, or right-click) saves what the filters show as a `.log` file. ## Edit the running game @@ -70,7 +72,7 @@ While the game plays, the **Hierarchy** and the **Inspector** show the live worl - Add or remove a component, spawn or remove an entity: same. - In the **Scene** screen, drag a shape to move the entity in the running game. - In the **Game** screen, turn on **Move entities** in its toolbar: the entities are outlined over the game, and you drag them there the same way. While it is on, the pointer goes to the editor, not to the game. In a networked game the server sends the positions of what it owns: pause the game before moving those, then apply. -- **Apply to code** writes the changes of an entity to `main.ts`. Entities spawned at runtime exist only in the game and cannot be applied. +- **Apply to code** writes the changes of an entity to `main.ts`; **Apply all changes to code**, in the Hierarchy's toolbar, writes those of every entity. Entities spawned at runtime exist only in the game and cannot be applied. - In the **Systems** panel, switch a system off to see the game without it. Stopping the game drops the changes that were not applied; the editor offers to apply them first. The ECS setting _Apply live edits to code_ writes every live change to code as it happens. @@ -81,8 +83,8 @@ Three panels at the bottom, each with a Client / Server selector. ![The profiler](../images/profiler.png) -- **Profiler**: tick time over the last two minutes, against a budget line (16.7 ms by default); spikes are marked. Below, the time per engine library and **per system**. Click the chart to freeze it on a moment. -- **Network**: the packets sent and received, with their size, type name and decoded content; filters by direction, transport and text; charts of bytes and packets per second. +- **Profiler**: tick time over the last two minutes, against a budget line (16.7 ms by default); spikes are marked. Below, the time per engine library and **per system**. Click the chart to freeze it on a moment. **Pause**, **Clear** and **Export** (CSV or JSON) are in its toolbar. +- **Network**: the packets sent and received, with their size, type name and decoded content; filters by direction, transport and text; charts of bytes and packets per second. **Pause** keeps the list still, **Clear** starts it over, **Export** saves the packets shown (CSV or JSON). - **World**: every entity of the running game with its components, read-only, with a search: `Position` (has the component), `Position.x > 100`, `#12` (an entity id). They ask the game for data only while they are shown. diff --git a/docs/docs/editor/scenes.mdx b/docs/docs/editor/scenes.mdx index ca8e5842..381e252c 100644 --- a/docs/docs/editor/scenes.mdx +++ b/docs/docs/editor/scenes.mdx @@ -71,7 +71,7 @@ declare module '@nanoforge-dev/scene' { - Each var shows its type, its default, its description, the scenes that make it (`init`, its possible owners), and its uses: click it to list them, and click a use to open it. - **Add var** asks for its name, type, default and description; the file is created with the first var. -- _Rename_ changes the declaration and every `vars.get("…")`, `vars.set("…")`… of that var. _Change type_, _Change default_ and _Change description_ edit the declaration; _Remove_ removes it. +- _Rename_ changes the declaration and every `vars.get("…")`, `vars.set("…")`… of that var. _Change type_, _Change default_ and _Change description_ edit the declaration; _Remove_ removes it, after a warning when the code uses it (_Remove anyway_). - A var used in code but not declared is listed under _Used but not declared_, with **Declare**. A var belongs to the scene that made it, and goes away when that scene is unloaded, unless it is persistent. diff --git a/docs/docs/editor/settings.mdx b/docs/docs/editor/settings.mdx index 0651ed6e..961366b1 100644 --- a/docs/docs/editor/settings.mdx +++ b/docs/docs/editor/settings.mdx @@ -51,7 +51,7 @@ The _Keyboard shortcuts_ page lists every action with its shortcuts. - **Add shortcut** on an action, then press the keys. Press a second combination to make a chord (`Ctrl+K` then `Ctrl+S`). A shortcut starts with `Ctrl`, `Alt` or `⌘`, or is a function key (`F1` to `F12`): a plain key would no longer work while typing. A plain key is accepted when the condition needs `!textInputFocus` (as in `focusedWidget == 'file-manager.files' && !textInputFocus`, not under `||`): the shortcut then never fires in a text field or the code editor. - When the shortcut is already used, the dialog names the other action: keep both, replace, or cancel. - **Reset** puts an action back to its default. -- A **preset** changes many shortcuts at once: _NanoForge_ (the defaults), _VS Code_, _Godot_. Your own changes apply on top. +- A **preset** changes many shortcuts at once: _NanoForge_ (the defaults), _VS Code_, _Godot_, _IntelliJ_ (Shift twice for the command palette, Alt+1 for Files, Alt+9 for Git, Ctrl+K to commit; on Linux the browser keeps Alt+1…9 for its tabs, so those reach the editor only outside a browser tab). Your own changes apply on top. Shortcuts are saved to your account, or to this machine only. diff --git a/docs/docs/plugins/extension-points.mdx b/docs/docs/plugins/extension-points.mdx index 56b77921..9dd55b3c 100644 --- a/docs/docs/plugins/extension-points.mdx +++ b/docs/docs/plugins/extension-points.mdx @@ -43,7 +43,7 @@ From `@nanoforge-dev/editor-sdk/ui`. | Point | A value is | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `FILE_TEMPLATES` | A "New…" entry of the Files panel: `{ id, title, category, defaultName, create({ folder, name }) }` returning the files to write. | +| `FILE_TEMPLATES` | A "New…" entry of the Files panel: `{ id, title, category, when, defaultName, create({ folder, name }) }` returning the files to write. `when` sees `fileFolder` and `appType` (`client`, `server`, `lib`, empty outside apps). | | `FILE_ACTIONS` | An entry of the context menu of files: `{ id, title, command, group, when }`. The command gets the selected paths. `when` sees `fileKind`, `fileExtname`, `fileReadOnly`, `fileSelectionCount`. | | `FILE_ICONS` | The icon of files matching a glob: `{ pattern, icon, priority }`. | | `FILE_DECORATIONS` | Marks on files (a badge and a tone), as Git does: `{ id, changes, decorate(path, kind) }`. | @@ -105,6 +105,7 @@ A plugin can define its own points with `defineExtensionPoint(id)`, and other | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ecs.fieldEditors` | The inspector field of a value type, or of one referenced type. | | `ecs.paramPresets` | A visual preset that merges several constructor parameters into one control. | +| `ecs.entityActions` | An action on an entity of the Hierarchy: `{ id, title, command, when }`. The command gets `{ app, entity, path }`; `when` sees `ecsEntity` and `ecsComponent:` (`ecsComponent:Position`). | | `ecs.sources` | What the ECS widgets edit for an app: a file and the function holding its spawns (`main`, or a method of a class), and read-only locations shown with it. The scene plugin uses it. | Declare `@nanoforge/ecs` in your manifest's `dependencies` to contribute to them. diff --git a/docs/docs/plugins/manifest.mdx b/docs/docs/plugins/manifest.mdx index 4ce1e5d6..13197586 100644 --- a/docs/docs/plugins/manifest.mdx +++ b/docs/docs/plugins/manifest.mdx @@ -122,7 +122,7 @@ The code gives the widget its view with the `WIDGET_VIEWS` extension point. See { "key": "Mod+Alt+K", "command": "counter.add", "when": "!textInputFocus" } ``` -- Write `Mod` for Ctrl (Cmd on macOS). Two strokes separated by a space make a chord: `Mod+K Mod+S`. +- Write `Mod` for Ctrl (Cmd on macOS). Two strokes separated by a space make a chord: `Mod+K Mod+S`. A modifier twice is a double tap: `Shift Shift`. - These are **defaults**: users change them in _Settings › Keyboard shortcuts_. - Don't bind a single stroke that starts an existing chord, and don't give a default shortcut to an action that publishes or deletes. diff --git a/docs/specs/ecs.md b/docs/specs/ecs.md index b5d02688..bc2d1269 100644 --- a/docs/specs/ecs.md +++ b/docs/specs/ecs.md @@ -158,7 +158,7 @@ Everything above (name, description, color, preset, group, hidden) can also be s ## Extension points (SDK) - `ecs.fieldEditors`: `{ type, ref?, component }`, the inspector field for an `Element` type, or for one `ref` (`@nanoforge-dev/graphics-2d#Rect`). -- `ecs.entityActions`: extra actions on a hierarchy row (`{ id, title, command, when? }`). +- `ecs.entityActions`: extra actions on a hierarchy row (`{ id, title, command, when? }`). The command gets `{ app, entity, path }`; `when` sees `ecsEntity` (its name) and `ecsComponent:` for each of its components. - In the code editor (a new `codeEditor.sidePanels` point, see Core additions): the **Component** panel, shown when the active file defines a component. - `ecs.paramPresets`: `{ id, slots?, component }`, a visual control combining the params of a group that use it (`vector`, `size` and `color` are built in). diff --git a/examples/counter-plugin/package.json b/examples/counter-plugin/package.json index f3dbf351..15c08a26 100644 --- a/examples/counter-plugin/package.json +++ b/examples/counter-plugin/package.json @@ -15,7 +15,7 @@ "@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", - "typescript": "^5.9.0", + "typescript": "^6.0.0", "vite": "^7.0.0 || ^8.0.0" } } diff --git a/knip.json b/knip.json index 9032ff09..ffca0aef 100644 --- a/knip.json +++ b/knip.json @@ -34,6 +34,10 @@ "*.mjs" ] }, + "packages/sdk": { + "entry": ["scripts/*.ts", "test/**/*.spec.ts"], + "ignoreDependencies": ["bits-ui"] + }, "packages/ui": { "ignoreDependencies": ["@fontsource/.+"] }, diff --git a/packages/protocol/src/git/git.contract.ts b/packages/protocol/src/git/git.contract.ts index 37d1f0c6..b0f6576d 100644 --- a/packages/protocol/src/git/git.contract.ts +++ b/packages/protocol/src/git/git.contract.ts @@ -13,7 +13,7 @@ const BranchName = z .min(1) .max(200) .regex(/^(?!-)(?!.*\.\.)[^\s~^:?*[\\]+$/, 'not a valid branch name'); -const Hash = z.string().regex(/^[0-9a-f]{7,40}$/); +const Hash = z.string().regex(/^[0-9a-f]{7,64}$/); /** * Git operations on the repository of a project's folder (local editors only). A repository @@ -95,9 +95,9 @@ export const GitContract = defineContract('git', { stash: { input: Project.extend({ message: z.string().optional() }), output: z.null() }, /** `pop`: drops the stash once applied. */ applyStash: { - input: Project.extend({ index: z.number().int().min(0), pop: z.boolean().default(false) }), + input: Project.extend({ hash: Hash, pop: z.boolean().default(false) }), output: z.null(), }, - dropStash: { input: Project.extend({ index: z.number().int().min(0) }), output: z.null() }, + dropStash: { input: Project.extend({ hash: Hash }), output: z.null() }, }, }); diff --git a/packages/protocol/src/git/git.schema.ts b/packages/protocol/src/git/git.schema.ts index 03b202ca..ebff3d6d 100644 --- a/packages/protocol/src/git/git.schema.ts +++ b/packages/protocol/src/git/git.schema.ts @@ -46,6 +46,8 @@ export const GitCommit = z.object({ hash: z.string(), shortHash: z.string(), subject: z.string(), + /** The message after the subject, when there is one (cut at 4000 characters). */ + body: z.string().optional(), author: z.string(), /** Seconds since epoch. */ time: z.number(), @@ -54,5 +56,6 @@ export const GitCommit = z.object({ }); export type GitCommit = z.output; -export const GitStash = z.object({ index: z.number().int(), message: z.string() }); +/** A stash, named by the hash of its commit: its `stash@{n}` changes as stashes come and go. */ +export const GitStash = z.object({ hash: z.string(), message: z.string() }); export type GitStash = z.output; diff --git a/packages/protocol/src/project/app-at.ts b/packages/protocol/src/project/app-at.ts new file mode 100644 index 00000000..2aee01e0 --- /dev/null +++ b/packages/protocol/src/project/app-at.ts @@ -0,0 +1,13 @@ +import type { AppModel } from './project.schema'; + +/** + * The app or shared library a project path belongs to: the deepest one whose root holds it (a + * single-app project's root is `''`, which holds every path). + */ +export const appAt = >( + apps: readonly T[], + path: string, +): T | undefined => + apps + .filter((app) => app.root === '' || path === app.root || path.startsWith(`${app.root}/`)) + .sort((a, b) => b.root.length - a.root.length)[0]; diff --git a/packages/protocol/src/project/index.ts b/packages/protocol/src/project/index.ts index e344b6aa..909d32f2 100644 --- a/packages/protocol/src/project/index.ts +++ b/packages/protocol/src/project/index.ts @@ -1,2 +1,3 @@ export * from './project.schema'; export * from './project.contract'; +export * from './app-at'; diff --git a/packages/protocol/test/project/app-at.spec.ts b/packages/protocol/test/project/app-at.spec.ts new file mode 100644 index 00000000..d69de4f0 --- /dev/null +++ b/packages/protocol/test/project/app-at.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { appAt } from '../../src/project/app-at'; + +describe('appAt', () => { + const apps = [{ root: 'apps/client' }, { root: 'apps/client-tools' }, { root: 'libs/shared' }]; + + it('finds the app whose root holds the path, not one that only starts like it', () => { + expect(appAt(apps, 'apps/client')).toBe(apps[0]); + expect(appAt(apps, 'apps/client/src/components')).toBe(apps[0]); + expect(appAt(apps, 'apps/client-tools/src')).toBe(apps[1]); + expect(appAt(apps, 'apps')).toBeUndefined(); + }); + + it('takes the deepest root, a single-app project holding every path', () => { + const single = [{ root: '' }, { root: 'tools' }]; + expect(appAt(single, 'src/main.ts')).toBe(single[0]); + expect(appAt(single, 'tools/x.ts')).toBe(single[1]); + }); +}); diff --git a/packages/sdk/.cliff-jumperrc.json b/packages/sdk/.cliff-jumperrc.json index 77626601..00be1a71 100644 --- a/packages/sdk/.cliff-jumperrc.json +++ b/packages/sdk/.cliff-jumperrc.json @@ -3,6 +3,5 @@ "name": "editor-sdk", "org": "nanoforge-dev", "packagePath": "packages/sdk", - "tagTemplate": "{{new-version}}", "identifierBase": false } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d07a785d..5e87d57e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -19,6 +19,8 @@ Public API for NanoForge editor plugins. +Three entries: `@nanoforge-dev/editor-sdk` (services, commands, extension points), `@nanoforge-dev/editor-sdk/ui` (widgets, the component kit) and `@nanoforge-dev/editor-sdk/worker` (code analyzers and transformers, which need `ts-morph`). The editor provides all three at runtime: build plugins with [`@nanoforge-dev/editor-vite-plugin`](https://www.npmjs.com/package/@nanoforge-dev/editor-vite-plugin), which points their imports at the editor's own copy. The published `ui` entry holds the types only: its exports throw when used outside the editor, so unit tests mock it (`vi.mock('@nanoforge-dev/editor-sdk/ui')`). + ## Installation **Node.js 26 or newer is required.** diff --git a/packages/sdk/cliff.toml b/packages/sdk/cliff.toml index 8da3ea14..55d0bd4f 100644 --- a/packages/sdk/cliff.toml +++ b/packages/sdk/cliff.toml @@ -69,7 +69,7 @@ commit_parsers = [ ] filter_commits = true protect_breaking_commits = true -tag_pattern = "^[0-9]+" +tag_pattern = "@nanoforge-dev/editor-sdk@[0-9]*" ignore_tags = "" topo_order = false sort_commits = "newest" diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 76438cb8..a4543b08 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -38,7 +38,7 @@ }, "funding": "https://github.com/NanoForge-dev/Editor?sponsor", "scripts": { - "build": "tsdown && node ./scripts/build-types.ts", + "build": "tsdown && node ./scripts/build-types.ts && node ./scripts/build-ui-stub.ts", "lint": "prettier --check . && eslint --format=pretty .", "format": "prettier --write . && eslint --fix --format=pretty src", "typecheck": "tsc -p tsconfig.json", @@ -48,6 +48,21 @@ "release": "cliff-jumper" }, "dependencies": { + "bits-ui": "catalog:components", + "semver": "catalog:libs", + "zod": "catalog:libs" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "ts-morph": "^28.0.0" + }, + "peerDependenciesMeta": { + "ts-morph": { + "optional": true + } + }, + "devDependencies": { + "@favware/cliff-jumper": "catalog:ci", "@nanoforge-dev/editor-code": "workspace:*", "@nanoforge-dev/editor-history": "workspace:*", "@nanoforge-dev/editor-kernel": "workspace:*", @@ -57,10 +72,10 @@ "@nanoforge-dev/editor-rpc": "workspace:*", "@nanoforge-dev/editor-runtime": "workspace:*", "@nanoforge-dev/editor-settings": "workspace:*", - "@nanoforge-dev/editor-ui": "workspace:*" - }, - "devDependencies": { - "@favware/cliff-jumper": "catalog:ci", + "@nanoforge-dev/editor-ui": "workspace:*", + "svelte": "catalog:core", + "svelte2tsx": "catalog:core", + "ts-morph": "catalog:code", "tsdown": "catalog:build", "typescript": "catalog:build", "vitest": "catalog:test" @@ -69,7 +84,6 @@ "engines": { "node": "26" }, - "private": true, "publishConfig": { "access": "public", "exports": { @@ -78,6 +92,10 @@ "default": "./dist/index.js" }, "./package.json": "./package.json", + "./ui": { + "types": "./dist/types/sdk/src/ui.d.ts", + "default": "./dist/ui.js" + }, "./worker": { "types": "./dist/types/sdk/src/worker.d.ts", "default": "./dist/worker.js" diff --git a/packages/sdk/scripts/build-types.ts b/packages/sdk/scripts/build-types.ts index 08d9f506..68776d1d 100644 --- a/packages/sdk/scripts/build-types.ts +++ b/packages/sdk/scripts/build-types.ts @@ -1,14 +1,57 @@ -import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { dirname, join, relative } from 'node:path'; +import { emitDts } from 'svelte2tsx'; +import ts from 'typescript'; +/** + * Types of the published SDK. The editor packages it reaches are private: their declarations are + * emitted next to the SDK's (Svelte components through svelte2tsx), and their specifiers are + * rewritten to relative paths. + */ const root = join(import.meta.dirname, '..'); +const packages = dirname(root); const out = join(root, 'dist', 'types'); +const generated = join(root, 'tsconfig.types.generated.json'); + +/** `@nanoforge-dev/[/sub]` → its source file, from each package's own `exports`. */ +const sources = new Map(); +for (const folder of readdirSync(packages)) { + const manifest = join(packages, folder, 'package.json'); + if (!statSync(join(packages, folder)).isDirectory()) continue; + const { name, exports } = JSON.parse(readFileSync(manifest, 'utf8')) as { + name: string; + exports: Record; + }; + for (const [subpath, target] of Object.entries(exports)) { + const file = typeof target === 'string' ? target : target.default; + if (!file.endsWith('.ts')) continue; + sources.set(join(name, subpath), join(packages, folder, file)); + } +} + rmSync(out, { recursive: true, force: true }); -execFileSync(join(root, 'node_modules/.bin/tsc'), ['-p', 'tsconfig.types.json'], { - cwd: root, - stdio: 'inherit', -}); +writeFileSync( + generated, + JSON.stringify({ + extends: './tsconfig.types.json', + compilerOptions: { + paths: Object.fromEntries( + [...sources].map(([specifier, file]) => [specifier, [relative(root, file)]]), + ), + }, + }), +); +try { + await emitDts({ + libRoot: packages, + declarationDir: out, + tsconfig: generated, + svelteShimsPath: createRequire(import.meta.url).resolve('svelte2tsx/svelte-shims-v4.d.ts'), + }); +} finally { + rmSync(generated, { force: true }); +} const walk = (dir: string): string[] => readdirSync(dir).flatMap((name) => { @@ -16,14 +59,88 @@ const walk = (dir: string): string[] => return statSync(path).isDirectory() ? walk(path) : path.endsWith('.d.ts') ? [path] : []; }); -const SPECIFIER = - /(['"])@nanoforge-dev\/editor-(code|history|kernel|project|protocol|rpc|settings)(\/engine)?\1/g; +/** A module specifier where a declaration file imports or augments one. */ +const SPECIFIER = /(\bfrom\s+|\bimport\s*\(\s*|\bimport\s+|\bdeclare module\s+)(['"])([^'"]+)\2/g; +const unknown = new Map(); +const imports = new Map(); for (const file of walk(out)) { const text = readFileSync(file, 'utf8'); - const rewritten = text.replace(SPECIFIER, (_match, quote: string, name: string, sub?: string) => { - const target = relative(dirname(file), join(out, name, 'src', sub ? 'engine' : '', 'index.js')); - return `${quote}${target.startsWith('.') ? target : `./${target}`}${quote}`; - }); + const local: string[] = []; + const rewritten = text.replace( + SPECIFIER, + (match, keyword: string, quote: string, specifier: string) => { + if (specifier.startsWith('.')) { + local.push(join(dirname(file), specifier)); + return match; + } + if (!specifier.startsWith('@nanoforge-dev/')) return match; + const source = sources.get(specifier); + if (!source) { + unknown.set(file, specifier); + return match; + } + const target = join(out, relative(packages, source)).replace(/\.ts$/, '.js'); + local.push(target); + const path = relative(dirname(file), target); + return `${keyword}${quote}${path.startsWith('.') ? path : `./${path}`}${quote}`; + }, + ); + imports.set(file, local); if (rewritten !== text) writeFileSync(file, rewritten); } -console.log(`SDK types written to ${relative(root, out)}`); + +/** Only what the SDK's entries reach is published: emitDts writes every file of `packages`. */ +const declaration = (path: string) => + [path.replace(/\.js$/, '.d.ts'), `${path}.d.ts`, join(path, 'index.d.ts')].find((candidate) => + imports.has(candidate), + ); +const reached = new Set(); +const queue = ['index', 'ui', 'worker'].map((entry) => join(out, 'sdk', 'src', `${entry}.d.ts`)); +for (let file = queue.pop(); file; file = queue.pop()) { + if (reached.has(file)) continue; + reached.add(file); + for (const target of imports.get(file) ?? []) { + const found = declaration(target); + if (found) queue.push(found); + } +} +for (const file of imports.keys()) if (!reached.has(file)) rmSync(file); +const prune = (dir: string): void => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) prune(path); + } + if (!readdirSync(dir).length) rmSync(dir, { recursive: true }); +}; +prune(out); + +/** The published declarations must check on their own (svelte2tsx can emit clashing names). */ +const entries = ['index', 'ui', 'worker'].map((entry) => join(out, 'sdk', 'src', `${entry}.d.ts`)); +const program = ts.createProgram(entries, { + noEmit: true, + strict: true, + skipLibCheck: false, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + lib: ['lib.esnext.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'], + types: [], +}); +const errors = ts + .getPreEmitDiagnostics(program) + .filter((diagnostic) => diagnostic.file?.fileName.startsWith(out)); +if (errors.length) + throw new Error( + ts.formatDiagnostics(errors, { + getCanonicalFileName: (name) => name, + getCurrentDirectory: () => root, + getNewLine: () => '\n', + }), + ); + +const leaks = [...unknown].filter(([file]) => reached.has(file)); +if (leaks.length) + throw new Error( + `SDK types import packages that are not published: ${leaks.map(([file, specifier]) => `${specifier} (${relative(out, file)})`).join(', ')}`, + ); +console.log(`SDK types written to ${relative(root, out)}: ${reached.size} files`); diff --git a/packages/sdk/scripts/build-ui-stub.ts b/packages/sdk/scripts/build-ui-stub.ts new file mode 100644 index 00000000..04774011 --- /dev/null +++ b/packages/sdk/scripts/build-ui-stub.ts @@ -0,0 +1,42 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import ts from 'typescript'; + +/** + * JavaScript of `@nanoforge-dev/editor-sdk/ui`. Its components are Svelte, which only the editor + * compiles: plugins built with `@nanoforge-dev/editor-vite-plugin` take the editor's own module. + * Every export of the published one throws when used, saying so (tests mock the module). + */ +const root = join(import.meta.dirname, '..'); +const entry = join(root, 'dist/types/sdk/src/ui.d.ts'); +const program = ts.createProgram([entry], { noEmit: true, skipLibCheck: true }); +const checker = program.getTypeChecker(); +const module = checker.getSymbolAtLocation(program.getSourceFile(entry)!)!; +const values = checker + .getExportsOfModule(module) + .filter((symbol) => { + const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + return target.flags & ts.SymbolFlags.Value; + }) + .map((symbol) => symbol.name) + .sort(); +if (!values.length) throw new Error(`No exports found in ${entry}`); + +const lines = [ + 'const message = (name) =>', + ' `${name} of @nanoforge-dev/editor-sdk/ui is provided by the NanoForge editor at runtime: build the plugin with @nanoforge-dev/editor-vite-plugin, and mock the module in unit tests.`;', + 'const unavailable = (name) =>', + ' new Proxy(function () {}, {', + ' get: (_target, key) => (key === Symbol.toPrimitive ? undefined : unavailable(name)),', + ' apply: () => {', + ' throw new Error(message(name));', + ' },', + ' construct: () => {', + ' throw new Error(message(name));', + ' },', + ' });', + ...values.map((name) => `export const ${name} = unavailable('${name}');`), + '', +]; +writeFileSync(join(root, 'dist/ui.js'), lines.join('\n')); +console.log(`SDK ui stub written: ${values.length} exports`); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 79373fbb..797f4f5b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -97,7 +97,13 @@ export type { FileEntry, ProjectModel, } from '@nanoforge-dev/editor-protocol'; -export { basename, dirname, joinPath, runtimeOutputPath } from '@nanoforge-dev/editor-protocol'; +export { + appAt, + basename, + dirname, + joinPath, + runtimeOutputPath, +} from '@nanoforge-dev/editor-protocol'; export { type Changeset, diff --git a/packages/sdk/src/ui.ts b/packages/sdk/src/ui.ts index 4667940b..722ad325 100644 --- a/packages/sdk/src/ui.ts +++ b/packages/sdk/src/ui.ts @@ -93,6 +93,7 @@ export { type ThemeService, ThemeServiceToken, THEMES, + SYSTEM_THEME, type WorkbenchService, WorkbenchServiceToken, registerIcons, @@ -123,6 +124,7 @@ export { Vector2Input, isKeyboardMenu, moveRowFocus, + saveAsFile, TOURS, type Tour, type TourStep, diff --git a/packages/sdk/test/index.spec.ts b/packages/sdk/test/index.spec.ts index e53dc869..4846bcbc 100644 --- a/packages/sdk/test/index.spec.ts +++ b/packages/sdk/test/index.spec.ts @@ -70,6 +70,7 @@ describe('public API', () => { "SettingsServiceToken", "TEXT_INPUT_FOCUS_KEY", "WorkspaceError", + "appAt", "appRefName", "applyTextEdits", "basename", diff --git a/packages/sdk/tsconfig.types.json b/packages/sdk/tsconfig.types.json index 8e90fb51..48521bb1 100644 --- a/packages/sdk/tsconfig.types.json +++ b/packages/sdk/tsconfig.types.json @@ -8,17 +8,7 @@ "sourceMap": false, "rootDir": "..", "outDir": "dist/types", - "types": [], - "paths": { - "@nanoforge-dev/editor-kernel": ["../kernel/src/index.ts"], - "@nanoforge-dev/editor-project": ["../project/src/index.ts"], - "@nanoforge-dev/editor-protocol": ["../protocol/src/index.ts"], - "@nanoforge-dev/editor-rpc": ["../rpc/src/index.ts"], - "@nanoforge-dev/editor-settings": ["../settings/src/index.ts"], - "@nanoforge-dev/editor-history": ["../history/src/index.ts"], - "@nanoforge-dev/editor-code": ["../code/src/index.ts"], - "@nanoforge-dev/editor-code/engine": ["../code/src/engine/index.ts"] - } + "types": [] }, "include": ["src/**/*.ts"] } diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts index 58dd3912..ff1ee5f2 100644 --- a/packages/sdk/tsdown.config.ts +++ b/packages/sdk/tsdown.config.ts @@ -2,7 +2,8 @@ import { defineConfig } from 'tsdown'; /** * JavaScript of the published SDK (types are built by scripts/build-types.ts). At runtime plugins - * never load it: the editor provides the SDK as a shared module. + * never load it: the editor provides the SDK as a shared module. The editor packages it reaches are + * private, so they are bundled, subpaths included. */ export default defineConfig({ entry: ['src/index.ts', 'src/worker.ts'], @@ -14,7 +15,7 @@ export default defineConfig({ clean: true, fixedExtension: false, deps: { - alwaysBundle: [/^@nanoforge-dev\/editor-(code|history|kernel|project|protocol|rpc|settings)$/], - neverBundle: ['semver', 'zod', 'ts-morph'], + alwaysBundle: [/^@nanoforge-dev\/(editor-(?!sdk\b)[\w-]+|registry)(\/[\w/-]+)?$/], + neverBundle: ['semver', 'zod', 'ts-morph', /^svelte(\/|$)/], }, }); diff --git a/packages/sdk/turbo.json b/packages/sdk/turbo.json new file mode 100644 index 00000000..19f73ffa --- /dev/null +++ b/packages/sdk/turbo.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": [ + "$TURBO_EXTENDS$", + "tsconfig.types.json", + "$TURBO_ROOT$/packages/*/src/**", + "$TURBO_ROOT$/packages/*/package.json" + ] + } + } +} diff --git a/packages/server-core/src/git/git-service.ts b/packages/server-core/src/git/git-service.ts index 699c29f3..cb912983 100644 --- a/packages/server-core/src/git/git-service.ts +++ b/packages/server-core/src/git/git-service.ts @@ -269,7 +269,7 @@ export class GitService { 'log', `--max-count=${limit}`, `--skip=${skip}`, - '--format=%H%x00%h%x00%an%x00%at%x00%D%x00%s%x1e', + '--format=%H%x00%h%x00%an%x00%at%x00%D%x00%s%x00%b%x1e', ...(branch ? [branch, '--'] : []), ]); return output @@ -277,7 +277,7 @@ export class GitService { .map((record) => record.replace(/^\n/, '')) .filter(Boolean) .map((record): GitCommit => { - const [hash = '', shortHash = '', author = '', time, refs = '', subject = ''] = + const [hash = '', shortHash = '', author = '', time, refs = '', subject = '', body = ''] = record.split('\0'); return { hash, @@ -289,6 +289,7 @@ export class GitService { .map((ref) => ref.replace(/^HEAD -> /, '')) .filter((ref) => ref && ref !== 'HEAD'), subject, + ...(body.trim() && { body: body.trim().slice(0, 4000) }), }; }); }); @@ -348,11 +349,14 @@ export class GitService { stashes(path: string): Promise { return this._mutex.run(path, async () => { - const output = await this._git(path, ['stash', 'list', '--format=%gs']); + const output = await this._git(path, ['stash', 'list', '--format=%H%x00%gs']); return output .split('\n') .filter(Boolean) - .map((message, index) => ({ index, message })); + .map((line) => { + const [hash = '', message = ''] = line.split('\0'); + return { hash, message }; + }); }); } @@ -366,12 +370,26 @@ export class GitService { ]); } - applyStash(path: string, index: number, pop = false): Promise { - return this._run(path, ['stash', pop ? 'pop' : 'apply', '--quiet', `stash@{${index}}`]); + /** Stashes are named by hash: `stash@{n}` moves when another stash is made or dropped. */ + applyStash(path: string, hash: string, pop = false): Promise { + return this._mutex.run(path, async () => { + const ref = await this._stashRef(path, hash); + await this._git(path, ['stash', pop ? 'pop' : 'apply', '--quiet', ref]); + }); + } + + dropStash(path: string, hash: string): Promise { + return this._mutex.run(path, async () => { + await this._git(path, ['stash', 'drop', '--quiet', await this._stashRef(path, hash)]); + }); } - dropStash(path: string, index: number): Promise { - return this._run(path, ['stash', 'drop', '--quiet', `stash@{${index}}`]); + /** `stash@{n}` of the stash with this hash now, inside the repository's lock. */ + private async _stashRef(path: string, hash: string): Promise { + const hashes = (await this._git(path, ['stash', 'list', '--format=%H'])).split('\n'); + const index = hashes.findIndex((candidate) => candidate && candidate.startsWith(hash)); + if (index < 0) throw new RpcError('NOT_FOUND', 'This stash no longer exists.'); + return `stash@{${index}}`; } private _run(path: string, args: string[]): Promise { diff --git a/packages/server-core/src/rpc/implementation/git.implementation.ts b/packages/server-core/src/rpc/implementation/git.implementation.ts index 31019e55..2b31044a 100644 --- a/packages/server-core/src/rpc/implementation/git.implementation.ts +++ b/packages/server-core/src/rpc/implementation/git.implementation.ts @@ -49,8 +49,8 @@ export const implementGitRpc = ( git.show(repo(session, project), path, revision), stashes: ({ project }, { session }) => git.stashes(repo(session, project)), stash: nothing((root, { message }) => git.stash(root, message)), - applyStash: nothing((root, { index, pop }) => git.applyStash(root, index, pop)), - dropStash: nothing((root, { index }) => git.dropStash(root, index)), + applyStash: nothing((root, { hash, pop }) => git.applyStash(root, hash, pop)), + dropStash: nothing((root, { hash }) => git.dropStash(root, hash)), }, }); }; diff --git a/packages/server-core/test/git/git-service.spec.ts b/packages/server-core/test/git/git-service.spec.ts index fc4c45d2..04d96a14 100644 --- a/packages/server-core/test/git/git-service.spec.ts +++ b/packages/server-core/test/git/git-service.spec.ts @@ -220,12 +220,21 @@ describe('GitService on a project folder', () => { await git.commit(root, 'b and a new file', { paths: ['src/b.txt', 'fresh.txt'] }); expect(await files(root)).toEqual([]); write(root, 'fresh.txt', 'fresher\n'); - await git.commit(root, 'b and a fresh file', { paths: ['fresh.txt'], amend: true }); - expect((await git.log(root)).map((commit) => commit.subject)).toEqual([ + await git.commit(root, 'b and a fresh file\n\nWhy: the body.\nSecond line.', { + paths: ['fresh.txt'], + amend: true, + }); + const amended = await git.log(root); + expect(amended.map((commit) => commit.subject)).toEqual([ 'b and a fresh file', 'change a', 'first commit', ]); + expect(amended.map((commit) => commit.body)).toEqual([ + 'Why: the body.\nSecond line.', + undefined, + undefined, + ]); raw(root, 'mv', 'fresh.txt', 'renamed.txt'); expect((await git.projectStatus(root)).files).toEqual([ { path: 'renamed.txt', from: 'fresh.txt', staged: 'R', conflicted: false }, @@ -303,15 +312,25 @@ describe('GitService on a project folder', () => { write(root, 'extra.txt', 'extra\n'); await git.stash(root, 'work in progress'); expect(await files(root)).toEqual([]); - expect(await git.stashes(root)).toEqual([{ index: 0, message: 'On main: work in progress' }]); - await git.applyStash(root, 0); + const [wip] = await git.stashes(root); + expect(wip).toEqual({ + hash: expect.stringMatching(/^[0-9a-f]{40}$/), + message: 'On main: work in progress', + }); + await git.applyStash(root, wip!.hash); expect(await files(root)).toEqual(['-M a.txt', '-U extra.txt']); await git.discard(root, ['a.txt', 'extra.txt']); - await git.applyStash(root, 0, true); - expect(await git.stashes(root)).toEqual([]); - await git.stash(root); - await git.dropStash(root, 0); + /** A newer stash moves it to stash@{1}: its hash still names it. */ + write(root, 'a.txt', 'newer\n'); + await git.stash(root, 'newer'); + await git.applyStash(root, wip!.hash, true); + expect((await git.stashes(root)).map((stash) => stash.message)).toEqual(['On main: newer']); + expect(await files(root)).toEqual(['-M a.txt', '-U extra.txt']); + await git.discard(root, ['a.txt', 'extra.txt']); + const [newer] = await git.stashes(root); + await git.dropStash(root, newer!.hash); expect(await git.stashes(root)).toEqual([]); + await expect(git.dropStash(root, newer!.hash)).rejects.toThrow(/no longer exists/); }); it('pushes and pulls with a remote, counts ahead and behind, and reports a conflict', async () => { diff --git a/packages/settings/src/definition/core-settings.ts b/packages/settings/src/definition/core-settings.ts index cb0ba69c..fd16c733 100644 --- a/packages/settings/src/definition/core-settings.ts +++ b/packages/settings/src/definition/core-settings.ts @@ -16,9 +16,10 @@ export const CoreSettings = { theme: defineSetting({ key: 'appearance.theme', schema: z.string().min(1), - default: 'nanoforge-dark', + default: 'system', title: 'Theme', - description: 'Color theme of the editor.', + description: + 'Color theme of the editor: `system` follows the light or dark mode of the system.', category: 'Editor/Appearance', scopes: ['account', 'machine'], }), diff --git a/packages/ui/src/components/combobox.svelte b/packages/ui/src/components/combobox.svelte index 7813def4..8681e99a 100644 --- a/packages/ui/src/components/combobox.svelte +++ b/packages/ui/src/components/combobox.svelte @@ -1,5 +1,5 @@ - ({ value: item.value, label: item.label }))} @@ -28,26 +28,26 @@ onOpenChange={(open) => !open && (search = '')} >
- (search = event.currentTarget.value)} /> - + - +
- - + + {#each filtered as item (item.value)} - + {item.label} {#if item.detail}{item.detail}{/if} - + {:else}

No match

{/each} -
-
-
+ + + diff --git a/packages/ui/src/components/context-menu.svelte b/packages/ui/src/components/context-menu.svelte index d91da8f3..2d2416b4 100644 --- a/packages/ui/src/components/context-menu.svelte +++ b/packages/ui/src/components/context-menu.svelte @@ -1,5 +1,5 @@ - open && (entries = items())}> - + open && (entries = items())}> + {@render children()} - - - + + + {#each entries as entry, index (index)} {#if entry.kind === 'separator'} - + {:else if entry.kind === 'submenu'} - - + + {entry.label} - - + + {#each entry.items as child, childIndex (childIndex)} {#if child.kind === 'item'} - {child.label} - + {:else if child.kind === 'separator'} - + {/if} {/each} - - + + {:else} - {entry.label} {#if entry.shortcut}{entry.shortcut}{/if} - + {/if} {/each} - - - + + + diff --git a/packages/ui/src/components/dialog.svelte b/packages/ui/src/components/dialog.svelte index 49ba1672..c88489ab 100644 --- a/packages/ui/src/components/dialog.svelte +++ b/packages/ui/src/components/dialog.svelte @@ -1,5 +1,5 @@ - open, (next) => { @@ -41,22 +41,22 @@ } } > - - - + + +
- {title} - + {title} + {#snippet child({ props })} {/snippet} - +
- {#if description}{description}{description}{/if} {#if children}
{@render children()}
{/if} {#if footer}
{@render footer()}
{/if} -
-
-
+ + + diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 3a7787de..d7c08aff 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -3,6 +3,7 @@ export type { MenuEntry } from './menu-entry.type'; export type { PropertyField } from './property-field.type'; export * from './menu-event'; export * from './row-focus'; +export * from './save-as-file'; export * from './tab-focus'; export * from './tree-model'; export { default as Button } from './button.svelte'; diff --git a/packages/ui/src/components/save-as-file.ts b/packages/ui/src/components/save-as-file.ts new file mode 100644 index 00000000..655b23c4 --- /dev/null +++ b/packages/ui/src/components/save-as-file.ts @@ -0,0 +1,11 @@ +/** Hands the browser a file to save (its download), as `name`. */ +export const saveAsFile = (name: string, content: string | Blob, type = 'text/plain'): void => { + const blob = content instanceof Blob ? content : new Blob([content], { type }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = name; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); +}; diff --git a/packages/ui/src/components/select.svelte b/packages/ui/src/components/select.svelte index c11a1935..f68e65e8 100644 --- a/packages/ui/src/components/select.svelte +++ b/packages/ui/src/components/select.svelte @@ -1,5 +1,5 @@ - onchange?.(next as T)} > - + {selected?.label ?? ''} - - - - + + + + {#each items as item (item.value)} - {item.label} {/snippet} - + {/each} - - - - + + + + diff --git a/packages/ui/src/components/tooltip.svelte b/packages/ui/src/components/tooltip.svelte index 2bdfa588..2dfb902c 100644 --- a/packages/ui/src/components/tooltip.svelte +++ b/packages/ui/src/components/tooltip.svelte @@ -1,5 +1,5 @@ - - - + + + {#snippet child({ props })} {@render children({ props })} {/snippet} - - - {text} - - - + + + {text} + + + diff --git a/packages/ui/src/theme/theme-service.ts b/packages/ui/src/theme/theme-service.ts index 505e0f0e..6c3ec811 100644 --- a/packages/ui/src/theme/theme-service.ts +++ b/packages/ui/src/theme/theme-service.ts @@ -34,9 +34,14 @@ export const THEMES = defineExtensionPoint('ui.themes', { validator: ThemeContributionSchema as never, }); +/** `appearance.theme` value that follows the system: NanoForge Light or Dark (the default). */ +export const SYSTEM_THEME = 'system'; + export interface ThemeServiceOptions { /** `appearance.density`: how tight the UI is. */ readonly density?: Observable; + /** Whether the system prefers light colors, for `SYSTEM_THEME`. */ + readonly prefersLight?: Observable; /** Where unresolved themes and faint text are reported. */ readonly logger?: Logger; } @@ -63,6 +68,7 @@ export class ThemeService implements Disposable { const apply = () => this._apply(); this._store.add(observe(_selected, apply)); if (_options.density) this._store.add(observe(_options.density, apply)); + if (_options.prefersLight) this._store.add(observe(_options.prefersLight, apply)); this._store.add(_extensions.onDidChange((point) => point.id === THEMES.id && apply())); } @@ -85,14 +91,20 @@ export class ThemeService implements Disposable { }); } + /** The theme an `appearance.theme` value shows: `SYSTEM_THEME` and unknown ids included. */ + resolve(id: string): ThemeDefinition { + if (id === SYSTEM_THEME) + id = this._options.prefersLight?.get() ? nanoforgeLight.id : nanoforgeDark.id; + return this.themes.find((candidate) => candidate.id === id) ?? nanoforgeDark; + } + dispose(): void { this._applied.dispose(); this._store.dispose(); } private _apply(): void { - const id = this._selected.get(); - const theme = this.themes.find((candidate) => candidate.id === id) ?? nanoforgeDark; + const theme = this.resolve(this._selected.get()); this._root.dataset.nfTheme = theme.id; this._applied.value = this._styles.inject( 'core', diff --git a/packages/ui/src/workbench/keybinding/keybinding-service.ts b/packages/ui/src/workbench/keybinding/keybinding-service.ts index ac074598..4f9edc0b 100644 --- a/packages/ui/src/workbench/keybinding/keybinding-service.ts +++ b/packages/ui/src/workbench/keybinding/keybinding-service.ts @@ -17,12 +17,26 @@ import { startsWith, strokeFromEvent } from './keystroke'; import { actionId, findConflicts, resolveKeymap } from './resolve-keymap'; const CHORD_TIMEOUT_MS = 1500; +/** A modifier pressed and released alone, held at most this long, is a tap. */ +const TAP_MS = 400; +/** Two taps of the same modifier this close are a double tap: `Shift Shift`. */ +const DOUBLE_TAP_MS = 500; +const MODIFIER_KEYS: Readonly> = { + Shift: 'Shift', + Control: 'Ctrl', + Alt: 'Alt', + Meta: 'Meta', +}; /** Dispatches keyboard shortcuts to commands, honoring `when` clauses, the preset and user overrides. */ export class KeybindingService implements Disposable { private readonly _store = new DisposableStore(); private _pending: { strokes: string[]; timer: ReturnType } | undefined; private _suspended = 0; + /** The modifier held alone right now, and since when. */ + private _held: { modifier: string; since: number } | undefined; + /** The last tap of a modifier, waiting for a second one. */ + private _tap: { modifier: string; at: number } | undefined; constructor( private readonly _extensions: ExtensionRegistry, @@ -75,18 +89,34 @@ export class KeybindingService implements Disposable { }); } - /** Listens to keydown on a target (the editor window). */ + /** Listens to keydown (and keyup, for double taps) on a target (the editor window). */ attach(target: Pick): Disposable { - const listener = (event: Event) => this.handle(event as KeyboardEvent); - target.addEventListener('keydown', listener, { capture: true }); + const down = (event: Event) => this.handle(event as KeyboardEvent); + const up = (event: Event) => this.handleKeyUp(event as KeyboardEvent); + target.addEventListener('keydown', down, { capture: true }); + target.addEventListener('keyup', up, { capture: true }); return this._store.add( - toDisposable(() => target.removeEventListener('keydown', listener, { capture: true })), + toDisposable(() => { + target.removeEventListener('keydown', down, { capture: true }); + target.removeEventListener('keyup', up, { capture: true }); + }), ); } /** Returns true when the event ran (or started) a keybinding; its default is then prevented. */ handle(event: KeyboardEvent): boolean { if (this._suspended) return false; + const modifier = MODIFIER_KEYS[event.key]; + if (modifier) { + const alone = ['ctrlKey', 'altKey', 'shiftKey', 'metaKey'].filter( + (flag) => event[flag as 'ctrlKey'], + ); + if (!event.repeat) + this._held = alone.length <= 1 ? { modifier, since: Date.now() } : undefined; + return false; + } + this._held = undefined; + this._tap = undefined; const stroke = strokeFromEvent(event); if (!stroke) return false; const strokes = [...(this._pending?.strokes ?? []), stroke]; @@ -113,6 +143,42 @@ export class KeybindingService implements Disposable { return false; } + /** + * A modifier released alone counts as a tap; a second tap of it soon after runs the binding of + * the double tap (`Shift Shift`). A single tap never starts a chord: typing goes on as usual. + */ + handleKeyUp(event: KeyboardEvent): boolean { + const held = this._held; + this._held = undefined; + const modifier = MODIFIER_KEYS[event.key]; + if (this._suspended || !held || held.modifier !== modifier) return false; + const now = Date.now(); + if (now - held.since > TAP_MS) { + this._tap = undefined; + return false; + } + const tap = this._tap; + if (!tap || tap.modifier !== modifier || now - tap.at > DOUBLE_TAP_MS) { + this._tap = { modifier, at: now }; + return false; + } + this._tap = undefined; + const binding = this.bindings.find( + (candidate) => + candidate.strokes.length === 2 && + candidate.strokes.every((stroke) => stroke === modifier) && + this._context.evaluate(candidate.when), + ); + if (!binding) return false; + this._clearPending(); + this._commands + .execute(binding.command, ...binding.args) + .catch((error: unknown) => + this._logger?.error(`Keybinding ${binding.key} → ${binding.command} failed`, error), + ); + return true; + } + dispose(): void { this._clearPending(); this._store.dispose(); diff --git a/packages/ui/src/workbench/keybinding/keystroke.ts b/packages/ui/src/workbench/keybinding/keystroke.ts index 1dc71656..22853de4 100644 --- a/packages/ui/src/workbench/keybinding/keystroke.ts +++ b/packages/ui/src/workbench/keybinding/keystroke.ts @@ -83,6 +83,9 @@ export const formatKeybinding = (key: string, mac = isMac()): string => .map((stroke) => mac ? stroke + .replace(/^Shift$/, '⇧') + .replace(/^Alt$/, '⌥') + .replace(/^Meta$/, '⌘') .replace('Ctrl+', '⌃') .replace('Alt+', '⌥') .replace('Shift+', '⇧') diff --git a/packages/ui/src/workbench/setup/setup-shell.ts b/packages/ui/src/workbench/setup/setup-shell.ts index 0bc980bb..1a818a40 100644 --- a/packages/ui/src/workbench/setup/setup-shell.ts +++ b/packages/ui/src/workbench/setup/setup-shell.ts @@ -9,6 +9,7 @@ import { type ExtensionRegistry, type Logger, type Observable, + ObservableValue, type PluginHost, type StaticContributionHandler, derived, @@ -90,13 +91,25 @@ export const setupShell = (options: ShellOptions): Shell => { const styles = new StyleService(); store.add(styles.inject('core', BASE_CSS, { layer: 'reset' })); store.add(styles.inject('core', KIT_CSS, { layer: 'ui' })); + const prefersLight = new ObservableValue(false); + const scheme = globalThis.matchMedia?.('(prefers-color-scheme: light)'); + if (scheme) { + const update = () => prefersLight.set(scheme.matches); + update(); + scheme.addEventListener('change', update); + store.add({ dispose: () => scheme.removeEventListener('change', update) }); + } const themes = store.add( new ThemeService( extensions, styles, settings.observe(CoreSettings.theme), document.documentElement, - { density: settings.observe(CoreSettings.density) as Observable, logger }, + { + density: settings.observe(CoreSettings.density) as Observable, + prefersLight: prefersLight.readonly(), + logger, + }, ), ); const iconThemes = store.add( diff --git a/packages/ui/test/style.svelte.spec.ts b/packages/ui/test/style.svelte.spec.ts index e4372e37..5e761895 100644 --- a/packages/ui/test/style.svelte.spec.ts +++ b/packages/ui/test/style.svelte.spec.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ExtensionRegistry, ObservableValue } from '@nanoforge-dev/editor-kernel'; import { StyleService } from '../src/style/style-service'; -import { ThemeService } from '../src/theme/theme-service'; +import { SYSTEM_THEME, ThemeService } from '../src/theme/theme-service'; const element = (html: string) => { const host = document.createElement('div'); @@ -58,4 +58,24 @@ describe('ThemeService', () => { expect(root.dataset.nfTheme).toBe('nanoforge-dark'); themes.dispose(); }); + + it('follows the light or dark mode of the system while the setting is system', () => { + const selected = new ObservableValue(SYSTEM_THEME); + const prefersLight = new ObservableValue(true); + const themes = new ThemeService( + new ExtensionRegistry(), + new StyleService(), + selected, + document.documentElement, + { prefersLight }, + ); + const root = document.documentElement; + expect(root.dataset.nfTheme).toBe('nanoforge-light'); + prefersLight.set(false); + expect(root.dataset.nfTheme).toBe('nanoforge-dark'); + expect(themes.resolve(SYSTEM_THEME).id).toBe('nanoforge-dark'); + selected.set('nanoforge-light'); + expect(root.dataset.nfTheme).toBe('nanoforge-light'); + themes.dispose(); + }); }); diff --git a/packages/ui/test/workbench/keybinding/keybindings.spec.ts b/packages/ui/test/workbench/keybinding/keybindings.spec.ts index 610f8c9a..8c8a54d4 100644 --- a/packages/ui/test/workbench/keybinding/keybindings.spec.ts +++ b/packages/ui/test/workbench/keybinding/keybindings.spec.ts @@ -68,6 +68,7 @@ describe('keybinding parsing', () => { expect(normalizeStroke('esc', false)).toBe('Escape'); expect(parseKeybinding('ctrl+k ctrl+s', false)).toEqual(['Ctrl+K', 'Ctrl+S']); expect(formatKeybinding('Mod+Shift+P', true)).toBe('⇧⌘P'); + expect(formatKeybinding('Shift Shift', true)).toBe('⇧ ⇧'); expect(() => normalizeStroke('Hyper+X', false)).toThrow(/modifier/); }); }); @@ -99,6 +100,33 @@ describe('KeybindingService', () => { expect(ran).toEqual(['palette', 'save']); }); + it('runs a double tap of a modifier, and lets single taps and held keys type', async () => { + vi.useFakeTimers(); + try { + const { service, ran, bind } = setup(); + bind({ key: 'Shift Shift', command: 'other' }); + const tap = (after = 50) => { + service.handle(key({ key: 'Shift', shiftKey: true })); + vi.advanceTimersByTime(after); + return service.handleKeyUp(key({ key: 'Shift' })); + }; + expect(tap()).toBe(false); + expect(tap()).toBe(true); + /** A capital letter in between breaks the double tap. */ + expect(tap()).toBe(false); + expect(service.handle(key({ key: 'A', shiftKey: true }))).toBe(false); + expect(tap()).toBe(false); + vi.advanceTimersByTime(600); + /** Too slow, then held too long. */ + expect(tap()).toBe(false); + expect(tap(500)).toBe(false); + await Promise.resolve(); + expect(ran).toEqual(['other']); + } finally { + vi.useRealTimers(); + } + }); + it('applies user overrides and reports conflicts', () => { const { service, bind } = setup([ { command: 'quickOpen', key: 'Ctrl+P', remove: true }, diff --git a/plugins/code-editor/src/index.ts b/plugins/code-editor/src/index.ts index 12e4e717..ac030c61 100644 --- a/plugins/code-editor/src/index.ts +++ b/plugins/code-editor/src/index.ts @@ -24,7 +24,7 @@ import { import { setupMonaco } from './monaco/monaco'; import { CodeEditorService } from './service/code-editor-service'; import type { Comparison, OpenOptions } from './service/code-editor.type'; -import { current } from './session/code-editor-session'; +import { current, sidePanelRequest } from './session/code-editor-session'; import ScriptScreen from './widgets/script/ScriptScreen.svelte'; export const SCRIPT_SCREEN = 'code-editor.script'; @@ -119,6 +119,7 @@ export default definePlugin({ withService(async (service, path: string, options: OpenOptions = {}) => { await context.executeCommand('workbench.openWidget', SCRIPT_SCREEN); await service.open(path, options); + if (options.sidePanel) sidePanelRequest.set({ id: options.sidePanel }); }), ], [ diff --git a/plugins/code-editor/src/service/code-editor.type.ts b/plugins/code-editor/src/service/code-editor.type.ts index 283d0220..606feadd 100644 --- a/plugins/code-editor/src/service/code-editor.type.ts +++ b/plugins/code-editor/src/service/code-editor.type.ts @@ -31,6 +31,8 @@ export interface OpenOptions { readonly line?: number; readonly column?: number; readonly group?: number; + /** A side panel to show next to the file (`ecs.component`), opened even if it was closed. */ + readonly sidePanel?: string; } /** Where to show a position in a group (consumed by the group's editor). */ diff --git a/plugins/code-editor/src/session/code-editor-session.ts b/plugins/code-editor/src/session/code-editor-session.ts index 1b7ce2a9..a4374b5a 100644 --- a/plugins/code-editor/src/session/code-editor-session.ts +++ b/plugins/code-editor/src/session/code-editor-session.ts @@ -7,3 +7,6 @@ import type { CodeEditorService } from '../service/code-editor-service'; export const current = new ObservableValue< { service: CodeEditorService; monaco: Monaco } | undefined >(undefined); + +/** The side panel `codeEditor.open` asked for last (a new object per request). */ +export const sidePanelRequest = new ObservableValue<{ readonly id: string } | undefined>(undefined); diff --git a/plugins/code-editor/src/widgets/script/ScriptScreen.svelte b/plugins/code-editor/src/widgets/script/ScriptScreen.svelte index 381a5c92..0fcb3a8a 100644 --- a/plugins/code-editor/src/widgets/script/ScriptScreen.svelte +++ b/plugins/code-editor/src/widgets/script/ScriptScreen.svelte @@ -12,7 +12,7 @@ import EditorGroupView from './EditorGroupView.svelte'; import type { EditorLayout } from '../../service/code-editor.type'; - import { current } from '../../session/code-editor-session'; + import { current, sidePanelRequest } from '../../session/code-editor-session'; const { instance }: { instance: WidgetInstance } = $props(); const layout = $derived($current?.service.layout); @@ -34,6 +34,15 @@ .filter((panel) => panel.appliesTo(path)); }); const panel = $derived(panels.find((candidate) => candidate.id === chosenPanel) ?? panels[0]); + /** _Edit layout_ and the like: `codeEditor.open` with a side panel shows it. */ + $effect(() => + sidePanelRequest.subscribe((request) => { + if (!request) return; + sidePanelRequest.set(undefined); + chosenPanel = request.id; + panelOpen = true; + }), + ); $effect(() => { const subscriptions = $panelContributions.flatMap((contribution) => contribution.value.changes diff --git a/plugins/command-palette/nanoforge.manifest.json b/plugins/command-palette/nanoforge.manifest.json index 8dab1fc0..339ceda7 100644 --- a/plugins/command-palette/nanoforge.manifest.json +++ b/plugins/command-palette/nanoforge.manifest.json @@ -56,6 +56,48 @@ { "key": "Mod+F2", "command": "workbench.openWidget", "args": ["viewport.game"] }, { "key": "Mod+F3", "command": "workbench.openWidget", "args": ["code-editor.script"] } ] + }, + { + "id": "intellij", + "title": "IntelliJ", + "description": "Shortcuts close to IntelliJ IDEA's: Shift Shift, Ctrl+Shift+A, Alt+1 for the tool windows. Those a browser keeps for itself (Ctrl+T, Ctrl+F4, Ctrl+F5) are left out.", + "bindings": [ + { "key": "Shift Shift", "command": "palette.show" }, + { "key": "Mod+Shift+A", "command": "palette.show" }, + { "key": "Mod+F12", "command": "palette.symbols" }, + { "key": "Alt+1", "command": "workbench.openWidget", "args": ["file-manager.files"] }, + { "key": "Alt+4", "command": "console.show" }, + { "key": "Alt+6", "command": "console.showProblems" }, + { "key": "Alt+9", "command": "workbench.openWidget", "args": ["git.log"] }, + { "key": "Alt+0", "command": "workbench.openWidget", "args": ["git.changes"] }, + { "key": "Mod+K S", "command": "codeEditor.saveAll", "remove": true }, + { + "key": "Mod+S", + "command": "codeEditor.save", + "when": "focusedWidget == 'code-editor.script'", + "remove": true + }, + { "key": "Mod+S", "command": "codeEditor.saveAll" }, + { "key": "Mod+K", "command": "git.commit" }, + { + "key": "Mod+Alt+L", + "command": "codeEditor.format", + "when": "focusedWidget == 'code-editor.script'" + }, + { + "key": "Mod+Alt+O", + "command": "codeEditor.organizeImports", + "when": "focusedWidget == 'code-editor.script'" + }, + { + "key": "Alt+Insert", + "command": "fileManager.newFile", + "when": "focusedWidget == 'file-manager.files' && !textInputFocus" + }, + { "key": "Shift+F10", "command": "runtime.play", "when": "!runtime.active" }, + { "key": "Mod+F2", "command": "runtime.stop", "when": "runtime.active" }, + { "key": "Mod+Alt+Shift+S", "command": "project.show" } + ] } ] } diff --git a/plugins/console/nanoforge.manifest.json b/plugins/console/nanoforge.manifest.json index d3651a68..ac68a362 100644 --- a/plugins/console/nanoforge.manifest.json +++ b/plugins/console/nanoforge.manifest.json @@ -37,8 +37,14 @@ "commands": [ { "id": "console.show", "title": "Show the console", "category": "Console" }, { "id": "console.clear", "title": "Clear the console", "category": "Console" }, - { "id": "console.showProblems", "title": "Show problems", "category": "Console" } + { "id": "console.showProblems", "title": "Show problems", "category": "Console" }, + { + "id": "console.errorsOnly", + "title": "Show only errors, or every level again", + "category": "Console" + } ], + "keybindings": [{ "key": "Mod+Alt+E", "command": "console.errorsOnly" }], "settings": [ { "key": "maxLines", diff --git a/plugins/console/src/icons.ts b/plugins/console/src/icons.ts index 332f12d9..01e4c5ba 100644 --- a/plugins/console/src/icons.ts +++ b/plugins/console/src/icons.ts @@ -1,6 +1,7 @@ import Bug from '@lucide/svelte/icons/bug'; +import Download from '@lucide/svelte/icons/download'; import Info from '@lucide/svelte/icons/info'; import ListFilter from '@lucide/svelte/icons/list-filter'; /** Icons of the panels that the editor's kit doesn't register. */ -export const ICONS = { bug: Bug, info: Info, 'list-filter': ListFilter }; +export const ICONS = { bug: Bug, download: Download, info: Info, 'list-filter': ListFilter }; diff --git a/plugins/console/src/index.ts b/plugins/console/src/index.ts index c8ca2446..035e5186 100644 --- a/plugins/console/src/index.ts +++ b/plugins/console/src/index.ts @@ -67,6 +67,12 @@ export default definePlugin({ const show = (widget: string) => commands.execute('workbench.openWidget', widget); context.subscriptions.add(context.registerCommand('console.show', () => show(CONSOLE))); context.subscriptions.add(context.registerCommand('console.clear', () => store.clear())); + context.subscriptions.add( + context.registerCommand('console.errorsOnly', async () => { + store.askErrorsOnly(); + await show(CONSOLE); + }), + ); context.subscriptions.add( context.registerCommand('console.showProblems', () => show(PROBLEMS)), ); diff --git a/plugins/console/src/store/console-store.ts b/plugins/console/src/store/console-store.ts index 26f01731..b64c1d96 100644 --- a/plugins/console/src/store/console-store.ts +++ b/plugins/console/src/store/console-store.ts @@ -1,5 +1,7 @@ import { type Disposable, + Emitter, + type Event, type LogEntry, LogLevel, type LogLocation, @@ -75,6 +77,8 @@ export class ConsoleStore implements Disposable { private _next = 0; private readonly _revision = new ObservableValue(0); private readonly _sink: Disposable; + private readonly _errorsOnly = new Emitter(); + private _errorsOnlyAsked = false; /** Bumped when the lines change. */ readonly revision = this._revision.readonly(); @@ -113,7 +117,26 @@ export class ConsoleStore implements Disposable { this._revision.set(this._revision.get() + 1); } + /** Fired when a command asks the Console to show only errors (or every level again). */ + get onErrorsOnly(): Event { + return this._errorsOnly.event; + } + + /** Asks the Console to toggle errors only: now if it is shown, else when it mounts. */ + askErrorsOnly(): void { + this._errorsOnlyAsked = true; + this._errorsOnly.fire(); + } + + /** Whether errors only was asked and not handled yet (it is handled now). */ + takeErrorsOnly(): boolean { + const asked = this._errorsOnlyAsked; + this._errorsOnlyAsked = false; + return asked; + } + dispose(): void { this._sink.dispose(); + this._errorsOnly.dispose(); } } diff --git a/plugins/console/src/widgets/console/ConsoleView.svelte b/plugins/console/src/widgets/console/ConsoleView.svelte index 10ec31c5..071267d4 100644 --- a/plugins/console/src/widgets/console/ConsoleView.svelte +++ b/plugins/console/src/widgets/console/ConsoleView.svelte @@ -15,11 +15,12 @@ Menu, type MenuEntry, type WidgetInstance, + saveAsFile, } from '@nanoforge-dev/editor-sdk/ui'; import ConsoleLineRow from './ConsoleLineRow.svelte'; import GroupChips from './GroupChips.svelte'; - import { consoleStats, sourcesMenu } from './console-filters'; + import { consoleStats, sourcesMenu, toggledErrorsOnly } from './console-filters'; import type { ConsoleLine, LineLevel } from '../../store/console-store'; import type { FileTarget } from '../../link/linkify'; import { FrameLocator, createSegmenter } from '../../link/frame-locator'; @@ -65,6 +66,16 @@ $effect(() => instance.setState({ groups: { ...groups }, hidden: [...hidden], levels: { ...levels } }), ); + + /** _Console › Show only errors_ (Ctrl+Alt+E): errors only, or every level when it already is. */ + const toggleErrorsOnly = () => (levels = toggledErrorsOnly(levels)); + if (store.takeErrorsOnly()) toggleErrorsOnly(); + $effect(() => { + const subscription = store.onErrorsOnly(() => { + if (store.takeErrorsOnly()) toggleErrorsOnly(); + }); + return () => subscription.dispose(); + }); let query = $state(''); let revision = $state(0); @@ -124,12 +135,14 @@ let list = $state(); let follow = $state(true); + /** The pointer is over the lines: new lines don't scroll them away from what it points at. */ + let hovering = $state(false); const toBottom = () => { if (list) list.scrollTop = list.scrollHeight; }; $effect(() => { void visible; - if (untrack(() => follow)) void tick().then(toBottom); + if (untrack(() => follow && !hovering)) void tick().then(toBottom); }); const onscroll = () => { if (!list) return; @@ -147,6 +160,10 @@ const asText = (line: ConsoleLine) => `${time(line.time)} [${sourceLabel(line.source)}] ${line.text}${line.count > 1 ? ` (×${line.count})` : ''}`; const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => undefined); + const save = () => { + const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); + saveAsFile(`console-${stamp}.log`, `${visible.map(asText).join('\n')}\n`); + }; const contextItems = (): MenuEntry[] => { const line = contextLine; return [ @@ -167,6 +184,13 @@ disabled: !visible.length, onSelect: () => copy(visible.map(asText).join('\n')), }, + { + kind: 'item', + id: 'save', + label: 'Save shown lines…', + disabled: !visible.length, + onSelect: save, + }, { kind: 'separator' }, { kind: 'item', id: 'clear', label: 'Clear console', onSelect: clear }, ]; @@ -192,6 +216,12 @@ {/each} + @@ -215,6 +245,11 @@ aria-label="Console" aria-live="off" {onscroll} + onpointerenter={() => (hovering = true)} + onpointerleave={() => { + hovering = false; + if (follow) toBottom(); + }} oncontextmenu={(event) => { const id = (event.target as HTMLElement).closest('[data-line]')?.dataset .line; diff --git a/plugins/console/src/widgets/console/console-filters.ts b/plugins/console/src/widgets/console/console-filters.ts index 118acab4..9e55c94b 100644 --- a/plugins/console/src/widgets/console/console-filters.ts +++ b/plugins/console/src/widgets/console/console-filters.ts @@ -1,6 +1,6 @@ import type { MenuEntry } from '@nanoforge-dev/editor-sdk/ui'; -import type { ConsoleLine } from '../../store/console-store'; +import type { ConsoleLine, LineLevel } from '../../store/console-store'; import { GROUPS, type GroupId, sourceLabel } from '../../store/log-sources'; export interface GroupStats { @@ -58,3 +58,11 @@ export const sourcesMenu = ( ? entries : [{ kind: 'item', id: 'none', label: 'No sources yet', disabled: true, onSelect: () => {} }]; }; + +/** _Show only errors_: errors only, or the usual levels again when only errors are shown. */ +export const toggledErrorsOnly = ( + levels: Readonly>, +): Record => + levels.error && !levels.debug && !levels.info && !levels.warn + ? { debug: false, info: true, warn: true, error: true } + : { debug: false, info: false, warn: false, error: true }; diff --git a/plugins/console/src/widgets/problems/ProblemsView.svelte b/plugins/console/src/widgets/problems/ProblemsView.svelte index 6f5bcd97..0992c29d 100644 --- a/plugins/console/src/widgets/problems/ProblemsView.svelte +++ b/plugins/console/src/widgets/problems/ProblemsView.svelte @@ -52,6 +52,51 @@ : [...collapsed, key]; }; + /** + * One Tab stop for the list: rows are `g:` (a file) and `p::` (its + * problems). Up and Down go through the rows shown, Home and End to the ends, Left folds a file + * or goes up to it, Right unfolds it or goes down to its first problem. + */ + const groupRow = (key: string) => `g:${key}`; + const problemRow = (key: string, index: number) => `p:${index}:${key}`; + const groupOfRow = (row: string) => + row.startsWith('g:') ? row.slice(2) : row.slice(row.indexOf(':', 2) + 1); + const rows = $derived( + groups.flatMap((group) => [ + groupRow(group.key), + ...(collapsed.includes(group.key) + ? [] + : group.problems.map((_, index) => problemRow(group.key, index))), + ]), + ); + let active = $state(); + const current = $derived(active && rows.includes(active) ? active : rows[0]); + let list = $state(); + const focusRow = (row: string | undefined) => { + if (!row) return; + active = row; + list?.querySelector(`[data-row="${CSS.escape(row)}"]`)?.focus(); + }; + const onkeydown = (event: KeyboardEvent) => { + const row = (event.target as HTMLElement).closest('[data-row]')?.dataset.row; + if (!row) return; + const at = rows.indexOf(row); + const key = groupOfRow(row); + const folded = collapsed.includes(key); + if (event.key === 'ArrowDown') focusRow(rows[Math.min(at + 1, rows.length - 1)]); + else if (event.key === 'ArrowUp') focusRow(rows[Math.max(at - 1, 0)]); + else if (event.key === 'Home') focusRow(rows[0]); + else if (event.key === 'End') focusRow(rows.at(-1)); + else if (event.key === 'ArrowLeft') { + if (row.startsWith('p:')) focusRow(groupRow(key)); + else if (!folded) toggle(key); + } else if (event.key === 'ArrowRight' && row.startsWith('g:')) { + if (folded) toggle(key); + else focusRow(rows[at + 1]?.startsWith('p:') ? rows[at + 1] : undefined); + } else return; + event.preventDefault(); + }; + const counts = (errors: number, warnings: number) => [ errors && `${errors} error${errors > 1 ? 's' : ''}`, @@ -91,11 +136,30 @@ -
+ +
{ + const row = (event.target as HTMLElement).closest('[data-row]')?.dataset.row; + if (row) active = row; + }} + > {#each groups as group (group.key)} {@const open_ = !collapsed.includes(group.key)}
- + {#if data.type === 'component' && !item.readonly} + + {/if} {#if item.source.kind === 'app' && libraries.length} {#snippet trigger({ props })} diff --git a/plugins/ecs/src/widgets/component-panel/ComponentPanel.svelte b/plugins/ecs/src/widgets/component-panel/ComponentPanel.svelte index c76649b0..44fa966d 100644 --- a/plugins/ecs/src/widgets/component-panel/ComponentPanel.svelte +++ b/plugins/ecs/src/widgets/component-panel/ComponentPanel.svelte @@ -5,6 +5,7 @@ import { Select } from '@nanoforge-dev/editor-sdk/ui'; import { EcsServiceToken } from '../../service/ecs-service'; + import { BUILT_IN_PRESETS } from '../../layout/param-layout'; import type { ComponentUse } from '../../model/ecs-model.type'; import type { ItemDocsOp, ParamDocs } from '../../model/item-docs.type'; import ComponentCard from '../inspector/ComponentCard.svelte'; @@ -16,6 +17,8 @@ const { path }: { path: string } = $props(); const ecs = panelServices().get(EcsServiceToken); const catalog = ecs.catalogState; + const contributed = ecs.paramPresets; + const presets = $derived([...$contributed, ...BUILT_IN_PRESETS]); let chosen = $state(); const opened = ecs.openedItem; @@ -88,7 +91,7 @@ const setPreset = (element: Element, presetId: string | undefined) => { if (!presetId) return setParam(element, { preset: undefined }, `Remove the preset of ${element.name}`); - const preset = presetDoc(params, element, presetId); + const preset = presetDoc(params, element, presetId, presets); if (preset) setParam(element, { preset }, `Set the preset of ${element.name}`); }; @@ -154,6 +157,7 @@ onchange={(change, label) => setParam(element, change, label)} onnewgroup={() => newGroupFor(element)} onpreset={(presetId) => setPreset(element, presetId)} + {presets} /> {/each}
@@ -165,6 +169,7 @@ component={preview} {item} shown={new Set()} + presets={$contributed} first last onargs={() => undefined} diff --git a/plugins/ecs/src/widgets/component-panel/ParamEditor.svelte b/plugins/ecs/src/widgets/component-panel/ParamEditor.svelte index 7983cb9c..7092a83c 100644 --- a/plugins/ecs/src/widgets/component-panel/ParamEditor.svelte +++ b/plugins/ecs/src/widgets/component-panel/ParamEditor.svelte @@ -2,7 +2,7 @@ import type { Element, ParamGroup } from '@nanoforge-dev/editor-sdk'; import { Button, ColorPicker, Input, Select, Switch } from '@nanoforge-dev/editor-sdk/ui'; - import { BUILT_IN_PRESETS } from '../../layout/param-layout'; + import type { PresetDefinition } from '../../layout/param-layout'; import { hexColor } from '../../model/arg-values'; import type { ParamDocs } from '../../model/item-docs.type'; import { freeSlots } from './component-docs'; @@ -17,13 +17,25 @@ /** Puts the param in a group of its own. */ onnewgroup: () => void; onpreset: (presetId: string | undefined) => void; + /** The built-in presets and those of plugins (`ecs.paramPresets`). */ + presets: readonly PresetDefinition[]; } - const { element, params, groups, readonly, onchange, onnewgroup, onpreset }: Props = $props(); + const { element, params, groups, readonly, onchange, onnewgroup, onpreset, presets }: Props = + $props(); const NONE = '__none__'; const NEW_GROUP = '__new__'; - const presetIds = [...new Set(BUILT_IN_PRESETS.map((preset) => preset.id))]; + /** Presets this param's type can use, by id (a preset has a definition per type). */ + const presetChoices = $derived( + [ + ...new Map( + presets + .filter((preset) => preset.types.includes(element.type)) + .map((preset) => [preset.id, preset.title]), + ), + ].map(([value, label]) => ({ value, label })), + ); const presetId = $derived(element.layout?.preset?.id ?? NONE); @@ -78,14 +90,14 @@ readonly string[]; + +/** Gives the fields below the assets of the app being edited (their pickers). */ +export const provideAssets = (list: AssetList): void => void setContext(KEY, list); + +/** The assets given by an inspector above; undefined where none is (a plain text field then). */ +export const assetList = (): AssetList | undefined => getContext(KEY); diff --git a/plugins/ecs/test/create/item-file-templates.spec.ts b/plugins/ecs/test/create/item-file-templates.spec.ts new file mode 100644 index 00000000..f5c34ccd --- /dev/null +++ b/plugins/ecs/test/create/item-file-templates.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { itemFileTemplates } from '../../src/create/item-file-templates'; + +const apps = [ + { root: 'apps/server', type: 'server' }, + { root: 'libs/shared', type: 'lib' }, +] as never[]; + +describe('itemFileTemplates', () => { + const [component, system] = itemFileTemplates(() => apps); + + it('are offered in apps and shared libraries only', () => { + expect(component!.when).toBe('appType'); + expect(system!.category).toBe('ECS'); + }); + + it("writes the item for the side of the folder's app, in kebab case", async () => { + const [file] = await component!.create({ + folder: 'libs/shared/src/x', + name: 'PlayerHealth.ts', + }); + expect(file!.path).toBe('player-health.ts'); + expect(file!.content).toContain('export class PlayerHealth'); + expect(file!.content).toContain('@side shared'); + const [tick] = await system!.create({ folder: 'apps/server/src', name: 'tick' }); + expect(tick!.content).toContain('@nanoforge-dev/ecs/server'); + }); + + it('refuses a name of the wrong case, and a folder outside every app', () => { + expect(() => component!.create({ folder: 'apps/server', name: 'health' })).toThrow(/capital/); + expect(() => system!.create({ folder: 'docs', name: 'tick' })).toThrow(/no app/); + }); +}); diff --git a/plugins/ecs/test/layout/layout-diagnostics.spec.ts b/plugins/ecs/test/layout/layout-diagnostics.spec.ts new file mode 100644 index 00000000..36203525 --- /dev/null +++ b/plugins/ecs/test/layout/layout-diagnostics.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { declarationOffset, layoutWarnings } from '../../src/layout/layout-diagnostics'; + +const component = (name: string, preset?: string, readonly = false) => + ({ + ref: name, + readonly, + meta: { + export: name, + source: `apps/client/src/components/${name}.ts`, + params: [ + { + type: 'number', + name: 'x', + ...(preset && { layout: { preset: { id: preset, slot: 'x' } } }), + }, + ], + fields: [], + groups: [], + '@nanoforge/ecs': { type: 'component', name }, + }, + }) as never; + +describe('layout diagnostics', () => { + it('finds the name in the declaration of an export', () => { + const text = 'import x from "y";\n\n/** Doc. */\nexport class Position {\n}\n'; + const at = declarationOffset(text, 'Position')!; + expect(text.slice(at.start, at.start + at.length)).toBe('Position'); + expect(declarationOffset(text, 'Missing')).toBeUndefined(); + }); + + it('reports the warnings of components the project can edit', () => { + const warnings = layoutWarnings( + [component('Broken', 'vector'), component('Fine'), component('Package', 'vector', true)], + [], + ); + expect(warnings.map((entry) => [entry.item.meta.export, entry.warnings])).toEqual([ + ['Broken', ['The preset "vector" needs the slots x, y.']], + ]); + }); +}); diff --git a/plugins/ecs/test/live/live-service.spec.ts b/plugins/ecs/test/live/live-service.spec.ts index 97581489..49ab10ea 100644 --- a/plugins/ecs/test/live/live-service.spec.ts +++ b/plugins/ecs/test/live/live-service.spec.ts @@ -7,7 +7,12 @@ import { RuntimeServiceToken, } from '@nanoforge-dev/editor-sdk'; -import { LiveService, type Pending, withoutApplied } from '../../src/live/live-service'; +import { + LiveService, + type Pending, + pendingCount, + withoutApplied, +} from '../../src/live/live-service'; vi.mock('@nanoforge-dev/editor-sdk/ui', () => ({ NotificationServiceToken: Symbol('notifications'), @@ -194,3 +199,13 @@ describe('LiveService worlds', () => { service.dispose(); }); }); + +describe('pendingCount', () => { + it('counts entities, not apps left without any', () => { + const pending = new Map([ + ['apps/client', new Map([['ball', new Map()]])], + ['apps/server', new Map()], + ]) as never; + expect(pendingCount(pending)).toBe(1); + }); +}); diff --git a/plugins/ecs/test/model/asset-paths.spec.ts b/plugins/ecs/test/model/asset-paths.spec.ts new file mode 100644 index 00000000..ba108b24 --- /dev/null +++ b/plugins/ecs/test/model/asset-paths.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { assetPaths } from '../../src/model/asset-paths'; + +describe('assetPaths', () => { + const entries = [ + { path: 'apps/client/assets/textures', kind: 'directory' }, + { path: 'apps/client/assets/textures/hero.PNG', kind: 'file' }, + { path: 'apps/client/assets/music.ogg', kind: 'file' }, + { path: 'apps/client/assets-old/ball.png', kind: 'file' }, + { path: 'apps/client/src/main.ts', kind: 'file' }, + ] as const; + + it('lists the files of the assets folder as the game loads them', () => { + expect(assetPaths(entries, 'apps/client/assets')).toEqual(['/music.ogg', '/textures/hero.PNG']); + }); + + it('keeps the accepted extensions, whatever their case', () => { + expect(assetPaths(entries, 'apps/client/assets', ['.png', '.jpg'])).toEqual([ + '/textures/hero.PNG', + ]); + }); +}); diff --git a/plugins/ecs/test/widgets/entity-action-entries.spec.ts b/plugins/ecs/test/widgets/entity-action-entries.spec.ts new file mode 100644 index 00000000..ef3fff9e --- /dev/null +++ b/plugins/ecs/test/widgets/entity-action-entries.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { ContextKeyService } from '@nanoforge-dev/editor-sdk'; + +import { entityActionEntries } from '../../src/widgets/hierarchy/entity-action-entries'; + +/** Context keys reading `key`, `key == 'x'` and throwing on `!!`. */ +const contextKeys = { + createScoped: () => { + const values = new Map(); + return { + set: (key: string, value: unknown) => values.set(key, value), + dispose: () => values.clear(), + evaluate: (when: string | undefined) => { + if (!when) return true; + if (when.startsWith('!!')) throw new Error('Invalid when clause'); + if (!when.includes('==')) return values.get(when) === true; + const [key, value] = when.split('==').map((part) => part.trim().replace(/^'|'$/g, '')); + return values.get(key!) === value; + }, + }; + }, +} as unknown as ContextKeyService; + +describe('entityActionEntries', () => { + const ball = { + name: 'ball', + components: [{ className: 'Position' }, { className: 'Velocity' }], + } as never; + const target = { app: 'apps/client', entity: 'ball', path: 'apps/client/src/main.ts' }; + + it('offers the actions whose when holds, after a separator, running their command', () => { + const run = vi.fn(); + const entries = entityActionEntries( + [ + { id: 'greet', title: 'Greet', command: 'hello.greet' }, + { id: 'move', title: 'Move', command: 'acme.move', when: 'ecsComponent:Position' }, + { id: 'size', title: 'Size', command: 'acme.size', when: 'ecsComponent:Size' }, + { id: 'broken', title: 'Broken', command: 'acme.broken', when: '!!' }, + { id: 'paddle', title: 'Paddle', command: 'acme.paddle', when: "ecsEntity == 'paddle'" }, + ], + ball, + target, + contextKeys, + run, + ); + expect(entries.map((entry) => ('label' in entry ? entry.label : entry.kind))).toEqual([ + 'separator', + 'Greet', + 'Move', + ]); + const move = entries[2] as { onSelect: () => void }; + move.onSelect(); + expect(run).toHaveBeenCalledWith('acme.move', target); + }); + + it('adds nothing without actions that apply', () => { + expect(entityActionEntries([], ball, target, contextKeys, vi.fn())).toEqual([]); + }); +}); diff --git a/plugins/file-manager/src/index.ts b/plugins/file-manager/src/index.ts index bcd0ee28..f2f560d6 100644 --- a/plugins/file-manager/src/index.ts +++ b/plugins/file-manager/src/index.ts @@ -62,6 +62,7 @@ export default definePlugin({ owner: context.name, }), extensions, + contextKeys: services.get(EditorServices.ContextKeys), prompts: services.tryGet(PromptServiceToken), notifications: services.tryGet(NotificationServiceToken), executeCommand: (id, ...args) => context.executeCommand(id, ...args), diff --git a/plugins/file-manager/src/menu/file-menu.ts b/plugins/file-manager/src/menu/file-menu.ts index c2581b68..7c555fdc 100644 --- a/plugins/file-manager/src/menu/file-menu.ts +++ b/plugins/file-manager/src/menu/file-menu.ts @@ -21,7 +21,7 @@ const SEPARATOR: MenuEntry = { kind: 'separator' }; /** "New" entries: file, folder and the contributed templates by category. */ export const newEntries = (service: FileManagerService, folder: string): MenuEntry[] => { - const templates = service.templates(); + const templates = service.templates(folder); const categories = [...new Set(templates.map((template) => template.category ?? ''))]; return [ item('new-file', 'File…', () => service.newFile(folder), { icon: 'file' }), diff --git a/plugins/file-manager/src/service/file-manager-service.ts b/plugins/file-manager/src/service/file-manager-service.ts index 3c617712..50980875 100644 --- a/plugins/file-manager/src/service/file-manager-service.ts +++ b/plugins/file-manager/src/service/file-manager-service.ts @@ -5,6 +5,7 @@ import { type FileEntry, type Observable, ObservableValue, + appAt, basename, dirname, editorsFor, @@ -94,8 +95,24 @@ export class FileManagerService implements Disposable { return isPackagePath(path); } - templates(): FileTemplate[] { - return this._options.extensions.getValues(FILE_TEMPLATES); + /** + * Templates offered in a folder: their `when` sees `fileFolder` (the folder) and `appType` + * (`client`, `server` or `lib` when the folder is in an app or a shared library, else empty). + * A template whose `when` can't be read is left out. + */ + templates(folder = this.targetFolder()): FileTemplate[] { + const scope = this._options.contextKeys.createScoped('file-manager.templates'); + scope.set('fileFolder', folder); + scope.set('appType', appAt(this._options.project.model.get().apps, folder)?.type ?? ''); + const templates = this._options.extensions.getValues(FILE_TEMPLATES).filter((template) => { + try { + return scope.evaluate(template.when); + } catch { + return false; + } + }); + scope.dispose(); + return templates; } editorsOf(path: string): DocumentEditor[] { @@ -139,7 +156,7 @@ export class FileManagerService implements Disposable { } async newFromTemplate(id: string, folder = this.targetFolder()): Promise { - const template = this.templates().find((candidate) => candidate.id === id); + const template = this.templates(folder).find((candidate) => candidate.id === id); if (!template) return; const name = await this._ask( `New ${template.title.toLowerCase()}`, diff --git a/plugins/file-manager/src/service/file-manager-service.type.ts b/plugins/file-manager/src/service/file-manager-service.type.ts index 60a82614..e6cecd80 100644 --- a/plugins/file-manager/src/service/file-manager-service.type.ts +++ b/plugins/file-manager/src/service/file-manager-service.type.ts @@ -1,5 +1,6 @@ import type { ClientProject, + ContextKeyService, ExtensionPoint, HistoryContext, Logger, @@ -20,6 +21,8 @@ export interface FileManagerServiceOptions { readonly project: ClientProject; readonly history: HistoryContext; readonly extensions: Contributions; + /** Evaluates the `when` of templates. */ + readonly contextKeys: Pick; readonly prompts: PromptService | undefined; readonly notifications: NotificationService | undefined; readonly executeCommand: (id: string, ...args: unknown[]) => Promise; diff --git a/plugins/file-manager/test/fixtures/service.ts b/plugins/file-manager/test/fixtures/service.ts index 0069bc74..6536633a 100644 --- a/plugins/file-manager/test/fixtures/service.ts +++ b/plugins/file-manager/test/fixtures/service.ts @@ -35,8 +35,9 @@ export const makeService = ( { path: 'nf_modules/ball.ts', kind: 'file' }, ] as FileEntry[], values: Record = {}, - { local = true } = {}, + { local = true, apps = [] as { root: string; type: string }[] } = {}, ) => { + const keys = contextKeys(); const executeCommand = vi.fn(async () => undefined); const ask = vi.fn<(options: { validate?: (value: string) => string | undefined }) => Promise>( async () => undefined, @@ -46,6 +47,7 @@ export const makeService = ( const service = new FileManagerService({ project: { id: 'p1', + model: { get: () => ({ apps }) }, fs: { entry: (path: string) => entries.find((entry) => entry.path === path), children: (path: string) => entries.filter((entry) => dirname(entry.path) === path), @@ -56,6 +58,7 @@ export const makeService = ( extensions: { getValues: (point: ExtensionPoint) => (values[point.id] ?? []) as T[], }, + contextKeys: keys, prompts: { ask } as never, notifications: { notify } as never, executeCommand, @@ -74,5 +77,5 @@ export const makeService = ( 'import', ] as const) vi.spyOn(service.operations, method).mockImplementation(async () => [] as never); - return { service, executeCommand, ask, notify, logger, contextKeys: contextKeys() }; + return { service, executeCommand, ask, notify, logger, contextKeys: keys }; }; diff --git a/plugins/file-manager/test/menu/file-menu.spec.ts b/plugins/file-manager/test/menu/file-menu.spec.ts index fa4a3054..aefd1cef 100644 --- a/plugins/file-manager/test/menu/file-menu.spec.ts +++ b/plugins/file-manager/test/menu/file-menu.spec.ts @@ -94,4 +94,24 @@ describe('file menu', () => { 'Readme…', ]); }); + + it('offers a template where its when holds: the folder and the type of its app', () => { + const { service } = makeService( + undefined, + { + 'files.templates': [ + { id: 'component', title: 'Component', when: "appType == 'client'" }, + { id: 'docs', title: 'Doc page', when: "fileFolder == 'docs'" }, + ], + }, + { apps: [{ root: 'apps/client', type: 'client' }] }, + ); + const labels = (folder: string) => + (newEntries(service, folder) as Entry[]).flatMap((entry) => + entry.label ? [entry.label] : [], + ); + expect(labels('apps/client/src')).toContain('Component…'); + expect(labels('apps/client/src')).not.toContain('Doc page…'); + expect(labels('docs')).toEqual(['File…', 'Folder…', 'Doc page…']); + }); }); diff --git a/plugins/git/src/actions/create-actions.ts b/plugins/git/src/actions/create-actions.ts index 8120e13f..0ee69f03 100644 --- a/plugins/git/src/actions/create-actions.ts +++ b/plugins/git/src/actions/create-actions.ts @@ -150,12 +150,12 @@ export const createActions = (options: { const message = text.trim(); return store.run('Could not stash', () => api.stash(input(message ? { message } : {}))); }, - applyStash: (index: number, pop: boolean) => + applyStash: (hash: string, pop: boolean) => store.run(pop ? 'Could not pop the stash' : 'Could not apply the stash', () => - api.applyStash(input({ index, pop })), + api.applyStash(input({ hash, pop })), ), - dropStash: (index: number) => - store.run('Could not drop the stash', () => api.dropStash(input({ index }))), + dropStash: (hash: string) => + store.run('Could not drop the stash', () => api.dropStash(input({ hash }))), /** Shows a file next to an earlier version of it, in the code editor. */ compare: async (path: string, revision: string, label: string) => { @@ -169,6 +169,15 @@ export const createActions = (options: { }, open: (path: string) => commands.execute('documents.open', path), info: (title: string) => notify('info', title), + /** Copies paths, one per line, to the clipboard. */ + copyPaths: async (paths: readonly string[]) => { + try { + await navigator.clipboard.writeText(paths.join('\n')); + notify('info', paths.length === 1 ? `Copied ${paths[0]}` : `Copied ${paths.length} paths`); + } catch (error) { + notify('warning', 'Could not copy the path', String(error)); + } + }, }; return actions; }; diff --git a/plugins/git/src/store/git-store.ts b/plugins/git/src/store/git-store.ts index 3f601873..210bec33 100644 --- a/plugins/git/src/store/git-store.ts +++ b/plugins/git/src/store/git-store.ts @@ -30,6 +30,8 @@ export interface GitState { readonly more: boolean; /** An operation is running. */ readonly busy: boolean; + /** A read of the repository has been running for a while (a quick one does not show). */ + readonly reading: boolean; /** Why git can't be used here (a hosted editor), if so. */ readonly unavailable: string | undefined; /** Why the last read of the repository failed (git missing, a broken `.git`…), if it did. */ @@ -45,11 +47,14 @@ const EMPTY: GitState = { logLimit: 50, more: false, busy: false, + reading: false, unavailable: undefined, readError: undefined, }; const PAGE = 50; +/** A read shows as running only once it took this long. */ +const READING_MS = 250; /** * What the Git panel, the status bar and the Files marks show: the repository state of the open @@ -92,7 +97,12 @@ export class GitStore implements Disposable { */ refresh(): Promise { if (!this._refreshing) { - this._refreshing = this._read().finally(() => (this._refreshing = undefined)); + const shown = setTimeout(() => this._patch({ reading: true }), READING_MS); + this._refreshing = this._read().finally(() => { + clearTimeout(shown); + this._refreshing = undefined; + if (this._state.get().reading) this._patch({ reading: false }); + }); return this._refreshing; } this._queued ??= this._refreshing.then(() => { diff --git a/plugins/git/src/widgets/commit/ChangesTab.svelte b/plugins/git/src/widgets/commit/ChangesTab.svelte index feaa93b1..f3476775 100644 --- a/plugins/git/src/widgets/commit/ChangesTab.svelte +++ b/plugins/git/src/widgets/commit/ChangesTab.svelte @@ -2,7 +2,7 @@ import { untrack } from 'svelte'; import type { GitStatus } from '@nanoforge-dev/editor-sdk'; - import { ContextMenu, IconButton, type MenuEntry } from '@nanoforge-dev/editor-sdk/ui'; + import { ContextMenu, Icon, IconButton, type MenuEntry } from '@nanoforge-dev/editor-sdk/ui'; import { type FileRow, changeGroups } from '../../model/change-groups'; import { getSession } from '../../session/git-session'; @@ -121,6 +121,13 @@ disabled: selectedRows.length !== 1 || selectedRows[0]?.letter === 'D', onSelect: () => selectedRows[0] && jumpToSource(selectedRows[0]), }, + { + kind: 'item', + id: 'copy-path', + label: selectedRows.length > 1 ? 'Copy paths' : 'Copy path', + disabled: !selectedRows.length, + onSelect: () => void act.copyPaths(selectedRows.map((row) => row.file.path)), + }, ...(conflictedSelected.length ? ([ { kind: 'separator' }, @@ -258,6 +265,9 @@ onclick={() => void act.stash()} /> + {#if $git.reading} + Refreshing… + {/if}
    - {#each $git.stashes as entry (entry.index)} + {#each $git.stashes as entry (entry.hash)}
  • {entry.message} - void act.applyStash(entry.hash, false)}>Apply - void act.applyStash(entry.hash, true)}>Pop
  • diff --git a/plugins/git/src/widgets/log/CommitDetails.svelte b/plugins/git/src/widgets/log/CommitDetails.svelte index ddbfb40f..4366c378 100644 --- a/plugins/git/src/widgets/log/CommitDetails.svelte +++ b/plugins/git/src/widgets/log/CommitDetails.svelte @@ -61,6 +61,7 @@

{commit.subject}

+ {#if commit.body}

{commit.body}

{/if}

{commit.shortHash} {commit.author} on {new Date(commit.time * 1000).toLocaleString()} @@ -158,6 +159,11 @@ .hash { font-family: var(--nf-font-code); } + .body { + margin: var(--nf-space-1) 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + } .none { margin: 0; padding: var(--nf-space-2); diff --git a/plugins/git/src/widgets/log/LogView.svelte b/plugins/git/src/widgets/log/LogView.svelte index 8dae1f73..b435ddf9 100644 --- a/plugins/git/src/widgets/log/LogView.svelte +++ b/plugins/git/src/widgets/log/LogView.svelte @@ -1,4 +1,8 @@ diff --git a/plugins/git/test/store/git-store.spec.ts b/plugins/git/test/store/git-store.spec.ts index 73858de9..829d279a 100644 --- a/plugins/git/test/store/git-store.spec.ts +++ b/plugins/git/test/store/git-store.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { GitStatus } from '@nanoforge-dev/editor-sdk'; @@ -45,6 +45,27 @@ describe('GitStore', () => { expect(store.state.get().status?.repository).toBe(false); }); + it('shows a read as running only once it is slow', async () => { + vi.useFakeTimers(); + try { + const store = new GitStore(); + const repo = api(status()); + let open: () => void = () => undefined; + const gate = new Promise((resolve) => (open = resolve)); + /** setApi starts the read. */ + store.setApi({ ...repo.api, status: () => gate.then(() => status()) }); + await vi.advanceTimersByTimeAsync(100); + expect(store.state.get().reading).toBe(false); + await vi.advanceTimersByTimeAsync(200); + expect(store.state.get().reading).toBe(true); + open(); + await store.refresh(); + expect(store.state.get()).toMatchObject({ reading: false, status: { branch: 'main' } }); + } finally { + vi.useRealTimers(); + } + }); + it('refreshes after an operation and reports a failure', async () => { const errors: string[] = []; const store = new GitStore((title, error) => errors.push(`${title}: ${String(error)}`)); diff --git a/plugins/inspectors/src/export/inspector-export.ts b/plugins/inspectors/src/export/inspector-export.ts new file mode 100644 index 00000000..ab74b833 --- /dev/null +++ b/plugins/inspectors/src/export/inspector-export.ts @@ -0,0 +1,56 @@ +import type { FrameSample, Packet } from '../recorder/recorder'; + +/** A CSV field: quoted when it holds a comma, a quote or a line break. */ +const field = (value: string | number | undefined): string => { + const text = value === undefined ? '' : String(value); + return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +}; + +/** Rows to CSV, a header first, lines ending with `\n`. */ +export const toCsv = ( + header: readonly string[], + rows: readonly (readonly (string | number | undefined)[])[], +): string => [header, ...rows].map((row) => row.map(field).join(',')).join('\n') + '\n'; + +/** The profiler's windows: one row each, the libraries' average times as columns. */ +export const framesCsv = (frames: readonly FrameSample[]): string => { + const libraries = [...new Set(frames.flatMap((frame) => Object.keys(frame.stats.libraries)))]; + return toCsv( + [ + 'time', + 'ticks per second', + 'average tick ms', + 'slowest tick ms', + ...libraries.map((name) => `${name} ms`), + ], + frames.map((frame) => [ + new Date(frame.time).toISOString(), + frame.stats.tps, + frame.stats.tick.avg, + frame.stats.tick.max, + ...libraries.map((name) => frame.stats.libraries[name]?.avg), + ]), + ); +}; + +/** The network's packets: one row each, the payload the editor has as hexadecimal and as text. */ +export const packetsCsv = ( + packets: readonly Packet[], + textOf: (packet: Packet) => string, +): string => + toCsv( + ['time', 'direction', 'transport', 'client', 'size', 'payload hex', 'payload text'], + packets.map((packet) => [ + new Date(packet.time).toISOString(), + packet.direction, + packet.transport, + packet.clientId, + packet.size, + packet.data ?? packet.head, + textOf(packet), + ]), + ); + +/** `-.`, a name that sorts by date. */ +export const exportName = (what: string, extension: 'csv' | 'json'): string => + `${what}-${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.${extension}`; diff --git a/plugins/inspectors/src/icons.ts b/plugins/inspectors/src/icons.ts index 4afbd326..5271e44d 100644 --- a/plugins/inspectors/src/icons.ts +++ b/plugins/inspectors/src/icons.ts @@ -1,4 +1,5 @@ import Activity from '@lucide/svelte/icons/activity'; +import Download from '@lucide/svelte/icons/download'; import Globe from '@lucide/svelte/icons/globe'; import Network from '@lucide/svelte/icons/network'; import Pause from '@lucide/svelte/icons/pause'; @@ -7,6 +8,7 @@ import Play from '@lucide/svelte/icons/play'; /** Icons of the inspectors (registering one the editor already has is harmless). */ export const ICONS = { activity: Activity, + download: Download, globe: Globe, network: Network, pause: Pause, diff --git a/plugins/inspectors/src/recorder/recorder.ts b/plugins/inspectors/src/recorder/recorder.ts index fbb1628d..8461279f 100644 --- a/plugins/inspectors/src/recorder/recorder.ts +++ b/plugins/inspectors/src/recorder/recorder.ts @@ -138,6 +138,13 @@ export class Recorder implements Disposable { }; } + /** Forgets what a game reported so far; what it reports next is recorded as usual. */ + clear(source: GameSource): void { + if (!this._records.has(source)) return; + this._records.set(source, { ...emptyRecord(), world: this._records.get(source)?.world }); + this._bump(); + } + /** Forgets the last run (a new one starts). */ reset(): void { this._records = new Map(); diff --git a/plugins/inspectors/src/widgets/network/NetworkView.svelte b/plugins/inspectors/src/widgets/network/NetworkView.svelte index 5b2293bf..739177a1 100644 --- a/plugins/inspectors/src/widgets/network/NetworkView.svelte +++ b/plugins/inspectors/src/widgets/network/NetworkView.svelte @@ -2,15 +2,20 @@ import { tick, untrack } from 'svelte'; import { + Button, EmptyState, Input, + Menu, + type MenuEntry, type WidgetInstance, moveRowFocus, + saveAsFile, } from '@nanoforge-dev/editor-sdk/ui'; import SourcePicker from '../SourcePicker.svelte'; import { follow } from '../follow.svelte'; import { settledStatus } from '../settled-status.svelte'; + import { exportName, packetsCsv } from '../../export/inspector-export'; import { formatBytes, hexDump, hexToBytes, readPacket } from '../../network/packets'; import type { Packet } from '../../recorder/recorder'; import { getRecorder } from '../../session/recorder-session'; @@ -21,7 +26,9 @@ const { instance }: { instance: WidgetInstance } = $props(); // svelte-ignore state_referenced_locally - const view = follow(getRecorder(), 'network', instance.visible); + const recorder = getRecorder(); + // svelte-ignore state_referenced_locally + const view = follow(recorder, 'network', instance.visible); let direction = $state>('all'); let transport = $state>('all'); @@ -31,7 +38,9 @@ let stick = $state(true); const record = $derived(view.record); - const packets = $derived(record?.packets ?? []); + /** While paused, the list keeps the packets it had (the recorder goes on). */ + let frozen = $state(); + const packets = $derived(frozen ?? record?.packets ?? []); const totals = $derived(record?.network ?? []); /** Payload bytes the editor has, and how people read them (computed once per packet). */ @@ -101,6 +110,41 @@ const countText = () => `${shown.length} of ${packets.length} packets${record?.untraced ? `, ${record.untraced} more not listed` : ''}`; const status = settledStatus(() => [query, transport, direction], countText); + + const clear = () => { + recorder.clear(view.source ?? 'client'); + frozen = undefined; + selectedId = undefined; + }; + /** Saves the packets shown (filtered, and frozen while paused). */ + const exportItems = (): MenuEntry[] => [ + { + kind: 'item', + id: 'csv', + label: 'Save as CSV', + onSelect: () => + saveAsFile( + exportName('network', 'csv'), + packetsCsv(shown, (packet) => detailOf(packet).text.replace(/\s+/g, ' ').trim()), + 'text/csv', + ), + }, + { + kind: 'item', + id: 'json', + label: 'Save as JSON', + onSelect: () => + saveAsFile( + exportName('network', 'json'), + `${JSON.stringify( + shown.map((packet) => ({ ...packet, text: detailOf(packet).text.trim() })), + null, + 2, + )}\n`, + 'application/json', + ), + }, + ]; {#snippet choice( @@ -131,6 +175,7 @@ onchange={(source) => { view.source = source; selectedId = undefined; + frozen = undefined; }} /> {@render choice( @@ -156,6 +201,17 @@ {countText()} {status.text} + {#if frozen} + + {:else} + + {/if} + +

+ {#snippet trigger({ props })} + + {/snippet} +
diff --git a/plugins/inspectors/src/widgets/profiler/ProfilerView.svelte b/plugins/inspectors/src/widgets/profiler/ProfilerView.svelte index 5fc5e052..bb39651b 100644 --- a/plugins/inspectors/src/widgets/profiler/ProfilerView.svelte +++ b/plugins/inspectors/src/widgets/profiler/ProfilerView.svelte @@ -1,10 +1,19 @@