From 639e7b68a32849f7c5bf5b8c52422875e7fc9f88 Mon Sep 17 00:00:00 2001 From: Exelo Date: Fri, 30 Oct 2026 10:43:14 +0100 Subject: [PATCH 1/2] refactor: simplify interfaces --- apps/editor/e2e/git.spec.ts | 44 ++++++------ apps/editor/e2e/helpers.ts | 15 +++- apps/editor/e2e/history-panel.spec.ts | 5 +- apps/editor/e2e/inspectors.spec.ts | 10 +-- apps/editor/e2e/workbench.spec.ts | 39 ++++++++++- .../src/lib/project/register-core-panels.ts | 1 + .../src/lib/workspace/workspace-actions.ts | 6 +- docs/docs/editor/git.mdx | 8 +-- docs/docs/editor/layout.mdx | 1 + docs/docs/editor/overview.mdx | 2 +- .../create-your-first-project.mdx | 9 ++- .../docs/plugins/create-your-first-plugin.mdx | 2 +- docs/docs/plugins/manifest.mdx | 1 + docs/usability-test.md | 69 +++++++++++++++++++ packages/layout/src/default/default-layout.ts | 22 +++++- .../test/default/default-layout.spec.ts | 33 ++++++++- packages/sdk/src/ui.ts | 3 + .../settings/src/definition/core-settings.ts | 10 +++ packages/ui/package.json | 1 + packages/ui/src/components/toasts.svelte | 18 +++++ .../extension-point/widget.extension-point.ts | 4 ++ .../src/workbench/layout/layout-controller.ts | 17 +++-- .../workbench/notification/describe-error.ts | 65 +++++++++++++++++ .../ui/src/workbench/notification/index.ts | 1 + .../notification/notification-service.ts | 4 ++ .../ui/src/workbench/parts/StatusBar.svelte | 32 +++++++-- packages/ui/src/workbench/parts/TopBar.svelte | 36 ++++++---- .../workbench/service/workbench-service.ts | 1 + .../ui/src/workbench/setup/setup-workbench.ts | 51 ++++++++++++++ .../notification/describe-error.spec.ts | 48 +++++++++++++ plugins/code-editor/nanoforge.manifest.json | 1 + plugins/code-editor/src/index.ts | 5 ++ .../src/service/code-editor-service.ts | 11 +-- .../src/service/code-editor.type.ts | 2 + .../src/widgets/script/EditorGroupView.svelte | 10 ++- plugins/console/nanoforge.manifest.json | 1 + plugins/ecs/nanoforge.manifest.json | 2 + .../ecs/src/library/create-shared-library.ts | 12 ++-- .../ecs/src/widgets/browser/create-flows.ts | 10 +-- plugins/file-manager/nanoforge.manifest.json | 1 + .../src/service/file-manager-service.ts | 10 ++- plugins/git/src/index.ts | 5 +- .../git/src/widgets/commit/ChangesTab.svelte | 16 ++--- .../src/widgets/commit/CommitMessage.svelte | 4 +- .../git/src/widgets/commit/StashTab.svelte | 2 +- plugins/git/src/widgets/log/BranchTree.svelte | 10 +-- .../git/src/widgets/log/CommitTable.svelte | 4 +- plugins/history-panel/nanoforge.manifest.json | 1 + plugins/scene/nanoforge.manifest.json | 1 + plugins/viewport/nanoforge.manifest.json | 2 + 50 files changed, 562 insertions(+), 106 deletions(-) create mode 100644 docs/usability-test.md create mode 100644 packages/ui/src/workbench/notification/describe-error.ts create mode 100644 packages/ui/test/workbench/notification/describe-error.spec.ts diff --git a/apps/editor/e2e/git.spec.ts b/apps/editor/e2e/git.spec.ts index b9c0b5c3..f2e29039 100644 --- a/apps/editor/e2e/git.spec.ts +++ b/apps/editor/e2e/git.spec.ts @@ -77,24 +77,24 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await showPanel(page, 'Commit'); await expect(commitPanel(page).getByText('This project is not a git repository')).toBeVisible(); await vcs(page, 'Initialize repository'); - await expect(group(page, 'Unversioned Files')).toBeVisible(); + await expect(group(page, 'Unversioned files')).toBeVisible(); git(PONG, 'config', 'user.name', 'E2E'); git(PONG, 'config', 'user.email', 'e2e@example.com'); - const nested = row(page, 'Unversioned Files', 'apps/client/src/main.ts'); + const nested = row(page, 'Unversioned files', 'apps/client/src/main.ts'); await expect(nested).toContainText('main.ts'); await expect(nested).toContainText('apps/client/src'); await message(page).fill('first commit'); await expect(commitButton).toBeDisabled(); - const includeAll = group(page, 'Unversioned Files').getByRole('checkbox', { - name: 'Include all: Unversioned Files', + const includeAll = group(page, 'Unversioned files').getByRole('checkbox', { + name: 'Include all: Unversioned files', includeHidden: true, }); await nested.getByRole('checkbox').check(); expect(await includeAll.evaluate((box: HTMLInputElement) => box.indeterminate)).toBe(true); await includeAll.check(); await commit(page, 'first commit'); - await expect(row(page, 'Unversioned Files', 'package.json')).toHaveCount(0); + await expect(row(page, 'Unversioned files', 'package.json')).toHaveCount(0); await expect(branchStatus(page, 'main')).toBeVisible(); await showPanel(page, 'Git'); @@ -181,23 +181,23 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await page.keyboard.press('Space'); await expect(changed.getByRole('checkbox')).toBeChecked(); await changed.click({ button: 'right' }); - await expect(page.getByRole('menuitem', { name: /^Show Diff/ })).toBeVisible(); - await expect(page.getByRole('menuitem', { name: /^Jump to Source/ })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /^Show diff/ })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /^Jump to source/ })).toBeVisible(); await page.getByRole('menuitem', { name: /^Rollback…/ }).click(); await expect(page.getByRole('heading', { name: `Rollback ${FILE}?` })).toBeVisible(); await page.getByRole('button', { name: 'Rollback', exact: true }).last().click(); await expect(changed).toHaveCount(0); writeFileSync(join(PONG, NEW_FILE), 'new\n'); - await row(page, 'Unversioned Files', NEW_FILE).click({ button: 'right' }); + await row(page, 'Unversioned files', NEW_FILE).click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Add to VCS' }).click(); await expect(row(page, 'Changes', NEW_FILE)).toBeVisible(); await row(page, 'Changes', NEW_FILE).click(); await rollBack.click(); await page.getByRole('button', { name: 'Rollback', exact: true }).last().click(); - await row(page, 'Unversioned Files', NEW_FILE).click(); + await row(page, 'Unversioned files', NEW_FILE).click(); await page.keyboard.press('Delete'); await page.getByRole('button', { name: 'Delete', exact: true }).last().click(); - await expect(row(page, 'Unversioned Files', NEW_FILE)).toHaveCount(0); + await expect(row(page, 'Unversioned files', NEW_FILE)).toHaveCount(0); await vcs(page, 'New branch…'); await page.getByRole('textbox', { name: 'Branch name' }).fill('feature'); @@ -207,7 +207,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await local.getByRole('button', { name: 'main', exact: true }).dblclick(); await expect(branchStatus(page, 'main')).toBeVisible(); await local.getByRole('button', { name: 'feature', exact: true }).click({ button: 'right' }); - await expect(page.getByRole('menuitem', { name: "New Branch from 'feature'…" })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: "New branch from 'feature'…" })).toBeVisible(); await page.getByRole('menuitem', { name: 'Delete', exact: true }).click(); await page.getByRole('button', { name: 'Delete', exact: true }).last().click(); await expect(local.getByRole('button', { name: 'feature', exact: true })).toHaveCount(0); @@ -229,7 +229,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash writeFileSync(join(OTHER, FILE), 'theirs\n'); git(OTHER, 'commit', '--quiet', '-am', 'their change'); git(OTHER, 'push', '--quiet'); - await logPanel(page).getByRole('button', { name: 'Fetch All Remotes' }).click(); + await logPanel(page).getByRole('button', { name: 'Fetch all remotes' }).click(); await expect(branchStatus(page, 'main ↓1')).toBeVisible(); const remote = logPanel(page).getByRole('list', { name: 'Branches of origin' }); await remote.getByRole('button', { name: 'main', exact: true }).click(); @@ -248,12 +248,12 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await expect(branchStatus(page, 'main ↑1 ↓1')).toBeVisible(); await vcs(page, 'Update project'); await expect(page.getByText('The update stopped on conflicts').last()).toBeVisible(); - const conflict = row(page, 'Merge Conflicts', FILE); + const conflict = row(page, 'Merge conflicts', FILE); await expect(conflict).toBeVisible(); writeFileSync(join(PONG, FILE), 'both\n'); await conflict.getByRole('button', { name: 'Mark resolved' }).click(); - await expect(group(page, 'Merge Conflicts')).toHaveCount(0); - await commit(page, 'merge', 'Commit and Push…'); + await expect(group(page, 'Merge conflicts')).toHaveCount(0); + await commit(page, 'merge', 'Commit and push…'); const afterMerge = await push(); await expect(afterMerge.getByRole('list', { name: 'Commits to push' })).toContainText('merge'); await afterMerge.getByRole('button', { name: 'Push', exact: true }).click(); @@ -267,7 +267,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await commit(page, 'merge, amended'); await expect(commits(page).getByRole('row').first()).toContainText('merge, amended'); await expect(changed).toHaveCount(0); - await commitPanel(page).getByRole('button', { name: 'Commit Message History' }).click(); + await commitPanel(page).getByRole('button', { name: 'Commit message history' }).click(); await page.getByRole('menuitem', { name: 'my change' }).click(); await expect(message(page)).toHaveValue('my change'); await message(page).fill(''); @@ -299,9 +299,9 @@ test('browses, folds and includes changes from the keyboard', async ({ page }) = await showPanel(page, 'Commit'); await vcs(page, 'Initialize repository'); const files = commitPanel(page).getByRole('group', { name: 'Changed files' }); - const node = commitPanel(page).getByRole('button', { name: /^Unversioned Files, / }); + const node = commitPanel(page).getByRole('button', { name: /^Unversioned files, / }); const includeAll = commitPanel(page).getByRole('checkbox', { - name: 'Include all: Unversioned Files', + name: 'Include all: Unversioned files', includeHidden: true, }); const active = () => @@ -314,7 +314,7 @@ test('browses, folds and includes changes from the keyboard', async ({ page }) = await page.keyboard.press('ArrowDown'); await expect.poll(active).toMatch(/^Changes, 0 files/); await page.keyboard.press('ArrowDown'); - await expect.poll(active).toMatch(/^Unversioned Files, /); + await expect.poll(active).toMatch(/^Unversioned files, /); await page.keyboard.press('Space'); await expect(includeAll).toBeChecked(); await page.keyboard.press('Space'); @@ -324,12 +324,12 @@ test('browses, folds and includes changes from the keyboard', async ({ page }) = await page.keyboard.press('ArrowRight'); await expect(node).toHaveAttribute('aria-expanded', 'true'); await page.keyboard.press('ArrowRight'); - await expect.poll(active).not.toMatch(/^Unversioned Files/); + await expect.poll(active).not.toMatch(/^Unversioned files/); await expect( - group(page, 'Unversioned Files').getByRole('option', { selected: true }), + group(page, 'Unversioned files').getByRole('option', { selected: true }), ).toHaveCount(1); await page.keyboard.press('ArrowLeft'); - await expect.poll(active).toMatch(/^Unversioned Files, /); + await expect.poll(active).toMatch(/^Unversioned files, /); await resetLayout(page); /** The layout is saved after 500 ms. */ await page.waitForTimeout(900); diff --git a/apps/editor/e2e/helpers.ts b/apps/editor/e2e/helpers.ts index eb65c961..09571b6f 100644 --- a/apps/editor/e2e/helpers.ts +++ b/apps/editor/e2e/helpers.ts @@ -1,11 +1,14 @@ import { type Page, expect } from '@playwright/test'; -/** Puts the saved layout of the project back to the default (tests reuse projects). */ +/** + * Puts the saved layout of the project back to the default with every panel (tests reuse + * projects, and expect the panels of the full layout). + */ export const resetLayout = async (page: Page): Promise => { await page.getByRole('button', { name: 'View', exact: true }).click(); await page.getByRole('menuitem', { name: 'Layouts' }).click(); - await page.getByRole('menuitem', { name: 'Reset layout' }).click(); - await expect(page.getByText('Layout reset to default').last()).toBeVisible(); + await page.getByRole('menuitem', { name: 'All panels' }).click(); + await expect(page.getByText('Layout reset with all panels').last()).toBeVisible(); }; /** Expands folders of the Files tree, from the root (already expanded ones stay open). */ @@ -32,6 +35,12 @@ export const panelTab = (page: Page, name: string | RegExp) => */ export const showPanel = async (page: Page, name: string | RegExp): Promise => { const tab = panelTab(page, name); + /** A panel the layout does not show (History, by default) opens from View › Panels. */ + if (typeof name === 'string' && !(await tab.count())) { + await page.getByRole('button', { name: 'View', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Panels' }).click(); + await page.getByRole('menuitem', { name, exact: true }).click(); + } await expect(tab).toBeVisible(); if ((await tab.getAttribute('aria-selected')) !== 'true') await tab.click(); await expect(tab).toHaveAttribute('aria-selected', 'true'); diff --git a/apps/editor/e2e/history-panel.spec.ts b/apps/editor/e2e/history-panel.spec.ts index aee2dbb4..b1bfa580 100644 --- a/apps/editor/e2e/history-panel.spec.ts +++ b/apps/editor/e2e/history-panel.spec.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -14,6 +14,9 @@ const row = (page: Page, name: string) => files(page).getByRole('treeitem', { na const open = async (page: Page) => { await openProject(page, 'pong'); await page.waitForURL(/\/project\//); + await resetLayout(page); + /** History is not shown by default. */ + await showPanel(page, 'History'); await expect(row(page, 'apps')).toBeVisible(); }; diff --git a/apps/editor/e2e/inspectors.spec.ts b/apps/editor/e2e/inspectors.spec.ts index 2fa8f8c6..dd4812a9 100644 --- a/apps/editor/e2e/inspectors.spec.ts +++ b/apps/editor/e2e/inspectors.spec.ts @@ -101,14 +101,16 @@ test.describe('with the engine', () => { /** The count shown; screen readers get it only once a search has settled. */ const count = page.getByText(/of \d+ packets/).first(); + /** + * While hidden (still mounted), the panel records nothing: read its count twice while it is + * hidden, once the packets already on their way have landed. + */ await showPanel(page, 'Console'); - await page.waitForTimeout(1500); - await showPanel(page, 'Network'); + await page.waitForTimeout(500); const before = await count.textContent(); - await showPanel(page, 'Console'); await page.waitForTimeout(2500); - await showPanel(page, 'Network'); expect(await count.textContent()).toBe(before); + await showPanel(page, 'Network'); await expect(page.getByRole('img', { name: 'Bytes per second' })).toBeVisible({ timeout: 10_000, }); diff --git a/apps/editor/e2e/workbench.spec.ts b/apps/editor/e2e/workbench.spec.ts index 321f71d0..cb53ca16 100644 --- a/apps/editor/e2e/workbench.spec.ts +++ b/apps/editor/e2e/workbench.spec.ts @@ -1,4 +1,4 @@ -import { expandFolders, openProject, panelTab, resetLayout } from './helpers'; +import { expandFolders, openProject, panelTab, resetLayout, showPanel } from './helpers'; import { type Locator, type Page, expect, test } from './test'; const openPong = async (page: Page) => { @@ -55,6 +55,7 @@ test('moves, resizes and floats docks, undoes, and restores the layout after rel }) => { await openPong(page); + await showPanel(page, 'History'); await drag(page, panelTab(page, 'History'), panelTab(page, 'Console')); await expect(stackOf(page, 'Console')).toHaveAttribute('data-slot', 'bottom'); await expect(page.locator('[data-slot="bottom"][data-nf-drop="stack"]').first()).toHaveAttribute( @@ -196,3 +197,39 @@ test('the project name in the status bar shows the Project screen, it no longer ); await expect(page).toHaveURL(/\/project\//); }); + +test('a simple layout shows the main panels only, all panels come back from the View menu', async ({ + page, +}) => { + await openPong(page); + const layouts = async (entry: string) => { + await page.getByRole('button', { name: 'View', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Layouts' }).click(); + await page.getByRole('menuitem', { name: entry }).click(); + }; + const stripes = page.getByRole('navigation', { name: /tool windows$/ }); + try { + await layouts('Simple layout'); + await expect(page.getByText('Layout reset with the main panels').last()).toBeVisible(); + for (const name of ['Files', 'Hierarchy', 'Inspector', 'Console']) + await expect(stripes.getByRole('tab', { name, exact: true })).toBeVisible(); + for (const name of ['Commit', 'Profiler', 'History', 'Components']) + await expect(stripes.getByRole('tab', { name, exact: true })).toHaveCount(0); + await layouts('All panels'); + await expect(stripes.getByRole('tab', { name: 'Commit', exact: true })).toBeVisible(); + } finally { + await resetLayout(page); + await page.waitForTimeout(900); + } +}); + +test('says what each screen is for', async ({ page }) => { + await openPong(page); + const screens = page.getByRole('tablist', { name: 'Main screens' }); + await expect( + screens.getByRole('tab', { name: 'Scene', exact: true }), + ).toHaveAccessibleDescription('Your entities drawn in 2D: select and move them.'); + await expect( + screens.getByRole('tab', { name: 'Project', exact: true }), + ).toHaveAccessibleDescription(/apps and shared libraries/); +}); diff --git a/apps/editor/src/lib/project/register-core-panels.ts b/apps/editor/src/lib/project/register-core-panels.ts index fc8e64a6..3fb0cf5c 100644 --- a/apps/editor/src/lib/project/register-core-panels.ts +++ b/apps/editor/src/lib/project/register-core-panels.ts @@ -18,6 +18,7 @@ const PANELS = [ { id: 'core.project', title: 'Project', + description: 'Your apps and shared libraries, and what each one uses.', icon: 'layers', kind: 'screen', order: 100, diff --git a/apps/editor/src/lib/workspace/workspace-actions.ts b/apps/editor/src/lib/workspace/workspace-actions.ts index acdc2ad9..620d42ea 100644 --- a/apps/editor/src/lib/workspace/workspace-actions.ts +++ b/apps/editor/src/lib/workspace/workspace-actions.ts @@ -38,6 +38,8 @@ import { import { type AppModel, ProjectsContract } from '@nanoforge-dev/editor-protocol'; import type { RpcClient } from '@nanoforge-dev/editor-rpc'; import type { NotificationService, PromptService } from '@nanoforge-dev/editor-ui'; +/** The notification helpers alone: the kit's main entry brings its Svelte components. */ +import { notifyError } from '@nanoforge-dev/editor-ui/notification'; /** Undo and redo target of the Project screen: changes of the workspace. */ export const PROJECT_HISTORY_CONTEXT = 'project'; @@ -269,8 +271,8 @@ export class WorkspaceActions implements Disposable { detail: 'Its output is in the Console, under Tasks.', }); } catch (error) { - notifications.notify('error', 'Could not install the dependencies', { - detail: error instanceof Error ? error.message : String(error), + notifyError(notifications, 'Could not install the dependencies', error, { + retry: () => this.install(), }); } } diff --git a/docs/docs/editor/git.mdx b/docs/docs/editor/git.mdx index d57c837e..efc1e940 100644 --- a/docs/docs/editor/git.mdx +++ b/docs/docs/editor/git.mdx @@ -17,7 +17,7 @@ The **Commit** panel on the left lists what changed since the last commit, as fi 1. **Check** the files to commit. A group's checkbox checks all its files. 2. Write a message. -3. **Commit**, or **Commit and Push…** +3. **Commit**, or **Commit and push…** **Amend** replaces the last commit instead of adding one. @@ -27,7 +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 | -| Put changes aside | The **Stash** tab: Stash Changes, then Apply, Pop or Drop | +| 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. @@ -45,7 +45,7 @@ The status bar shows the branch and what is ahead and behind (`main ↑1 ↓2`). 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_. +- **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. @@ -57,7 +57,7 @@ Changed files are colored and carry a mark in the **Files** panel, and folders t ## Conflicts -After an update that conflicts, the files show under **Merge Conflicts**. Open one, fix it in the code editor, then **Mark resolved**. +After an update that conflicts, the files show under **Merge conflicts**. Open one, fix it in the code editor, then **Mark resolved**. Git works on the project's own repository only, and in local editors. Hosted projects are saved to diff --git a/docs/docs/editor/layout.mdx b/docs/docs/editor/layout.mdx index a4d8b77e..14026369 100644 --- a/docs/docs/editor/layout.mdx +++ b/docs/docs/editor/layout.mdx @@ -40,6 +40,7 @@ _View › Layouts_: - **Save layout** keeps the current arrangement under a name; - the saved layouts are listed there: choose one to switch to it; +- **Simple layout** shows the main panels only (Files, Hierarchy, Scenes, Inspector, Console), **All panels** every one; each puts the layout back to that default, and becomes the default of _Reset layout_ (the setting _Default layout_). A new editor starts with the simple one. - **Reset layout** goes back to the default. Moving, closing, floating and resizing a panel are steps you can undo: `Ctrl+Z` with the focus on the layout, or in the **History** panel under _Layout_. diff --git a/docs/docs/editor/overview.mdx b/docs/docs/editor/overview.mdx index 437f6ec7..e2228374 100644 --- a/docs/docs/editor/overview.mdx +++ b/docs/docs/editor/overview.mdx @@ -75,7 +75,7 @@ Letters only need to be in order, not next to each other. The commands you ran l `Ctrl+Z` undoes the last change **of what has focus**: the file you edit, the Files panel, the layout. Each has its own history, so undoing a file move does not undo your code. -The **History** panel lists the steps of what you are working on. Click a step to go back to it; the _All_ switch shows every history. +The **History** panel (_View › Panels › History_) lists the steps of what you are working on. Click a step to go back to it; the _All_ switch shows every history. ## Problems and the status bar diff --git a/docs/docs/getting-started/create-your-first-project.mdx b/docs/docs/getting-started/create-your-first-project.mdx index fc90bc73..19147513 100644 --- a/docs/docs/getting-started/create-your-first-project.mdx +++ b/docs/docs/getting-started/create-your-first-project.mdx @@ -34,9 +34,12 @@ A local editor can do what a hosted one cannot: run the game, use Git with your ![The editor](../images/editor-overview.png) - **Top**: the menu bar, the four main **screens** (Project, Scene, Script, Game), and the **Play** button. -- **Left**: **Files**, the **Hierarchy** of entities and the **Commit** panel; below, **Components** and **Systems**. -- **Right**: **History** and the **Inspector**. -- **Bottom**: the **Console**, **Problems**, **Git**, and the **Profiler**, **Network** and **World** panels. +- **Left**: **Files**, the **Hierarchy** of entities and, for games made of scenes, **Scenes**. +- **Right**: the **Inspector**. +- **Bottom**: the **Console**. + +The editor starts with these main panels only. _View › Layouts › All panels_ adds the others: **Commit**, **Components** and **Systems** on the left, **Problems**, **Git**, and the **Profiler**, **Network** and **World** panels at the bottom. _View › Panels_ opens any one of them, and the **History** of changes. + - **Status bar**: the project, the count of problems, the Git branch, and the game's speed while it plays. Every panel can be moved, floated or closed: see [Layout](../editor/layout). diff --git a/docs/docs/plugins/create-your-first-plugin.mdx b/docs/docs/plugins/create-your-first-plugin.mdx index 679dd91d..aa136485 100644 --- a/docs/docs/plugins/create-your-first-plugin.mdx +++ b/docs/docs/plugins/create-your-first-plugin.mdx @@ -41,7 +41,7 @@ In another terminal, start the editor with the plugin as a **dev plugin**: DEV_PLUGINS=/path/to/counter nf editor /path/to/a/project ``` -The Counter panel is in the bottom right dock. _View › Panels › Counter_ shows it again once closed. When you save a source file, the editor reloads the plugin without reloading the page. +The Counter panel is in the bottom right dock. In the simple layout, which shows only the main panels, open it with _View › Panels › Counter_ (or _View › Layouts › All panels_); the same entry shows it again once closed. A plugin can put its panel in the simple layout with `"essential": true`, for the few panels most work needs. When you save a source file, the editor reloads the plugin without reloading the page. `@nanoforge-dev/editor-sdk` and `@nanoforge-dev/editor-vite-plugin` are not published on npm yet, diff --git a/docs/docs/plugins/manifest.mdx b/docs/docs/plugins/manifest.mdx index 1b93e8e9..c85f9317 100644 --- a/docs/docs/plugins/manifest.mdx +++ b/docs/docs/plugins/manifest.mdx @@ -91,6 +91,7 @@ Panels (docks) and screens. | `defaultSlot` | | `leftTop`, `leftBottom`, `rightTop`, `rightBottom`, `bottom` or `bottomRight`. | | `order` | `0` | Order among the tabs of the slot, or among screens. | | `openByDefault` | `true` | Whether the default layout shows it. | +| `essential` | `false` | Whether the simple layout shows it too (the few panels most work needs). | | `singleton` | `true` | One instance at a time. | | `when` | | Offered in menus only while true. | | `historyContext` | | The history that undo and redo act on while the widget has focus. | diff --git a/docs/usability-test.md b/docs/usability-test.md new file mode 100644 index 00000000..cd6a50be --- /dev/null +++ b/docs/usability-test.md @@ -0,0 +1,69 @@ +# Usability test + +Five people, five tasks, timed, before and after the simpler interfaces of M11. The numbers say whether M11 made the editor easier; the notes say where people still get stuck. + +## Builds + +Run the same five tasks on two builds, with different people for each (someone who did the tasks once is faster the second time, whatever the build). + +| Round | What it is | +| ------ | --------------------------------------------------------------------------- | +| Before | The editor before: every panel open, git labels in title case | +| After | The simple layout, screens described, plain-language errors, History closed | + +For each round: + +```sh +git switch +pnpm install && pnpm turbo build --filter=@nanoforge-dev/editor... +pnpm example breakout # http://127.0.0.1:4800, engine from ../engine (NANOFORGE_ENGINE) +``` + +Before **each person** (stop the editor first): + +```sh +rm -rf examples/.data # the editor's data: recent projects, account +rm -f examples/breakout/.nanoforge/editor/local.json # breakout's saved layout (not in git) +``` + +and use a fresh browser profile (a private window), so each one starts from the editor's first run: the previous person's layout would otherwise spoil the comparison. + +## People + +Five people who make games but have not used this editor: students of the course are a good fit. Note for each one their experience with game engines (none, Unity/Godot, other) and with code editors. + +## Tasks + +Read each task aloud, then say nothing until they finish or give up (5 minutes per task at most). Ask them to think aloud. + +1. **Open the project.** "Open the breakout game: its folder is `breakout`." (The list of recent projects is empty on a first run.) +2. **Add an entity.** "In the Level 1 scene, add a new brick entity." (It must end up in the scene's code.) +3. **Change a value.** "Make the ball start faster." (Any velocity change on the ball counts.) +4. **Play.** "Play the game, then stop it." +5. **Commit.** "Save your work in Git with the message `faster ball`." (The project must be a repository: run `git init && git add -A && git commit -m start` in `examples/breakout` before the session.) + +## What to record + +For each person and task: + +| Measure | How | +| ------- | --------------------------------------------------------------------------- | +| Time | From the end of the instruction to the result on screen (or the give-up) | +| Done | Yes, with help (you had to say something), or no | +| Errors | Wrong turns: a wrong panel or menu opened, an action undone, an error shown | +| Notes | What they looked for and where, what they said, what they misread | + +A sheet per round: + +| Person | Experience | Task | Time (s) | Done | Errors | Notes | +| ------ | ---------- | ---- | -------- | ---- | ------ | ----- | +| | | 1 | | | | | + +## Reading the results + +- Compare the median time and the success rate of each task between the rounds; with five people per round, only large differences mean something. +- The notes matter more than the times: a place where three people looked for the same thing is worth fixing whatever the numbers say. + +## After the session + +Remove `examples/breakout/.git` and `examples/.data`, and put back anything the people changed in `examples/breakout` (`git checkout examples/breakout`). diff --git a/packages/layout/src/default/default-layout.ts b/packages/layout/src/default/default-layout.ts index 3b408fd7..a2ff331c 100644 --- a/packages/layout/src/default/default-layout.ts +++ b/packages/layout/src/default/default-layout.ts @@ -13,15 +13,31 @@ export interface WidgetPlacement { readonly order?: number; /** Docks: shown in the default layout (default true). */ readonly openByDefault?: boolean; + /** Docks: shown in the simple layout too (the few panels most work needs). */ + readonly essential?: boolean; } -/** The layout used when nothing is saved: screens in order, docks in their default slots. */ -export const composeDefaultLayout = (widgets: readonly WidgetPlacement[]): Layout => { +/** `simple`: only the essential docks; `full`: every dock open by default. */ +export type LayoutPreset = 'simple' | 'full'; + +/** + * The layout used when nothing is saved: screens in order, docks in their default slots. With + * the `simple` preset, only the docks marked `essential`; the others stay in View › Panels. + */ +export const composeDefaultLayout = ( + widgets: readonly WidgetPlacement[], + preset: LayoutPreset = 'full', +): Layout => { const byOrder = (a: WidgetPlacement, b: WidgetPlacement) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id); const screens = widgets.filter((widget) => widget.kind === 'screen').sort(byOrder); const docks = widgets - .filter((widget) => widget.kind === 'dock' && widget.openByDefault !== false) + .filter( + (widget) => + widget.kind === 'dock' && + widget.openByDefault !== false && + (preset === 'full' || widget.essential === true), + ) .sort(byOrder); const ops: LayoutOp[] = [{ type: 'setScreens', screens: screens.map((screen) => screen.id) }]; const counts: Partial> = {}; diff --git a/packages/layout/test/default/default-layout.spec.ts b/packages/layout/test/default/default-layout.spec.ts index 5d3be202..5145606f 100644 --- a/packages/layout/test/default/default-layout.spec.ts +++ b/packages/layout/test/default/default-layout.spec.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { allWidgets, deserializeLayout, reconcileLayout, serializeLayout } from '../../src'; +import { + allWidgets, + composeDefaultLayout, + deserializeLayout, + reconcileLayout, + serializeLayout, +} from '../../src'; import { apply, base, widgets } from '../fixtures/layout'; describe('default layout', () => { @@ -18,6 +24,31 @@ describe('default layout', () => { expect(allWidgets(layout).map((ref) => ref.instanceId)).not.toContain('profiler'); }); + it('opens only the essential docks with the simple preset, and keeps the others out', () => { + const marked = widgets.map((widget) => + widget.id === 'files' || widget.id === 'inspector' ? { ...widget, essential: true } : widget, + ); + const simple = composeDefaultLayout(marked, 'simple'); + expect( + allWidgets(simple) + .map((ref) => ref.widgetId) + .sort(), + ).toEqual(['files', 'inspector']); + expect(simple.screens).toEqual(['scene', 'script', 'game']); + expect(simple.known).toContain('console'); + + const reloaded = deserializeLayout(serializeLayout(simple))!; + const known = new Set(reloaded.known ?? []); + const withPlugin = reconcileLayout( + reloaded, + [...marked, { id: 'network', kind: 'dock', defaultSlot: 'bottom' }], + known, + ); + const shown = allWidgets(withPlugin).map((ref) => ref.widgetId); + expect(shown).not.toContain('console'); + expect(shown).toContain('network'); + }); + it('keeps a closed dock closed after a reload', () => { const closed = apply(base(), { type: 'remove', instanceId: 'console' }); const reloaded = deserializeLayout(serializeLayout(closed))!; diff --git a/packages/sdk/src/ui.ts b/packages/sdk/src/ui.ts index bd4596e5..558d2105 100644 --- a/packages/sdk/src/ui.ts +++ b/packages/sdk/src/ui.ts @@ -118,5 +118,8 @@ export { Vector2Input, isKeyboardMenu, moveRowFocus, + describeError, + notifyError, + type ErrorExplanation, moveTabFocus, } from '@nanoforge-dev/editor-ui'; diff --git a/packages/settings/src/definition/core-settings.ts b/packages/settings/src/definition/core-settings.ts index 664cd065..8895fd8a 100644 --- a/packages/settings/src/definition/core-settings.ts +++ b/packages/settings/src/definition/core-settings.ts @@ -58,6 +58,16 @@ export const CoreSettings = { description: 'Consecutive changes of the same value within this delay (ms) are one undo step.', category: 'Editor/History', }), + layoutPreset: defineSetting({ + key: 'layout.preset', + schema: z.enum(['simple', 'full']), + default: 'simple', + title: 'Default layout', + description: + 'Simple shows only the main panels (files, hierarchy, scenes, inspector, console); All panels shows every one. Used when a layout is reset or none is saved yet.', + category: 'Editor/Layout', + scopes: ['account', 'machine'], + }), layoutActive: defineSetting({ key: 'layout.active', schema: z.string(), diff --git a/packages/ui/package.json b/packages/ui/package.json index a514fce7..18622d9d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -24,6 +24,7 @@ "default": "./src/index.ts" }, "./fonts.css": "./src/theme/fonts.css", + "./notification": "./src/workbench/notification/index.ts", "./package.json": "./package.json" }, "type": "module", diff --git a/packages/ui/src/components/toasts.svelte b/packages/ui/src/components/toasts.svelte index 8b7f9353..ae681a1a 100644 --- a/packages/ui/src/components/toasts.svelte +++ b/packages/ui/src/components/toasts.svelte @@ -55,6 +55,12 @@

{notification.message}

{#if notification.detail}

{notification.detail}

{/if} + {#if notification.technical} +
+ Show details +
{notification.technical}
+
+ {/if} {#if notification.actions.length}
{#each notification.actions as action (action.title)} @@ -120,6 +126,18 @@ .message { margin: 2px 0 0; } + .technical { + margin: var(--nf-space-1) 0 0; + color: var(--nf-color-text-muted); + font-size: var(--nf-font-size-sm); + } + .technical pre { + max-height: 8em; + margin: var(--nf-space-1) 0 0; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + } .detail { margin: 2px 0 0; color: var(--nf-color-text-muted); diff --git a/packages/ui/src/workbench/extension-point/widget.extension-point.ts b/packages/ui/src/workbench/extension-point/widget.extension-point.ts index 91b52702..748dbdac 100644 --- a/packages/ui/src/workbench/extension-point/widget.extension-point.ts +++ b/packages/ui/src/workbench/extension-point/widget.extension-point.ts @@ -17,11 +17,15 @@ export const WidgetDescriptorSchema = z.object({ title: z.string().min(1), /** Lucide icon name, e.g. `folder-tree`. */ icon: z.string().optional(), + /** One line on what it is for (screens: shown when the pointer or the focus is on its tab). */ + description: z.string().optional(), /** `screen`: main screen in the center; `dock`: panel in a dock slot or floating window. */ kind: z.enum(['dock', 'screen']).default('dock'), defaultSlot: SLOT.optional(), order: z.number().default(0), openByDefault: z.boolean().default(true), + /** Docks: part of the simple layout, the few panels most work needs. */ + essential: z.boolean().default(false), /** Only one instance at a time (default). */ singleton: z.boolean().default(true), /** Offered in menus only when true. */ diff --git a/packages/ui/src/workbench/layout/layout-controller.ts b/packages/ui/src/workbench/layout/layout-controller.ts index e6d0a3f4..f174fd57 100644 --- a/packages/ui/src/workbench/layout/layout-controller.ts +++ b/packages/ui/src/workbench/layout/layout-controller.ts @@ -10,6 +10,7 @@ import { type Layout, LayoutError, type LayoutOp, + type LayoutPreset, type Location, type SlotId, allWidgets, @@ -52,6 +53,8 @@ export class LayoutController implements Disposable { private readonly _store = new DisposableStore(); private readonly _history: HistoryContext; private _saveTimer: ReturnType | undefined; + /** No layout was saved yet: the preset's default was used (offer the other one). */ + firstRun = false; private _floatCounter = 0; private readonly _docksHidden = new ObservableValue(false); @@ -232,9 +235,9 @@ export class LayoutController implements Disposable { await this._history.stack.clear(); } - /** Back to the default layout of the installed widgets. */ - async reset(): Promise { - this._set(composeDefaultLayout(this._widgets())); + /** Back to the default layout of the installed widgets (`layout.preset`, or the one given). */ + async reset(preset?: LayoutPreset): Promise { + this._set(composeDefaultLayout(this._widgets(), preset ?? this._preset())); await this._history.stack.clear(); } @@ -281,7 +284,9 @@ export class LayoutController implements Disposable { private _load(): Layout { const saved = this._saved()[this._activeName()]; const layout = saved === undefined ? undefined : deserializeLayout(saved); - return layout ? this._reconcile(layout) : composeDefaultLayout(this._widgets()); + if (layout) return this._reconcile(layout); + this.firstRun = true; + return composeDefaultLayout(this._widgets(), this._preset()); } private _reconcile(layout: Layout): Layout { @@ -294,6 +299,10 @@ export class LayoutController implements Disposable { return this._options.settings.hasStore('projectLocal') ? 'projectLocal' : 'account'; } + private _preset(): LayoutPreset { + return this._options.settings.get(CoreSettings.layoutPreset) === 'full' ? 'full' : 'simple'; + } + private _saved(): Record { return this._options.settings.get(CoreSettings.layoutSaved) as Record; } diff --git a/packages/ui/src/workbench/notification/describe-error.ts b/packages/ui/src/workbench/notification/describe-error.ts new file mode 100644 index 00000000..99e723db --- /dev/null +++ b/packages/ui/src/workbench/notification/describe-error.ts @@ -0,0 +1,65 @@ +import type { NotificationAction, NotificationService } from './notification-service'; + +/** An error said in plain words, with what can be done about it. */ +export interface ErrorExplanation { + /** What happened, for anyone (`undefined`: the raw message is already plain). */ + readonly hint: string | undefined; + /** The raw message, kept for Show details. */ + readonly technical: string; + /** Whether trying the same thing again may work (the server was away, a request timed out). */ + readonly retryable: boolean; +} + +/** + * Codes whose raw message is technical. The others (CONFLICT, FORBIDDEN, NOT_FOUND…) carry the + * server's own words, already plain and more precise than a hint per code would be ("Already + * exists: a.ts", "Permission denied: b.ts"): they are shown as they are. + */ +const HINTS: Record = { + UNAVAILABLE: { + hint: "The editor server can't be reached. Your changes are kept: try again once it is back.", + retryable: true, + }, + TIMEOUT: { hint: 'The editor server took too long to answer.', retryable: true }, + UNAUTHORIZED: { hint: 'Your session expired: sign in again.', retryable: false }, + INTERNAL: { + hint: 'Something went wrong in the editor server. Show details tells what.', + retryable: false, + }, +}; + +/** The code of an editor error (an RPC error, or anything with a `code`). */ +export const errorCodeOf = (error: unknown): string | undefined => { + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : undefined; +}; + +export const describeError = (error: unknown): ErrorExplanation => { + const technical = error instanceof Error ? error.message : String(error); + const known = HINTS[errorCodeOf(error) ?? '']; + return { hint: known?.hint, technical, retryable: known?.retryable ?? false }; +}; + +/** + * Shows an error as a sentence and what to do: `title` says what failed, the explanation why; + * the raw message stays behind Show details. `retry` is offered only when the error says trying + * again may work, and only if the operation is safe to repeat (the caller decides). + */ +export const notifyError = ( + notifications: NotificationService | undefined, + title: string, + error: unknown, + options: { + readonly retry?: () => void | Promise; + readonly actions?: readonly NotificationAction[]; + } = {}, +): void => { + const { hint, technical, retryable } = describeError(error); + notifications?.notify('error', title, { + ...(hint ? { detail: hint, technical } : { detail: technical }), + actions: [ + ...(retryable && options.retry ? [{ title: 'Try again', run: options.retry }] : []), + ...(options.actions ?? []), + ], + }); +}; diff --git a/packages/ui/src/workbench/notification/index.ts b/packages/ui/src/workbench/notification/index.ts index 5ce85c27..c6c63301 100644 --- a/packages/ui/src/workbench/notification/index.ts +++ b/packages/ui/src/workbench/notification/index.ts @@ -1 +1,2 @@ export * from './notification-service'; +export * from './describe-error'; diff --git a/packages/ui/src/workbench/notification/notification-service.ts b/packages/ui/src/workbench/notification/notification-service.ts index 6efc6d88..08318689 100644 --- a/packages/ui/src/workbench/notification/notification-service.ts +++ b/packages/ui/src/workbench/notification/notification-service.ts @@ -12,6 +12,8 @@ export interface Notification { readonly severity: Severity; readonly message: string; readonly detail?: string; + /** The raw error, behind Show details: the message and `detail` say it in plain words. */ + readonly technical?: string; readonly actions: readonly NotificationAction[]; readonly source?: string; readonly time: number; @@ -19,6 +21,7 @@ export interface Notification { export interface NotifyOptions { readonly detail?: string; + readonly technical?: string; readonly actions?: readonly NotificationAction[]; readonly source?: string; /** Auto dismiss delay in ms; errors stay until dismissed. Default 5000 (0 = sticky). */ @@ -60,6 +63,7 @@ export class NotificationService { actions: options.actions ?? [], time: Date.now(), ...(options.detail && { detail: options.detail }), + ...(options.technical && { technical: options.technical }), ...(options.source && { source: options.source }), }; this._active.update((list) => [...list, notification]); diff --git a/packages/ui/src/workbench/parts/StatusBar.svelte b/packages/ui/src/workbench/parts/StatusBar.svelte index a1a070cd..0e1f6dc8 100644 --- a/packages/ui/src/workbench/parts/StatusBar.svelte +++ b/packages/ui/src/workbench/parts/StatusBar.svelte @@ -78,10 +78,19 @@ > {#each $history as notification (notification.id)} -

- - {notification.message} -

+
+

+ + {notification.message} +

+ {#if notification.detail}

{notification.detail}

{/if} + {#if notification.technical} +
+ Show details +
{notification.technical}
+
+ {/if} +
{:else}

No notifications

{/each} @@ -140,9 +149,22 @@ padding: 6px 4px; border-top: 1px solid var(--nf-color-border); } - :global(.notification-center .error) { + :global(.notification-center .entry p) { + margin: 0; + } + :global(.notification-center .error > p:first-child) { color: var(--nf-color-danger); } + :global(.notification-center .entry-detail) { + margin: 2px 0 0; + color: var(--nf-color-text-muted); + font-size: var(--nf-font-size-sm); + } + :global(.notification-center .entry-detail pre) { + margin: 2px 0 0; + white-space: pre-wrap; + word-break: break-word; + } :global(.notification-center time) { margin-right: 6px; color: var(--nf-color-text-faint); diff --git a/packages/ui/src/workbench/parts/TopBar.svelte b/packages/ui/src/workbench/parts/TopBar.svelte index 374c44c8..158c39b7 100644 --- a/packages/ui/src/workbench/parts/TopBar.svelte +++ b/packages/ui/src/workbench/parts/TopBar.svelte @@ -8,6 +8,7 @@ import { TOOLBAR_ITEMS } from '../extension-point/toolbar.extension-point'; import { formatKeybinding } from '../keybinding/keystroke'; import { getWorkbenchContext } from '../workbench-context'; + import Tooltip from '../../components/tooltip.svelte'; import MenuBarMenu from './MenuBarMenu.svelte'; const { layout: current, brand }: { layout: Layout; brand?: Snippet } = $props(); @@ -39,6 +40,8 @@ }); const screenTitle = (id: string) => context.workbench.descriptor(id)?.title ?? id; + const screenDescription = (id: string) => context.workbench.descriptor(id)?.description; + const uid = $props.id(); const screenIcon = (id: string) => context.workbench.descriptor(id)?.icon; const onkeydown = (event: KeyboardEvent) => { @@ -67,18 +70,27 @@
{#each current.screens as screen (screen)} - + {@const description = screenDescription(screen)} + + {#snippet children({ props })} + + {/snippet} + + {#if description}{description}{/if} {/each}
diff --git a/packages/ui/src/workbench/service/workbench-service.ts b/packages/ui/src/workbench/service/workbench-service.ts index 042120fa..228d70a7 100644 --- a/packages/ui/src/workbench/service/workbench-service.ts +++ b/packages/ui/src/workbench/service/workbench-service.ts @@ -140,6 +140,7 @@ export class WorkbenchService implements Disposable { kind: 'dock' as const, order: 0, openByDefault: false, + essential: false, singleton: true, }; const record: InstanceRecord = { diff --git a/packages/ui/src/workbench/setup/setup-workbench.ts b/packages/ui/src/workbench/setup/setup-workbench.ts index f696e979..32d5bf3c 100644 --- a/packages/ui/src/workbench/setup/setup-workbench.ts +++ b/packages/ui/src/workbench/setup/setup-workbench.ts @@ -103,6 +103,55 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext await layout.reset(); shell.notifications.notify('info', 'Layout reset to default'); }); + /** Switches the default layout and resets to it (the layout's undo history starts over). */ + const usePreset = async (preset: 'simple' | 'full') => { + await settings.set( + CoreSettings.layoutPreset, + preset, + settings.hasStore('account') ? 'account' : 'machine', + ); + await layout.reset(preset); + shell.notifications.notify( + 'info', + preset === 'full' ? 'Layout reset with all panels' : 'Layout reset with the main panels', + ); + }; + register('layout.simple', 'Simple layout', () => usePreset('simple')); + register('layout.full', 'All panels', () => usePreset('full')); + /** + * Someone who used the editor before presets keeps every panel when resetting: saved layouts + * and no preset chosen in any scope mean an existing user. + */ + const presetScopes = settings.inspect(CoreSettings.layoutPreset).scopes; + if ( + !layout.firstRun && + Object.entries(presetScopes).every( + ([scope, value]) => scope === 'default' || value === undefined, + ) && + layout.savedNames.length + ) + void settings + .set(CoreSettings.layoutPreset, 'full', settings.hasStore('account') ? 'account' : 'machine') + .catch(() => undefined); + + /** Said once per browser: the default layout is composed again until something is saved. */ + const NOTICE_KEY = 'nanoforge.layout.simpleNoticeShown'; + const noticeShown = () => { + try { + return localStorage.getItem(NOTICE_KEY) !== null; + } catch { + return true; + } + }; + if (layout.firstRun && settings.get(CoreSettings.layoutPreset) === 'simple' && !noticeShown()) { + try { + localStorage.setItem(NOTICE_KEY, '1'); + } catch {} + shell.notifications.notify('info', 'Only the main panels are shown', { + detail: 'View › Panels opens the others, or show them all.', + actions: [{ title: 'Show all panels', run: () => usePreset('full') }], + }); + } const dynamic = store.add(new DisposableStore()); const refreshMenus = () => { @@ -135,6 +184,8 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext } add({ menu: 'view.layouts', command: 'layout.saveAs', group: 'manage', order: 0 }); add({ menu: 'view.layouts', command: 'layout.reset', group: 'manage', order: 1 }); + add({ menu: 'view.layouts', command: 'layout.simple', group: 'presets', order: 0 }); + add({ menu: 'view.layouts', command: 'layout.full', group: 'presets', order: 1 }); for (const name of layout.savedNames) { add({ menu: 'view.layouts', diff --git a/packages/ui/test/workbench/notification/describe-error.spec.ts b/packages/ui/test/workbench/notification/describe-error.spec.ts new file mode 100644 index 00000000..189dc84b --- /dev/null +++ b/packages/ui/test/workbench/notification/describe-error.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { describeError, notifyError } from '../../../src/workbench/notification/describe-error'; +import { NotificationService } from '../../../src/workbench/notification/notification-service'; + +const rpcError = (code: string, message: string) => Object.assign(new Error(message), { code }); + +describe('describeError', () => { + it('says known errors in plain words and keeps the raw message', () => { + const away = describeError(rpcError('UNAVAILABLE', 'Editor server unreachable: fetch failed')); + expect(away.hint).toMatch(/can't be reached/); + expect(away.technical).toBe('Editor server unreachable: fetch failed'); + expect(describeError(rpcError('UNAVAILABLE', 'fetch failed')).retryable).toBe(true); + expect(describeError(new Error('Something odd')).hint).toBeUndefined(); + }); + + it("keeps the server's own words when they are already plain", () => { + const exists = describeError(rpcError('CONFLICT', 'Already exists: a.ts')); + expect(exists.hint).toBeUndefined(); + expect(exists.retryable).toBe(false); + const notifications = new NotificationService(); + notifyError( + notifications, + 'The file operation failed', + rpcError('CONFLICT', 'Already exists: a.ts'), + ); + expect(notifications.history.get()[0]).toMatchObject({ detail: 'Already exists: a.ts' }); + }); +}); + +describe('notifyError', () => { + it('offers Try again only when it may work, and the raw message behind details', () => { + const notifications = new NotificationService(); + const retry = () => undefined; + notifyError(notifications, 'Could not save a.ts', rpcError('UNAVAILABLE', 'fetch failed'), { + retry, + }); + notifyError(notifications, 'Could not save a.ts', rpcError('FORBIDDEN', 'Permission denied'), { + retry, + }); + notifyError(notifications, 'Could not read b.ts', new Error('Unexpected token')); + const [away, conflict, plain] = notifications.history.get().toReversed(); + expect(away).toMatchObject({ technical: 'fetch failed', actions: [{ title: 'Try again' }] }); + expect(conflict!.actions).toEqual([]); + expect(plain).toMatchObject({ detail: 'Unexpected token' }); + expect(plain!.technical).toBeUndefined(); + }); +}); diff --git a/plugins/code-editor/nanoforge.manifest.json b/plugins/code-editor/nanoforge.manifest.json index 32e8a24c..1b8e4e8c 100644 --- a/plugins/code-editor/nanoforge.manifest.json +++ b/plugins/code-editor/nanoforge.manifest.json @@ -17,6 +17,7 @@ "widgets": [ { "id": "code-editor.script", + "description": "The code of your game: components, systems, scenes.", "title": "Script", "icon": "code", "kind": "screen", diff --git a/plugins/code-editor/src/index.ts b/plugins/code-editor/src/index.ts index 4aa29e75..12e4e717 100644 --- a/plugins/code-editor/src/index.ts +++ b/plugins/code-editor/src/index.ts @@ -79,6 +79,11 @@ export default definePlugin({ settings: services.get(SettingsServiceToken), notifications: services.tryGet(NotificationServiceToken), logger: context.logger, + runCommand: (id) => + void services + .get(EditorServices.Commands) + .execute(id) + .catch(() => undefined), }); service.applyTheme(themes.current.get()); current.set({ service, monaco }); diff --git a/plugins/code-editor/src/service/code-editor-service.ts b/plugins/code-editor/src/service/code-editor-service.ts index 744666ca..c2375819 100644 --- a/plugins/code-editor/src/service/code-editor-service.ts +++ b/plugins/code-editor/src/service/code-editor-service.ts @@ -6,7 +6,7 @@ import { type Observable, ObservableValue, } from '@nanoforge-dev/editor-sdk'; -import type { ThemeDefinition } from '@nanoforge-dev/editor-sdk/ui'; +import { type ThemeDefinition, notifyError } from '@nanoforge-dev/editor-sdk/ui'; import { EditorDocument } from '../document/editor-document'; import { createUnsavedStore } from '../document/unsaved-store'; @@ -297,6 +297,11 @@ export class CodeEditorService implements Disposable { this._options.monaco.editor.setTheme('nanoforge'); } + /** Runs an editor command, when the editor gave a way to. */ + runCommand(id: string): void { + this._options.runCommand?.(id); + } + dispose(): void { for (const timer of this._autoSaveTimers.values()) clearTimeout(timer); for (const path of [...this._documents.keys()]) this._release(path); @@ -463,8 +468,6 @@ export class CodeEditorService implements Disposable { private _report(title: string, error: unknown): void { this._options.logger.warn(title, error); - this._options.notifications?.notify('error', title, { - detail: error instanceof Error ? error.message : String(error), - }); + notifyError(this._options.notifications, title, error); } } diff --git a/plugins/code-editor/src/service/code-editor.type.ts b/plugins/code-editor/src/service/code-editor.type.ts index 1e6c4b44..283d0220 100644 --- a/plugins/code-editor/src/service/code-editor.type.ts +++ b/plugins/code-editor/src/service/code-editor.type.ts @@ -50,4 +50,6 @@ export interface CodeEditorServiceOptions { readonly settings: SettingsService; readonly notifications: NotificationService | undefined; readonly logger: Logger; + /** Runs an editor command (the empty screen offers Go to file). */ + readonly runCommand?: (id: string) => void; } diff --git a/plugins/code-editor/src/widgets/script/EditorGroupView.svelte b/plugins/code-editor/src/widgets/script/EditorGroupView.svelte index 22a4ab8f..948d459b 100644 --- a/plugins/code-editor/src/widgets/script/EditorGroupView.svelte +++ b/plugins/code-editor/src/widgets/script/EditorGroupView.svelte @@ -289,8 +289,14 @@ + description="Open a file from the Files panel, or find one by name." + > + {#snippet actions()} + + {/snippet} + {/if}
diff --git a/plugins/console/nanoforge.manifest.json b/plugins/console/nanoforge.manifest.json index 297097b2..88cb0fba 100644 --- a/plugins/console/nanoforge.manifest.json +++ b/plugins/console/nanoforge.manifest.json @@ -12,6 +12,7 @@ "widgets": [ { "id": "console.console", + "essential": true, "title": "Console", "icon": "square-terminal", "defaultSlot": "bottom", diff --git a/plugins/ecs/nanoforge.manifest.json b/plugins/ecs/nanoforge.manifest.json index 4e8018cd..d3657903 100644 --- a/plugins/ecs/nanoforge.manifest.json +++ b/plugins/ecs/nanoforge.manifest.json @@ -38,6 +38,7 @@ "widgets": [ { "id": "ecs.hierarchy", + "essential": true, "title": "Hierarchy", "icon": "list-tree", "defaultSlot": "leftTop", @@ -45,6 +46,7 @@ }, { "id": "ecs.inspector", + "essential": true, "title": "Inspector", "icon": "sliders-horizontal", "defaultSlot": "rightTop", diff --git a/plugins/ecs/src/library/create-shared-library.ts b/plugins/ecs/src/library/create-shared-library.ts index 71f9d8ed..23b68d93 100644 --- a/plugins/ecs/src/library/create-shared-library.ts +++ b/plugins/ecs/src/library/create-shared-library.ts @@ -6,7 +6,7 @@ import { runnableApps, workspaceIo, } from '@nanoforge-dev/editor-sdk'; -import { NotificationServiceToken } from '@nanoforge-dev/editor-sdk/ui'; +import { NotificationServiceToken, notifyError } from '@nanoforge-dev/editor-sdk/ui'; import { refactorsHistory } from './refactors-history'; @@ -36,11 +36,11 @@ export const createSharedLibrary = async ( await refactorsHistory(context).push(command); return true; } catch (error) { - context.services - .tryGet(NotificationServiceToken) - ?.notify('error', 'Could not create the shared library', { - detail: error instanceof Error ? error.message : String(error), - }); + notifyError( + context.services.tryGet(NotificationServiceToken), + 'Could not create the shared library', + error, + ); return false; } }; diff --git a/plugins/ecs/src/widgets/browser/create-flows.ts b/plugins/ecs/src/widgets/browser/create-flows.ts index d24bcb4a..63554912 100644 --- a/plugins/ecs/src/widgets/browser/create-flows.ts +++ b/plugins/ecs/src/widgets/browser/create-flows.ts @@ -4,7 +4,11 @@ import { validateFolder, validatePackageName, } from '@nanoforge-dev/editor-sdk'; -import type { NotificationService, PromptService } from '@nanoforge-dev/editor-sdk/ui'; +import { + type NotificationService, + type PromptService, + notifyError, +} from '@nanoforge-dev/editor-sdk/ui'; import { type ItemKind, createItem, validateItemName } from '../../create/create-item'; import type { EcsService } from '../../service/ecs-service'; @@ -36,9 +40,7 @@ export const createItemFlow = async ( const path = await createItem(project, kind, name, target); await ecs.openInCode(path); } catch (error) { - notifications?.notify('error', `Could not create ${name}`, { - detail: error instanceof Error ? error.message : String(error), - }); + notifyError(notifications, `Could not create ${name}`, error); } }; diff --git a/plugins/file-manager/nanoforge.manifest.json b/plugins/file-manager/nanoforge.manifest.json index 8aeaccd4..36d3450d 100644 --- a/plugins/file-manager/nanoforge.manifest.json +++ b/plugins/file-manager/nanoforge.manifest.json @@ -20,6 +20,7 @@ "widgets": [ { "id": "file-manager.files", + "essential": true, "title": "Files", "icon": "folder-tree", "defaultSlot": "leftTop", diff --git a/plugins/file-manager/src/service/file-manager-service.ts b/plugins/file-manager/src/service/file-manager-service.ts index 7082ca6e..59b62d42 100644 --- a/plugins/file-manager/src/service/file-manager-service.ts +++ b/plugins/file-manager/src/service/file-manager-service.ts @@ -10,7 +10,12 @@ import { editorsFor, matchesGlob, } from '@nanoforge-dev/editor-sdk'; -import { FILE_ICONS, FILE_TEMPLATES, type FileTemplate } from '@nanoforge-dev/editor-sdk/ui'; +import { + FILE_ICONS, + FILE_TEMPLATES, + type FileTemplate, + notifyError, +} from '@nanoforge-dev/editor-sdk/ui'; import { isPackagePath } from '../filter/file-filter'; import type { Clipboard, FileManagerServiceOptions } from './file-manager-service.type'; @@ -303,7 +308,8 @@ export class FileManagerService implements Disposable { await task(); return true; } catch (error) { - this._report(error instanceof Error ? error.message : String(error)); + this._options.logger.warn('A file operation failed', error); + notifyError(this._options.notifications, 'The file operation failed', error); return false; } } diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts index 28a634ee..e3b6351d 100644 --- a/plugins/git/src/index.ts +++ b/plugins/git/src/index.ts @@ -15,6 +15,7 @@ import { StyleServiceToken, WIDGET_VIEWS, type WidgetInstance, + notifyError, registerIcons, } from '@nanoforge-dev/editor-sdk/ui'; @@ -41,9 +42,7 @@ export default definePlugin({ const projects = services.get(ProjectServiceToken); const contextKeys = services.get(EditorServices.ContextKeys); const store = new GitStore((title, error) => - services.tryGet(NotificationServiceToken)?.notify('error', title, { - detail: error instanceof Error ? error.message : String(error), - }), + notifyError(services.tryGet(NotificationServiceToken), title, error), ); context.subscriptions.add(store); const actions = createActions({ diff --git a/plugins/git/src/widgets/commit/ChangesTab.svelte b/plugins/git/src/widgets/commit/ChangesTab.svelte index 9e11023e..feaa93b1 100644 --- a/plugins/git/src/widgets/commit/ChangesTab.svelte +++ b/plugins/git/src/widgets/commit/ChangesTab.svelte @@ -35,14 +35,14 @@ ? [ { id: 'conflicts', - title: 'Merge Conflicts', + title: 'Merge conflicts', rows: lists.conflicts, checkable: false, } as const, ] : []), { id: 'changes', title: 'Changes', rows: lists.changes, checkable: true }, - { id: 'unversioned', title: 'Unversioned Files', rows: lists.unversioned, checkable: true }, + { id: 'unversioned', title: 'Unversioned files', rows: lists.unversioned, checkable: true }, ]); const busy = $derived($git.busy); @@ -108,7 +108,7 @@ { kind: 'item', id: 'diff', - label: 'Show Diff', + label: 'Show diff', shortcut: 'Ctrl+D', disabled: selectedRows.length !== 1, onSelect: () => selectedRows[0] && showDiff(selectedRows[0]), @@ -116,7 +116,7 @@ { kind: 'item', id: 'source', - label: 'Jump to Source', + label: 'Jump to source', shortcut: 'F4', disabled: selectedRows.length !== 1 || selectedRows[0]?.letter === 'D', onSelect: () => selectedRows[0] && jumpToSource(selectedRows[0]), @@ -246,14 +246,14 @@ selectedRows[0] && showDiff(selectedRows[0])} /> void act.stash()} /> @@ -261,13 +261,13 @@ (panel.folded = [])} /> (panel.folded = groups.map((group) => group.id))} />
diff --git a/plugins/git/src/widgets/commit/CommitMessage.svelte b/plugins/git/src/widgets/commit/CommitMessage.svelte index f588371c..e6f05a7f 100644 --- a/plugins/git/src/widgets/commit/CommitMessage.svelte +++ b/plugins/git/src/widgets/commit/CommitMessage.svelte @@ -74,7 +74,7 @@ {#snippet trigger({ props })} - + {/snippet} @@ -95,7 +95,7 @@ >Commit Commit and push… diff --git a/plugins/git/src/widgets/commit/StashTab.svelte b/plugins/git/src/widgets/commit/StashTab.svelte index deff87ea..f207a83e 100644 --- a/plugins/git/src/widgets/commit/StashTab.svelte +++ b/plugins/git/src/widgets/commit/StashTab.svelte @@ -21,7 +21,7 @@ size="sm" variant="ghost" disabled={$git.busy || !hasChanges} - onclick={() => void act.stash()}>Stash Changes void act.stash()}>Stash changes
    diff --git a/plugins/git/src/widgets/log/BranchTree.svelte b/plugins/git/src/widgets/log/BranchTree.svelte index 4f59186c..258d3661 100644 --- a/plugins/git/src/widgets/log/BranchTree.svelte +++ b/plugins/git/src/widgets/log/BranchTree.svelte @@ -84,7 +84,7 @@ { kind: 'item', id: 'new', - label: `New Branch from '${branch.name}'…`, + label: `New branch from '${branch.name}'…`, onSelect: () => void act.newBranch(branch.name), }, { kind: 'separator' }, @@ -153,27 +153,27 @@ void act.newBranch(selectedBranchEntry?.name)} /> void act.pull()} /> selectedBranchEntry && remove(selectedBranchEntry)} /> void act.fetch()} /> diff --git a/plugins/git/src/widgets/log/CommitTable.svelte b/plugins/git/src/widgets/log/CommitTable.svelte index 0decdd73..485b5d12 100644 --- a/plugins/git/src/widgets/log/CommitTable.svelte +++ b/plugins/git/src/widgets/log/CommitTable.svelte @@ -59,13 +59,13 @@ { kind: 'item', id: 'copy', - label: 'Copy Revision Number', + label: 'Copy revision number', onSelect: () => void navigator.clipboard?.writeText(commit.hash).catch(() => undefined), }, { kind: 'item', id: 'branch', - label: 'New Branch…', + label: 'New branch…', onSelect: () => void act.newBranch(commit.hash), }, ]; diff --git a/plugins/history-panel/nanoforge.manifest.json b/plugins/history-panel/nanoforge.manifest.json index 7113e4c2..bb46efad 100644 --- a/plugins/history-panel/nanoforge.manifest.json +++ b/plugins/history-panel/nanoforge.manifest.json @@ -12,6 +12,7 @@ "widgets": [ { "id": "history-panel.history", + "openByDefault": false, "title": "History", "icon": "history", "defaultSlot": "rightTop", diff --git a/plugins/scene/nanoforge.manifest.json b/plugins/scene/nanoforge.manifest.json index ca054a61..c62c4f27 100644 --- a/plugins/scene/nanoforge.manifest.json +++ b/plugins/scene/nanoforge.manifest.json @@ -30,6 +30,7 @@ "widgets": [ { "id": "scene.scenes", + "essential": true, "title": "Scenes", "icon": "layers", "defaultSlot": "leftTop", diff --git a/plugins/viewport/nanoforge.manifest.json b/plugins/viewport/nanoforge.manifest.json index 8fa067b1..99e9d8e4 100644 --- a/plugins/viewport/nanoforge.manifest.json +++ b/plugins/viewport/nanoforge.manifest.json @@ -16,6 +16,7 @@ "widgets": [ { "id": "viewport.scene", + "description": "Your entities drawn in 2D: select and move them.", "title": "Scene", "icon": "box", "kind": "screen", @@ -23,6 +24,7 @@ }, { "id": "viewport.game", + "description": "Play the game here, and see it run.", "title": "Game", "icon": "gamepad-2", "kind": "screen", From 8a1c6c6bfb7564e542397b6038b45a3276564f9b Mon Sep 17 00:00:00 2001 From: Exelo Date: Fri, 30 Oct 2026 21:34:13 +0100 Subject: [PATCH 2/2] fix: address the simplify interfaces review - the default layout is written once for new users too, so a saved layout no longer turns them into full-layout users; users with other recent projects keep every panel - choosing a layout writes the scope that holds the default - git's own messages are kept on internal errors and timeouts - a screen tab gets a tooltip only when it has a description - docs: widget description, layout paragraph --- .../routes/(editor)/project/[id]/+page.svelte | 2 + .../create-your-first-project.mdx | 4 +- docs/docs/plugins/manifest.mdx | 1 + .../workbench/notification/describe-error.ts | 26 +++-- packages/ui/src/workbench/parts/TopBar.svelte | 44 ++++---- .../ui/src/workbench/setup/setup-workbench.ts | 43 +++---- .../notification/describe-error.spec.ts | 26 +++++ .../workbench/setup/setup-workbench.spec.ts | 106 ++++++++++++++++++ 8 files changed, 201 insertions(+), 51 deletions(-) create mode 100644 packages/ui/test/workbench/setup/setup-workbench.spec.ts diff --git a/apps/editor/src/routes/(editor)/project/[id]/+page.svelte b/apps/editor/src/routes/(editor)/project/[id]/+page.svelte index 87642328..589009cc 100644 --- a/apps/editor/src/routes/(editor)/project/[id]/+page.svelte +++ b/apps/editor/src/routes/(editor)/project/[id]/+page.svelte @@ -146,6 +146,7 @@ resolve(); }); }); + const recent = await editor.projects.recent().catch(() => []); if (disposed) return; created = setupWorkbench({ shell: editor.shell, @@ -157,6 +158,7 @@ history: editor.history, logger: editor.services.get(CoreServices.Logger).getLogger('workbench'), activate: (event) => editor.plugins.activateByEvent(event), + knownUser: recent.some((project) => project.id !== id), }); provided = [ editor.services.provide(LayoutControllerToken, created.layout), diff --git a/docs/docs/getting-started/create-your-first-project.mdx b/docs/docs/getting-started/create-your-first-project.mdx index 19147513..c6abaea5 100644 --- a/docs/docs/getting-started/create-your-first-project.mdx +++ b/docs/docs/getting-started/create-your-first-project.mdx @@ -38,10 +38,10 @@ A local editor can do what a hosted one cannot: run the game, use Git with your - **Right**: the **Inspector**. - **Bottom**: the **Console**. -The editor starts with these main panels only. _View › Layouts › All panels_ adds the others: **Commit**, **Components** and **Systems** on the left, **Problems**, **Git**, and the **Profiler**, **Network** and **World** panels at the bottom. _View › Panels_ opens any one of them, and the **History** of changes. - - **Status bar**: the project, the count of problems, the Git branch, and the game's speed while it plays. +The editor starts with these main panels only. _View › Layouts › All panels_ adds the others: **Commit**, **Components** and **Systems** on the left, **Problems**, **Git**, and the **Profiler**, **Network** and **World** panels at the bottom. _View › Panels_ opens any one of them, and the **History** of changes. + Every panel can be moved, floated or closed: see [Layout](../editor/layout). ## Next diff --git a/docs/docs/plugins/manifest.mdx b/docs/docs/plugins/manifest.mdx index c85f9317..00268b8c 100644 --- a/docs/docs/plugins/manifest.mdx +++ b/docs/docs/plugins/manifest.mdx @@ -88,6 +88,7 @@ Panels (docks) and screens. | `title` | | **Required.** The tab's name. | | `icon` | | An icon name (`folder-tree`): a dock shows only the icon in its stripe. | | `kind` | `dock` | `dock`: a panel in a dock or a floating window. `screen`: a main screen in the center. | +| `description` | | Screens: one line on what it is for, shown in the screen tab's tooltip. | | `defaultSlot` | | `leftTop`, `leftBottom`, `rightTop`, `rightBottom`, `bottom` or `bottomRight`. | | `order` | `0` | Order among the tabs of the slot, or among screens. | | `openByDefault` | `true` | Whether the default layout shows it. | diff --git a/packages/ui/src/workbench/notification/describe-error.ts b/packages/ui/src/workbench/notification/describe-error.ts index 99e723db..42079940 100644 --- a/packages/ui/src/workbench/notification/describe-error.ts +++ b/packages/ui/src/workbench/notification/describe-error.ts @@ -13,21 +13,26 @@ export interface ErrorExplanation { /** * Codes whose raw message is technical. The others (CONFLICT, FORBIDDEN, NOT_FOUND…) carry the * server's own words, already plain and more precise than a hint per code would be ("Already - * exists: a.ts", "Permission denied: b.ts"): they are shown as they are. + * exists: a.ts", "Permission denied: b.ts"): they are shown as they are. A TIMEOUT says what + * timed out and what to do (git's "no answer from the remote"), so it has no hint either. */ -const HINTS: Record = { +const HINTS: Record = { UNAVAILABLE: { hint: "The editor server can't be reached. Your changes are kept: try again once it is back.", retryable: true, }, - TIMEOUT: { hint: 'The editor server took too long to answer.', retryable: true }, + TIMEOUT: { retryable: true }, UNAUTHORIZED: { hint: 'Your session expired: sign in again.', retryable: false }, - INTERNAL: { - hint: 'Something went wrong in the editor server. Show details tells what.', - retryable: false, - }, + INTERNAL: { retryable: false }, }; +/** + * The editor server's message for an error it did not expect (`toRpcError`). An INTERNAL error + * with another message carries its own words (git's stderr): those are shown as they are. + */ +const UNEXPECTED_MESSAGE = 'Internal error'; +const UNEXPECTED_HINT = 'Something went wrong in the editor server. Show details tells what.'; + /** The code of an editor error (an RPC error, or anything with a `code`). */ export const errorCodeOf = (error: unknown): string | undefined => { const code = (error as { code?: unknown } | null)?.code; @@ -36,8 +41,11 @@ export const errorCodeOf = (error: unknown): string | undefined => { export const describeError = (error: unknown): ErrorExplanation => { const technical = error instanceof Error ? error.message : String(error); - const known = HINTS[errorCodeOf(error) ?? '']; - return { hint: known?.hint, technical, retryable: known?.retryable ?? false }; + const code = errorCodeOf(error); + const known = HINTS[code ?? '']; + const hint = + code === 'INTERNAL' && technical === UNEXPECTED_MESSAGE ? UNEXPECTED_HINT : known?.hint; + return { hint, technical, retryable: known?.retryable ?? false }; }; /** diff --git a/packages/ui/src/workbench/parts/TopBar.svelte b/packages/ui/src/workbench/parts/TopBar.svelte index 158c39b7..2d1074ac 100644 --- a/packages/ui/src/workbench/parts/TopBar.svelte +++ b/packages/ui/src/workbench/parts/TopBar.svelte @@ -71,26 +71,30 @@
    {#each current.screens as screen (screen)} {@const description = screenDescription(screen)} - - {#snippet children({ props })} - - {/snippet} - - {#if description}{description}{/if} + {#snippet tab(props: Record)} + + {/snippet} + {#if description} + + {#snippet children({ props })}{@render tab(props)}{/snippet} + + + {:else} + {@render tab({})} + {/if} {/each}
    diff --git a/packages/ui/src/workbench/setup/setup-workbench.ts b/packages/ui/src/workbench/setup/setup-workbench.ts index 32d5bf3c..5a19cec3 100644 --- a/packages/ui/src/workbench/setup/setup-workbench.ts +++ b/packages/ui/src/workbench/setup/setup-workbench.ts @@ -32,7 +32,11 @@ export interface WorkbenchSetupOptions { readonly settings: SettingsService; readonly history: HistoryService; readonly logger: Logger; - /** Activates plugins (`onWidget:`). */ + /** + * The user worked in other projects before (the recent projects list): with no default layout + * chosen yet, they used the editor before presets and keep every panel. + */ + readonly knownUser?: boolean; /** Activates the plugins of an event; may resolve with their failures (to explain a panel). */ readonly activate: (event: string) => Promise; } @@ -103,13 +107,15 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext await layout.reset(); shell.notifications.notify('info', 'Layout reset to default'); }); + /** Where to write the default layout: the scope that holds it now, so no other one outranks it. */ + const presetScope = (): 'account' | 'machine' => { + const { effectiveScope } = settings.inspect(CoreSettings.layoutPreset); + if (effectiveScope === 'account' || effectiveScope === 'machine') return effectiveScope; + return settings.hasStore('account') ? 'account' : 'machine'; + }; /** Switches the default layout and resets to it (the layout's undo history starts over). */ const usePreset = async (preset: 'simple' | 'full') => { - await settings.set( - CoreSettings.layoutPreset, - preset, - settings.hasStore('account') ? 'account' : 'machine', - ); + await settings.set(CoreSettings.layoutPreset, preset, presetScope()); await layout.reset(preset); shell.notifications.notify( 'info', @@ -119,20 +125,17 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext register('layout.simple', 'Simple layout', () => usePreset('simple')); register('layout.full', 'All panels', () => usePreset('full')); /** - * Someone who used the editor before presets keeps every panel when resetting: saved layouts - * and no preset chosen in any scope mean an existing user. + * The default layout is chosen once, the first time no scope holds one: someone who used the + * editor before presets (a saved layout, or other recent projects) keeps every panel, anyone + * else starts simple. Writing it also for a new user keeps their first saved layout from making + * them look like an existing one on the next load. */ - const presetScopes = settings.inspect(CoreSettings.layoutPreset).scopes; - if ( - !layout.firstRun && - Object.entries(presetScopes).every( - ([scope, value]) => scope === 'default' || value === undefined, - ) && - layout.savedNames.length - ) - void settings - .set(CoreSettings.layoutPreset, 'full', settings.hasStore('account') ? 'account' : 'machine') - .catch(() => undefined); + let preset = settings.get(CoreSettings.layoutPreset); + if (settings.inspect(CoreSettings.layoutPreset).effectiveScope === 'default') { + preset = !layout.firstRun || options.knownUser ? 'full' : 'simple'; + void settings.set(CoreSettings.layoutPreset, preset, presetScope()).catch(() => undefined); + if (layout.firstRun && preset === 'full') void layout.reset('full'); + } /** Said once per browser: the default layout is composed again until something is saved. */ const NOTICE_KEY = 'nanoforge.layout.simpleNoticeShown'; @@ -143,7 +146,7 @@ export const setupWorkbench = (options: WorkbenchSetupOptions): WorkbenchContext return true; } }; - if (layout.firstRun && settings.get(CoreSettings.layoutPreset) === 'simple' && !noticeShown()) { + if (layout.firstRun && preset === 'simple' && !noticeShown()) { try { localStorage.setItem(NOTICE_KEY, '1'); } catch {} diff --git a/packages/ui/test/workbench/notification/describe-error.spec.ts b/packages/ui/test/workbench/notification/describe-error.spec.ts index 189dc84b..a115eac5 100644 --- a/packages/ui/test/workbench/notification/describe-error.spec.ts +++ b/packages/ui/test/workbench/notification/describe-error.spec.ts @@ -46,3 +46,29 @@ describe('notifyError', () => { expect(plain!.technical).toBeUndefined(); }); }); + +describe('describeError for git and the server', () => { + it("shows git's own message on a failed command, the hint only for the server's generic one", () => { + const rejected = describeError(rpcError('INTERNAL', '! [rejected] main -> main (fetch first)')); + expect(rejected.hint).toBeUndefined(); + expect(rejected.technical).toBe('! [rejected] main -> main (fetch first)'); + expect(rejected.retryable).toBe(false); + expect(describeError(rpcError('INTERNAL', 'Internal error')).hint).toMatch( + /Something went wrong in the editor server/, + ); + }); + + it('keeps the message of a timeout and offers to try again', () => { + const message = 'git push got no answer from the remote: run it once in a terminal'; + const stuck = describeError(rpcError('TIMEOUT', message)); + expect(stuck).toMatchObject({ hint: undefined, technical: message, retryable: true }); + const notifications = new NotificationService(); + notifyError(notifications, 'Push failed', rpcError('TIMEOUT', message), { + retry: () => undefined, + }); + expect(notifications.history.get()[0]).toMatchObject({ + detail: message, + actions: [{ title: 'Try again' }], + }); + }); +}); diff --git a/packages/ui/test/workbench/setup/setup-workbench.spec.ts b/packages/ui/test/workbench/setup/setup-workbench.spec.ts new file mode 100644 index 00000000..621dd89c --- /dev/null +++ b/packages/ui/test/workbench/setup/setup-workbench.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { HistoryService } from '@nanoforge-dev/editor-history'; +import { + CommandRegistry, + Container, + ContextKeyService, + ExtensionRegistry, +} from '@nanoforge-dev/editor-kernel'; +import { emptyLayout } from '@nanoforge-dev/editor-layout'; +import { + CoreSettings, + MemoryScopeStore, + SettingsRegistry, + SettingsService, +} from '@nanoforge-dev/editor-settings'; + +import { NotificationService } from '../../../src/workbench/notification/notification-service'; +import { setupWorkbench } from '../../../src/workbench/setup/setup-workbench'; + +const settingsWith = async (stores: { account?: MemoryScopeStore; machine?: MemoryScopeStore }) => { + const registry = new SettingsRegistry(); + registry.register(...Object.values(CoreSettings)); + const settings = new SettingsService(registry); + const projectLocal = new MemoryScopeStore(); + await settings.setStore('projectLocal', projectLocal); + for (const [scope, store] of Object.entries(stores)) + await settings.setStore(scope as 'account' | 'machine', store); + return { settings, projectLocal }; +}; + +const open = (settings: SettingsService, knownUser?: boolean) => { + const contextKeys = new ContextKeyService(); + return setupWorkbench({ + shell: { notifications: new NotificationService(), styles: {}, prompts: {} } as never, + services: {} as never, + extensions: new ExtensionRegistry(), + commands: new CommandRegistry(new Container(), contextKeys), + contextKeys, + settings, + history: new HistoryService(), + logger: { warn: () => undefined, error: () => undefined } as never, + activate: async () => undefined, + ...(knownUser !== undefined && { knownUser }), + }); +}; + +const preset = (store: MemoryScopeStore) => store.values.get()[CoreSettings.layoutPreset.key]; + +describe('setupWorkbench default layout', () => { + it('keeps every panel for someone with a saved layout and no default chosen', async () => { + const account = new MemoryScopeStore(); + const { settings } = await settingsWith({ account }); + await settings.set(CoreSettings.layoutSaved, { default: emptyLayout() }, 'projectLocal'); + const workbench = open(settings); + expect(workbench.layout.firstRun).toBe(false); + await expect.poll(() => preset(account)).toBe('full'); + workbench.dispose(); + }); + + it('starts a new user with the main panels and writes it', async () => { + const account = new MemoryScopeStore(); + const { settings } = await settingsWith({ account }); + const workbench = open(settings); + expect(workbench.layout.firstRun).toBe(true); + await expect.poll(() => preset(account)).toBe('simple'); + workbench.dispose(); + }); + + it('keeps a new user on the main panels once their layout is saved', async () => { + const account = new MemoryScopeStore(); + const { settings, projectLocal } = await settingsWith({ account }); + const first = open(settings); + await expect.poll(() => preset(account)).toBe('simple'); + await first.layout.apply({ type: 'resizeSlot', slot: 'bottom', size: 320 }); + first.dispose(); + await expect.poll(() => projectLocal.values.get()[CoreSettings.layoutSaved.key]).toBeDefined(); + + const second = open(settings); + expect(second.layout.firstRun).toBe(false); + await Promise.resolve(); + expect(preset(account)).toBe('simple'); + expect(settings.get(CoreSettings.layoutPreset)).toBe('simple'); + second.dispose(); + }); + + it('keeps every panel for someone who worked in other projects', async () => { + const account = new MemoryScopeStore(); + const { settings } = await settingsWith({ account }); + const workbench = open(settings, true); + await expect.poll(() => preset(account)).toBe('full'); + workbench.dispose(); + }); + + it('switches the default layout in the scope that holds it', async () => { + const account = new MemoryScopeStore(); + const machine = new MemoryScopeStore({ [CoreSettings.layoutPreset.key]: 'full' }); + const { settings } = await settingsWith({ account, machine }); + const workbench = open(settings); + await workbench.commands.execute('layout.simple'); + expect(preset(machine)).toBe('simple'); + expect(preset(account)).toBeUndefined(); + expect(settings.get(CoreSettings.layoutPreset)).toBe('simple'); + workbench.dispose(); + }); +});