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
212 changes: 116 additions & 96 deletions apps/electron/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import type { ConnectHubRequest } from './components/ConnectHubDialog'
import { useCommandPalette, CommandPalette } from '@xnetjs/ui'
import { PlatformProvider } from '@xnetjs/workbench'
import { AiChatPanel } from '@xnetjs/workbench/ai'
import React, { useCallback, useEffect, useState } from 'react'
import { ActionDock } from './components/ActionDock'
Expand All @@ -26,6 +27,7 @@ import { SocialImportView } from './components/SocialImportView'
import { StorybookView } from './components/StorybookView'
import { SystemMenu } from './components/SystemMenu'
import { setPersistedHubUrl } from './lib/hub-url'
import { useDesktopPlatformPort } from './shell/desktop-platform'
import { STORIES_ENABLED, useDocumentShell } from './shell/use-document-shell'
import { useShellPaletteCommands } from './shell/use-shell-palette-commands'

Expand Down Expand Up @@ -92,6 +94,18 @@ export function App(): React.ReactElement {
await window.__xnetIpcSyncManager?.configureShareSession({ signalingUrl: request.hub })
}, [])

// The desktop PlatformPort (0406 phase 3): shared workbench modules navigate
// by intent; this host resolves intents to ShellState transitions.
const platform = useDesktopPlatformPort({
shellState,
returnHome: handleReturnHome,
openDocument: handleOpenDocument,
openAssistant: handleOpenAssistant,
openSettings: handleOpenSettings,
openMeetings: handleOpenMeetings,
openDataWorkspace: handleOpenDataWorkspace
})

const paletteCommands = useShellPaletteCommands({
canvasViewRef,
canvasCommandState,
Expand Down Expand Up @@ -274,112 +288,118 @@ export function App(): React.ReactElement {
}

return (
<div className="relative h-screen overflow-hidden bg-background">
<header className="absolute inset-x-0 top-0 z-50 h-[38px]">
<div className="absolute inset-0 titlebar-drag" />
<div className="relative flex h-full items-center justify-end px-3">
<SystemMenu
recentDocuments={recentDocuments}
onOpenDocument={handleOpenDocument}
onOpenSettings={handleOpenSettings}
onOpenDataWorkspace={handleOpenDataWorkspace}
onOpenMeetings={handleOpenMeetings}
onOpenAssistant={handleOpenAssistant}
onOpenSocialImport={handleOpenSocialImport}
onOpenStories={STORIES_ENABLED ? handleOpenStories : undefined}
onAddShared={() => {
setPrefilledShareValue('')
setShowAddSharedDialog(true)
}}
onToggleDebugPanel={() => {
window.dispatchEvent(new CustomEvent('xnet-devtools-toggle'))
}}
/>
</div>
</header>
<PlatformProvider value={platform}>
<div className="relative h-screen overflow-hidden bg-background">
<header className="absolute inset-x-0 top-0 z-50 h-[38px]">
<div className="absolute inset-0 titlebar-drag" />
<div className="relative flex h-full items-center justify-end px-3">
<SystemMenu
recentDocuments={recentDocuments}
onOpenDocument={handleOpenDocument}
onOpenSettings={handleOpenSettings}
onOpenDataWorkspace={handleOpenDataWorkspace}
onOpenMeetings={handleOpenMeetings}
onOpenAssistant={handleOpenAssistant}
onOpenSocialImport={handleOpenSocialImport}
onOpenStories={STORIES_ENABLED ? handleOpenStories : undefined}
onAddShared={() => {
setPrefilledShareValue('')
setShowAddSharedDialog(true)
}}
onToggleDebugPanel={() => {
window.dispatchEvent(new CustomEvent('xnet-devtools-toggle'))
}}
/>
</div>
</header>

<main className="relative h-full overflow-hidden pt-[38px]">
<div
className={[
'absolute inset-0',
prefersReducedMotion ? '' : 'transition-all duration-200',
isCanvasInteractiveShell
? 'opacity-100'
: prefersReducedMotion
? 'pointer-events-none opacity-70'
: 'pointer-events-none scale-[0.985] opacity-70'
].join(' ')}
>
<CanvasView
ref={canvasViewRef}
docId={homeCanvasId}
documents={documents}
pendingInsert={pendingCanvasInsert}
<main className="relative h-full overflow-hidden pt-[38px]">
<div
className={[
'absolute inset-0',
prefersReducedMotion ? '' : 'transition-all duration-200',
isCanvasInteractiveShell
? 'opacity-100'
: prefersReducedMotion
? 'pointer-events-none opacity-70'
: 'pointer-events-none scale-[0.985] opacity-70'
].join(' ')}
>
<CanvasView
ref={canvasViewRef}
docId={homeCanvasId}
documents={documents}
pendingInsert={pendingCanvasInsert}
onCreatePage={() => void handleCreateLinkedDocument('page')}
onCreateDatabase={() => void handleCreateLinkedDocument('database')}
onCreateNote={handleCreateCanvasNote}
onCommandStateChange={handleCommandStateChange}
onPendingInsertConsumed={handlePendingInsertConsumed}
onOpenDocument={(docId, docType) => focusDocument(docId, docType, true)}
onOpenDatabaseSplit={openDatabaseSplit}
/>
</div>

{renderOverlay()}

<ActionDock
mode={isCanvasInteractiveShell ? 'canvas-home' : 'focused'}
onCreatePage={() => void handleCreateLinkedDocument('page')}
onCreateDatabase={() => void handleCreateLinkedDocument('database')}
onCreateNote={handleCreateCanvasNote}
onCommandStateChange={handleCommandStateChange}
onPendingInsertConsumed={handlePendingInsertConsumed}
onOpenDocument={(docId, docType) => focusDocument(docId, docType, true)}
onOpenDatabaseSplit={openDatabaseSplit}
onCreateShape={() => {
canvasViewRef.current?.createShape('rectangle')
}}
onCreateFrame={() => {
canvasViewRef.current?.createFrame()
}}
onCreateReference={() => {
canvasViewRef.current?.createExternalReference()
}}
onCreateMedia={() => {
canvasViewRef.current?.createMediaFile()
}}
onOpenSearch={showPalette}
onReturnHome={handleReturnHome}
onZoomOut={() => {
canvasViewRef.current?.zoomOut()
}}
onZoomIn={() => {
canvasViewRef.current?.zoomIn()
}}
onFitToContent={() => {
canvasViewRef.current?.fitCanvasContent()
}}
onResetView={() => {
canvasViewRef.current?.resetCanvasView()
}}
/>
</div>

{renderOverlay()}
</main>

<ActionDock
mode={isCanvasInteractiveShell ? 'canvas-home' : 'focused'}
onCreatePage={() => void handleCreateLinkedDocument('page')}
onCreateDatabase={() => void handleCreateLinkedDocument('database')}
onCreateNote={handleCreateCanvasNote}
onCreateShape={() => {
canvasViewRef.current?.createShape('rectangle')
}}
onCreateFrame={() => {
canvasViewRef.current?.createFrame()
}}
onCreateReference={() => {
canvasViewRef.current?.createExternalReference()
}}
onCreateMedia={() => {
canvasViewRef.current?.createMediaFile()
}}
onOpenSearch={showPalette}
onReturnHome={handleReturnHome}
onZoomOut={() => {
canvasViewRef.current?.zoomOut()
}}
onZoomIn={() => {
canvasViewRef.current?.zoomIn()
}}
onFitToContent={() => {
canvasViewRef.current?.fitCanvasContent()
}}
onResetView={() => {
canvasViewRef.current?.resetCanvasView()
<AddSharedDialog
isOpen={showAddSharedDialog}
onClose={() => {
setShowAddSharedDialog(false)
setPrefilledShareValue('')
}}
onAdd={handleAddShared}
initialValue={prefilledShareValue}
/>
</main>

<AddSharedDialog
isOpen={showAddSharedDialog}
onClose={() => {
setShowAddSharedDialog(false)
setPrefilledShareValue('')
}}
onAdd={handleAddShared}
initialValue={prefilledShareValue}
/>

<ConnectHubDialog
request={connectRequest}
onCancel={() => setConnectRequest(null)}
onConfirm={handleCloudConnect}
/>
<ConnectHubDialog
request={connectRequest}
onCancel={() => setConnectRequest(null)}
onConfirm={handleCloudConnect}
/>

<CommandPalette commands={paletteCommands} open={paletteOpen} onOpenChange={setPaletteOpen} />
<CommandPalette
commands={paletteCommands}
open={paletteOpen}
onOpenChange={setPaletteOpen}
/>

<BundledPluginInstaller />
</div>
<BundledPluginInstaller />
</div>
</PlatformProvider>
)
}
86 changes: 86 additions & 0 deletions apps/electron/src/renderer/shell/desktop-platform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* The desktop PlatformPort's two pure halves (exploration 0406):
* shell state → synthesized pathname, and nav intent → shell action.
*
* The pathname grammar mirrors web's routes on purpose — shared core modules
* (`tabFromPathname`, route titles) must behave identically on both hosts.
*/

import type { NavTarget } from '@xnetjs/workbench'
import { describe, expect, it, vi } from 'vitest'
import { navigateShell, pathnameForShellState, type DesktopNavDeps } from './desktop-platform'

function makeDeps(): DesktopNavDeps & { calls: string[] } {
const calls: string[] = []
return {
calls,
shellState: { kind: 'canvas-home' },
returnHome: () => void calls.push('shell:return-home'),
openDocument: (id) => void calls.push(`doc:${id}`),
openAssistant: () => void calls.push('assistant'),
openSettings: () => void calls.push('settings'),
openMeetings: () => void calls.push('meetings'),
openDataWorkspace: () => void calls.push('data')
}
}

describe('pathnameForShellState', () => {
it('mirrors the web route grammar for every shell kind', () => {
expect(pathnameForShellState({ kind: 'canvas-home' })).toBe('/')
expect(pathnameForShellState({ kind: 'page-focus', docId: 'p1', returnViewport: null })).toBe(
'/doc/p1'
)
expect(
pathnameForShellState({ kind: 'database-focus', docId: 'd1', returnViewport: null })
).toBe('/db/d1')
expect(pathnameForShellState({ kind: 'database-split', docId: 'd2' })).toBe('/db/d2')
expect(pathnameForShellState({ kind: 'settings' })).toBe('/settings')
expect(pathnameForShellState({ kind: 'data-workspace' })).toBe('/data')
expect(pathnameForShellState({ kind: 'assistant' })).toBe('/ai')
})

it('encodes doc ids the way web routes do (seeded ids contain slashes)', () => {
expect(
pathnameForShellState({ kind: 'page-focus', docId: 'default/spec', returnViewport: null })
).toBe('/doc/default%2Fspec')
})
})

describe('navigateShell', () => {
it('routes node intents through the shell handlers', () => {
const deps = makeDeps()
navigateShell({ kind: 'node', nodeType: 'page', nodeId: 'p1' }, deps)
navigateShell({ kind: 'node', nodeType: 'settings', nodeId: '' }, deps)
navigateShell({ kind: 'home' }, deps)
expect(deps.calls).toEqual(['doc:p1', 'settings', 'shell:return-home'])
})

it('maps the path escape hatch onto desktop surfaces', () => {
const deps = makeDeps()
navigateShell({ kind: 'path', path: '/ai' }, deps)
navigateShell({ kind: 'path', path: '/data' }, deps)
expect(deps.calls).toEqual(['assistant', 'data'])
})

it('returns false for targets this host has no surface for — never a silent no-op', () => {
const deps = makeDeps()
const unhandled: NavTarget[] = [
{ kind: 'node', nodeType: 'crm', nodeId: '' },
{ kind: 'path', path: '/requests' },
{ kind: 'surface', surfaceId: 'discover' }
]
for (const target of unhandled) {
expect(navigateShell(target, deps), JSON.stringify(target)).toBe(false)
}
expect(deps.calls).toEqual([])
})

it('handles every TabNodeType without throwing (exhaustive dispatch)', () => {
const deps = makeDeps()
const spy = vi.fn()
// Types desktop cannot open return false; none may throw.
for (const nodeType of ['post', 'dashboard', 'map', 'savedview', 'tag', 'channel'] as const) {
expect(() => spy(navigateShell({ kind: 'node', nodeType, nodeId: 'x' }, deps))).not.toThrow()
}
})
})
Loading
Loading