Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
useSyncExternalStore,
} from 'react'
import useEditor from '../../../store/use-editor'
import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels'
import {
editorHostPanelRegistry,
type EditorHostPanel,
managedPluginIds,
showsPluginManager,
} from '../../../lib/plugin-panels'
import { ErrorBoundary } from '../primitives/error-boundary'
import type { ExtraPanel } from './icon-rail'
import { PluginsPanel } from './panels/plugins-panel'
Expand Down Expand Up @@ -100,6 +105,7 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
)
const workspaceMode = useEditor((s) => s.workspaceMode)
const installedPlugins = useScene((s) => s.installedPlugins)
const readOnly = useScene((s) => s.readOnly)
const hostIds = new Set(hostPanels?.map((p) => p.id))

useEffect(() => {
Expand All @@ -126,7 +132,16 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
pluginId: p.pluginId,
}),
)
// The manager tab is the one panel the editor contributes itself, so it is
// also the one that can be alone in the rail — see `showsPluginManager`.
const manager =
workspaceMode === 'edit' && !hostIds.has(pluginsManagerPanel.id) ? [pluginsManagerPanel] : []
!hostIds.has(pluginsManagerPanel.id) &&
showsPluginManager({
managedPluginCount: managedPluginIds(registered).length,
readOnly,
workspaceMode,
})
? [pluginsManagerPanel]
: []
return [...(hostPanels ?? []), ...fromRegistry, ...manager]
}
93 changes: 92 additions & 1 deletion packages/editor/src/lib/plugin-panels.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { editorHostPanelRegistry, registerEditorHostPanel } from './plugin-panels'
import {
type EditorHostPanel,
editorHostPanelRegistry,
managedPluginIds,
registerEditorHostPanel,
showsPluginManager,
} from './plugin-panels'

describe('editorHostPanelRegistry', () => {
afterEach(() => editorHostPanelRegistry.reset())
Expand All @@ -17,3 +23,88 @@ describe('editorHostPanelRegistry', () => {
expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined()
})
})

const panel = (id: string, pluginId?: string): EditorHostPanel => ({
component: async () => ({ default: () => null }),
icon: { kind: 'url', src: '/x.webp' },
id,
label: id,
...(pluginId ? { pluginId } : {}),
})

describe('managedPluginIds', () => {
test('counts plugins, not panels — one plugin with two panels is one plugin', () => {
expect(
managedPluginIds([
panel('pascal:boots:game', 'pascal:boots'),
panel('pascal:boots:keep', 'pascal:boots'),
panel('pascal:trees:nature', 'pascal:trees'),
]),
).toEqual(['pascal:boots', 'pascal:trees'])
})

test("the editor's own panels are not plugins", () => {
// No `pluginId` means it came from the host app, not from a plugin, and
// there is nothing to install or uninstall.
expect(managedPluginIds([panel('site'), panel('settings')])).toEqual([])
expect(managedPluginIds([])).toEqual([])
})
})

/**
* THE EMPTY LOBBY PANEL (owner report 2026-08-31). `/play/<id>` mounts the
* editor read-only and registers no host panels, so the plugin *manager* was
* the only tab in the rail: it opened by default onto a bare "Plugins" heading
* eating ~40% of a visitor's window. Dropping the last tab makes the v2 layout
* drop the left column entirely, which is the lobby as designed.
*
* The line to hold is that this hides an EMPTY manager and never a populated
* one — a read-only viewer in the real editor can still see what a project uses.
*/
describe('showsPluginManager', () => {
test('the open lobby — read-only with nothing registered — gets no rail at all', () => {
expect(
showsPluginManager({ managedPluginCount: 0, readOnly: true, workspaceMode: 'edit' }),
).toBe(false)
})

test('a read-only editor keeps the manager as soon as a plugin is registered', () => {
// Browsing what a project uses is a read, and the install button is
// already disabled on its own.
expect(
showsPluginManager({ managedPluginCount: 1, readOnly: true, workspaceMode: 'edit' }),
).toBe(true)
})

test('a writable scene always keeps it, even with zero plugins', () => {
// The empty state is still useful to an owner: it is where "Create a
// Pascal plugin" lives.
expect(
showsPluginManager({ managedPluginCount: 0, readOnly: false, workspaceMode: 'edit' }),
).toBe(true)
expect(
showsPluginManager({ managedPluginCount: 3, readOnly: false, workspaceMode: 'edit' }),
).toBe(true)
})

test('never outside the edit workspace — studio has its own rail', () => {
for (const readOnly of [false, true]) {
for (const managedPluginCount of [0, 2]) {
expect(
showsPluginManager({ managedPluginCount, readOnly, workspaceMode: 'studio' }),
`readOnly=${readOnly} count=${managedPluginCount}`,
).toBe(false)
}
}
})

test('the pre-existing behaviour is unchanged for the normal editor', () => {
// Regression fence: before this gate the rule was `workspaceMode === 'edit'`
// alone. Every writable edit-workspace case must still answer the same.
for (const managedPluginCount of [0, 1, 5]) {
expect(
showsPluginManager({ managedPluginCount, readOnly: false, workspaceMode: 'edit' }),
).toBe(true)
}
})
})
48 changes: 48 additions & 0 deletions packages/editor/src/lib/plugin-panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,51 @@ export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl()
export function registerEditorHostPanel(panel: EditorHostPanel): void {
editorHostPanelRegistry.registerPanel(panel)
}

/**
* The distinct plugins the manager can act on — every registered panel that
* declares a `pluginId`, deduplicated, because one plugin may contribute
* several panels and the manager lists plugins, not panels.
*
* Registration is what makes a plugin *manageable*, not installation: an
* uninstalled plugin still has to appear so it can be installed.
*/
export function managedPluginIds(panels: readonly EditorHostPanel[]): string[] {
return Array.from(
new Set(panels.filter((panel) => panel.pluginId).map((panel) => panel.pluginId as string)),
)
}

/**
* Does the plugin *manager* tab belong in the rail?
*
* It is a management surface — it installs and uninstalls plugins into the
* scene — so it earns a slot when there is something to manage, or when the
* scene is writable and the "create a plugin" path is still worth offering to
* whoever owns it.
*
* That leaves exactly one case out, and it is a real screen rather than a
* hypothetical: the open lobby (`/play/<id>`) mounts the editor under a
* read-only lease and registers NO host panels, so the manager was the only
* tab in the rail. The rail therefore opened by default onto a "Plugins"
* heading with nothing under it, covering roughly 40% of a visitor's window
* over the world they had come to play in (owner report 2026-08-31). With no
* tabs at all the v2 layout drops the whole left column, which is the lobby as
* intended: the canvas, and nothing else.
*
* A read-only *editor* keeps the tab as long as plugins are registered — a
* viewer can still read what a project uses; only the install button is
* disabled. So this hides an empty panel, never a populated one.
*/
export function showsPluginManager({
managedPluginCount,
readOnly,
workspaceMode,
}: {
managedPluginCount: number
readOnly: boolean
workspaceMode: string
}): boolean {
if (workspaceMode !== 'edit') return false
return managedPluginCount > 0 || !readOnly
}
Loading