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
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ module.exports = {
// shared by web and desktop. The desktop renderer has no router, so the
// shell reaches navigation only through the PlatformPort — a direct
// router import here is the fork restarting inside shared code.
files: ['apps/web/src/workbench/**/*.{ts,tsx}'],
files: ['apps/web/src/workbench/**/*.{ts,tsx}', 'packages/workbench/src/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@xnetjs/ui": "workspace:*",
"@xnetjs/vectors": "workspace:*",
"@xnetjs/views": "workspace:*",
"@xnetjs/workbench": "workspace:*",
"fflate": "0.8.3",
"lucide-react": "^0.453.0",
"maplibre-gl": "^5.0.0",
Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/lib/doc-creation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
* Shared document-creation affordances: the per-type route/icon/label table
* and the "New …" dropdown items used by both the sidebar and the home page.
*/
export { newDocId, type CreatableDocType } from '@xnetjs/workbench'
import type { CreatableDocType } from '@xnetjs/workbench'
import type { ComponentType } from 'react'
import { Code2, Database, FileText, Layout, LayoutDashboard, MapPin } from 'lucide-react'

export type CreatableDocType = 'page' | 'database' | 'canvas' | 'dashboard' | 'map' | 'lab'

export interface DocTypeRoute {
to: string
paramKey: string
Expand All @@ -28,10 +28,6 @@ export const DOC_TYPE_ROUTES: Record<CreatableDocType, DocTypeRoute> = {
lab: { to: '/lab/$labId', paramKey: 'labId', label: 'Lab', icon: Code2 }
}

export function newDocId(): string {
return Math.random().toString(36).substring(2, 15)
}

/** The shared "New …" dropdown entries. */
export function CreateDocMenuItems({
types,
Expand Down
191 changes: 3 additions & 188 deletions apps/web/src/workbench/commands.ts
Original file line number Diff line number Diff line change
@@ -1,190 +1,5 @@
/**
* Core workbench keyboard map (exploration 0166).
*
* Every shell action is a CommandRegistry command so the palette can
* list it with its chord. Cmd+K (palette) lives in GlobalSearch;
* Cmd+T / Cmd+W / Ctrl+Tab / Cmd+1/2 are registered by the editor
* area where tab context lives.
* Shim (0406): canonical module lives in @xnetjs/workbench — the 0280
* layout-tree pattern. New code imports the package directly.
*/
import { getCommandRegistry } from '@xnetjs/plugins'
import { useEffect } from 'react'
import { useWorkbench } from './state'

export function useWorkbenchCommands(): void {
useEffect(() => {
const registry = getCommandRegistry()
const wb = () => useWorkbench.getState()

const disposables = [
registry.register({
id: 'workbench.toggleLeftPanel',
title: 'Toggle left panel',
key: 'Mod-B',
allowInInput: true,
run: () => wb().togglePanel('left')
}),
registry.register({
id: 'workbench.toggleRightPanel',
title: 'Toggle right panel',
key: 'Mod-\\',
allowInInput: true,
run: () => wb().togglePanel('right')
}),
registry.register({
id: 'workbench.toggleBottomPanel',
title: 'Toggle bottom panel',
key: 'Mod-J',
allowInInput: true,
run: () => wb().togglePanel('bottom')
}),
registry.register({
id: 'workbench.focus',
title: 'View: Focus mode (hide chrome)',
key: 'Mod-.',
allowInInput: true,
run: () => wb().toggleFocus()
}),
registry.register({
id: 'workbench.switchLayout',
title: 'View: Switch layout (Calm ↔ Workbench)',
run: () => wb().toggleLayout()
}),
// Tabless mode (0353): both directions are one ⌘K away, so the
// preference is reversible without hunting through settings.
registry.register({
id: 'workbench.disableTabs',
title: 'View: Turn off tabs (single surface)',
when: () => useWorkbench.getState().tabsEnabled,
run: () => wb().setTabsEnabled(false)
}),
registry.register({
id: 'workbench.enableTabs',
title: 'View: Turn on tabs',
when: () => !useWorkbench.getState().tabsEnabled,
run: () => wb().setTabsEnabled(true)
}),
// Quiet-surface posture (0273): both directions get explicit palette
// entries so either posture is one ⌘K away from the other.
registry.register({
id: 'workbench.quietChrome',
title: 'View: Quiet chrome (surface first)',
when: () => useWorkbench.getState().chrome !== 'quiet',
run: () => wb().setChrome('quiet')
}),
registry.register({
id: 'workbench.pinnedChrome',
title: 'View: Pinned chrome',
when: () => useWorkbench.getState().chrome !== 'pinned',
run: () => wb().setChrome('pinned')
}),
registry.register({
id: 'workbench.showExplorer',
title: 'Show explorer',
run: () => wb().showPanelView('left', 'explorer')
}),
registry.register({
id: 'workbench.showTasksPanel',
title: 'Show tasks panel',
run: () => wb().showPanelView('left', 'tasks')
}),
registry.register({
id: 'workbench.showDataPanel',
title: 'Show data panel',
run: () => wb().showPanelView('left', 'data')
}),
registry.register({
id: 'workbench.setStartupTab',
title: 'Use current tab at startup',
when: () => {
const state = useWorkbench.getState()
const group = state.groups.find((g) => g.id === state.activeGroupId)
return Boolean(group?.activeTabId)
},
run: () => {
const state = wb()
const group = state.groups.find((g) => g.id === state.activeGroupId)
const tab = group?.tabs.find((t) => t.id === group.activeTabId)
if (tab) state.setStartupTab({ nodeType: tab.nodeType, nodeId: tab.nodeId })
}
}),
registry.register({
id: 'workbench.clearStartupTab',
title: 'Clear startup tab',
when: () => Boolean(useWorkbench.getState().startupTab),
run: () => wb().setStartupTab(null)
})
]

return () => {
for (const disposable of disposables) disposable.dispose()
}
}, [])
}

/**
* The pinned frame's Esc ladder (0280 phase 4, extending 0273): each Esc
* closes ONE open dock — bottom, then right, then left — walking the
* disclosure ladder down to the bare surface. Runs only when nothing
* closer to the keystroke claimed it (palette, dialogs, editors all
* preventDefault first) and never steals Esc from text inputs.
*/
export function useShellEscape(): void {
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
const target = event.target instanceof HTMLElement ? event.target : null
if (
target &&
(target.closest('input, textarea, [contenteditable="true"]') || target.isContentEditable)
) {
return
}
const state = useWorkbench.getState()
// Focus mode (0284): a single Esc restores the chrome.
if (state.focus) {
event.preventDefault()
state.setFocus(false)
return
}
// Tabless split (0353) is the newest surface, so it's the first rung
// down: Esc closes the second pane before it starts closing docks.
if (state.splitTarget) {
event.preventDefault()
state.setSplitTarget(null)
return
}
if (state.chrome === 'quiet' || state.mode === 'zen') return
const side = (['bottom', 'right', 'left'] as const).find((s) => state[s].open)
if (!side) return
event.preventDefault()
state.setPanelOpen(side, false)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
}

/** Exit zen with Esc Esc (two presses within 500ms), preserving layout. */
export function useZenEscape(): void {
const mode = useWorkbench((state) => state.mode)

useEffect(() => {
if (mode !== 'zen') return

let lastEscape = 0
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
const now = Date.now()
if (now - lastEscape < 500) {
event.preventDefault()
useWorkbench.getState().toggleZen()
lastEscape = 0
} else {
lastEscape = now
}
}

window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [mode])
}
export * from '@xnetjs/workbench'
102 changes: 3 additions & 99 deletions apps/web/src/workbench/focus.ts
Original file line number Diff line number Diff line change
@@ -1,101 +1,5 @@
/**
* Shell-level focus ring (exploration 0166).
*
* Regions carry data-wb-region attributes; F6 / Shift+F6 cycle focus
* through the visible ones (the VS Code model), and Escape inside a
* panel returns focus to the editor — the per-region "trap" exit.
* Every shell action stays reachable by keyboard alone.
* Shim (0406): canonical module lives in @xnetjs/workbench — the 0280
* layout-tree pattern. New code imports the package directly.
*/
import { getCommandRegistry } from '@xnetjs/plugins'
import { useEffect } from 'react'
import { useWorkbench } from './state'

export type WorkbenchRegion = 'left' | 'editor' | 'right' | 'bottom'

const RING_ORDER: WorkbenchRegion[] = ['left', 'editor', 'right', 'bottom']

const FOCUSABLE =
'input, textarea, select, button, a[href], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]'

export function focusRegion(region: WorkbenchRegion): void {
const root = document.querySelector<HTMLElement>(`[data-wb-region="${region}"]`)
if (!root) return
const target = root.querySelector<HTMLElement>(FOCUSABLE) ?? root
target.focus()
}

function visibleRing(): WorkbenchRegion[] {
const state = useWorkbench.getState()
return RING_ORDER.filter((region) => {
if (region === 'editor') return true
return state[region].open
})
}

function currentRegion(): WorkbenchRegion | null {
const active = document.activeElement
if (!(active instanceof HTMLElement)) return null
const root = active.closest<HTMLElement>('[data-wb-region]')
return (root?.dataset.wbRegion as WorkbenchRegion | undefined) ?? null
}

function cycleRegion(delta: 1 | -1): void {
const ring = visibleRing()
if (ring.length === 0) return
const current = currentRegion()
const index = current ? ring.indexOf(current) : -1
const next = ring[(index + delta + ring.length) % ring.length]
focusRegion(next)
}

function returnFocusToEditor(): void {
const region = currentRegion()
if (region !== null && region !== 'editor') focusRegion('editor')
}

const EDITABLE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])

function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return EDITABLE_TAGS.has(target.tagName) || target.isContentEditable
}

export function useFocusRing(): void {
useEffect(() => {
const registry = getCommandRegistry()
const disposables = [
registry.register({
id: 'workbench.focusNextRegion',
title: 'Focus next region',
key: 'F6',
allowInInput: true,
run: () => cycleRegion(1)
}),
registry.register({
id: 'workbench.focusPreviousRegion',
title: 'Focus previous region',
key: 'Shift-F6',
allowInInput: true,
run: () => cycleRegion(-1)
}),
registry.register({
id: 'workbench.focusEditor',
title: 'Focus editor',
run: () => focusRegion('editor')
})
]

// Escape inside a panel returns to the editor; inputs keep their
// own Escape semantics (blur, close menus) untouched.
const escapeHandler = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || isEditableTarget(event.target)) return
returnFocusToEditor()
}

window.addEventListener('keydown', escapeHandler)
return () => {
for (const disposable of disposables) disposable.dispose()
window.removeEventListener('keydown', escapeHandler)
}
}, [])
}
export * from '@xnetjs/workbench'
34 changes: 3 additions & 31 deletions apps/web/src/workbench/layout-tree.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,5 @@
/**
* LayoutTree (0280) — canonical module lives in @xnetjs/plugins
* (`workspace/layout-tree`), shared with the seed and the desktop shell.
* This shim keeps the workbench's local import paths stable.
* Shim (0406): canonical module lives in @xnetjs/workbench — the 0280
* layout-tree pattern. New code imports the package directly.
*/
export {
createDefaultTree,
createPresetTree,
DEFAULT_WORKSPACE_ID,
insertSlot,
moveSlot,
parseWorkspacePayload,
placementOf,
PRESET_IDS,
PRESET_WORKSPACE_ID_PREFIX,
isPresetWorkspaceId,
presetForWorkspaceId,
presetWorkspaceId,
REGION_IDS,
regionOf,
serializeWorkspacePayload,
setSlotTier,
slotsIn
} from '@xnetjs/plugins'
export type {
ChromePosture,
LayoutTree,
PresetId,
RegionId,
SlotPlacement,
SlotTier,
WorkspacePayload
} from '@xnetjs/plugins'
export * from '@xnetjs/workbench'
Loading
Loading