fix(desktop): shift project menu around browser preview

This commit is contained in:
程序员阿江(Relakkes)
2026-08-03 02:35:44 +08:00
parent f6f2a6a6f7
commit 0f1ebf8d1f
20 changed files with 41 additions and 1080 deletions
-31
View File
@@ -3,7 +3,6 @@ import { ELECTRON_IPC_CHANNELS } from './channels'
import {
ELECTRON_IPC_VALIDATORS,
isElectronIpcChannel,
isElectronIpcChannelAllowedForOpenProjectMenuWindow,
isElectronIpcChannelAllowedForPetWindow,
validateElectronIpcPayload,
} from './capabilities'
@@ -31,21 +30,6 @@ describe('Electron IPC capabilities', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, { text: 'paste me' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.openProjectMenuShow, {
anchor: { x: 900, y: 8, width: 44, height: 32 },
targets: [
{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
],
zoom: 1,
})).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.openProjectMenuShow, {
anchor: { x: 900, y: 8, width: 44, height: 32 },
targets: [{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }],
zoom: 1,
})).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.openProjectMenuReady, 1)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.openProjectMenuReady, 0)).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, undefined)).toBe(true)
@@ -178,21 +162,6 @@ describe('Electron IPC capabilities', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusSession, '../escape')).toBe(false)
})
it('limits the open-project popup to its narrow IPC surface', () => {
expect(isElectronIpcChannelAllowedForOpenProjectMenuWindow(
ELECTRON_IPC_CHANNELS.openProjectMenuGetState,
)).toBe(true)
expect(isElectronIpcChannelAllowedForOpenProjectMenuWindow(
ELECTRON_IPC_CHANNELS.openProjectMenuSelect,
)).toBe(true)
expect(isElectronIpcChannelAllowedForOpenProjectMenuWindow(
ELECTRON_IPC_CHANNELS.shellOpen,
)).toBe(false)
expect(isElectronIpcChannelAllowedForOpenProjectMenuWindow(
ELECTRON_IPC_CHANNELS.runtimeGetLocalAccessToken,
)).toBe(false)
})
it('pins the reported appearance colors to literal 6-digit hex', () => {
// Both values reach BrowserWindow.setBackgroundColor, which also accepts
// #AARRGGBB — an 8-digit value would let a compromised renderer make the
-65
View File
@@ -181,52 +181,6 @@ const urlWithOptionalBounds: Validator = value =>
const zoomPayload: Validator = value => typeof value === 'number' && Number.isFinite(value)
const finiteUiCoordinate = (value: unknown) =>
typeof value === 'number'
&& Number.isFinite(value)
&& Math.abs(value) <= 100_000
const openProjectMenuShow: Validator = value => {
if (!isRecord(value) || !hasOnlyKeys(value, ['anchor', 'targets', 'zoom'])) return false
if (!isRecord(value.anchor) || !hasOnlyKeys(value.anchor, ['x', 'y', 'width', 'height'])) return false
if (
!finiteUiCoordinate(value.anchor.x)
|| !finiteUiCoordinate(value.anchor.y)
|| !finiteUiCoordinate(value.anchor.width)
|| !finiteUiCoordinate(value.anchor.height)
|| Number(value.anchor.width) <= 0
|| Number(value.anchor.height) <= 0
) return false
if (typeof value.zoom !== 'number' || !Number.isFinite(value.zoom) || value.zoom < 0.5 || value.zoom > 2) {
return false
}
if (!Array.isArray(value.targets) || value.targets.length < 2 || value.targets.length > 32) return false
return value.targets.every((target) => {
if (!isRecord(target) || !hasOnlyKeys(target, ['id', 'kind', 'label', 'icon', 'iconUrl', 'platform'])) {
return false
}
return typeof target.id === 'string'
&& target.id.length > 0
&& target.id.length <= 120
&& !/[\u0000-\u001f\u007f-\u009f]/.test(target.id)
&& (target.kind === 'ide' || target.kind === 'file_manager')
&& isSafeUiLabel(target.label)
&& typeof target.icon === 'string'
&& target.icon.length <= 120
&& typeof target.platform === 'string'
&& target.platform.length <= 40
&& (
target.iconUrl === undefined
|| (typeof target.iconUrl === 'string' && target.iconUrl.length <= 2_048)
)
})
}
const openProjectMenuRequestId: Validator = value =>
typeof value === 'number'
&& Number.isSafeInteger(value)
&& value > 0
// The colors reach BrowserWindow.setBackgroundColor, so they are pinned to a
// literal 6-digit #RRGGBB. This is load-bearing, not tidiness: that API also
// accepts #AARRGGBB, so an 8-digit value would let a compromised renderer make
@@ -270,11 +224,6 @@ export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
[ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload,
[ELECTRON_IPC_CHANNELS.openProjectMenuShow]: openProjectMenuShow,
[ELECTRON_IPC_CHANNELS.openProjectMenuGetState]: noPayload,
[ELECTRON_IPC_CHANNELS.openProjectMenuSelect]: stringPayload,
[ELECTRON_IPC_CHANNELS.openProjectMenuDismiss]: noPayload,
[ELECTRON_IPC_CHANNELS.openProjectMenuReady]: openProjectMenuRequestId,
[ELECTRON_IPC_CHANNELS.petsList]: noPayload,
[ELECTRON_IPC_CHANNELS.petsCreateFromImage]: petCreateFromAtlas,
[ELECTRON_IPC_CHANNELS.petsCreateFromAtlas]: petCreateFromAtlas,
@@ -349,16 +298,6 @@ const petWindowChannels = new Set<ElectronIpcChannel>([
ELECTRON_IPC_CHANNELS.petsFocusSession,
])
const openProjectMenuWindowChannels = new Set<ElectronIpcChannel>([
ELECTRON_IPC_CHANNELS.appGetLocalePreference,
ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages,
ELECTRON_IPC_CHANNELS.openProjectMenuGetState,
ELECTRON_IPC_CHANNELS.openProjectMenuSelect,
ELECTRON_IPC_CHANNELS.openProjectMenuDismiss,
ELECTRON_IPC_CHANNELS.openProjectMenuReady,
ELECTRON_IPC_CHANNELS.zoomSet,
])
export function isElectronIpcChannel(channel: string): channel is ElectronIpcChannel {
return allowedChannels.has(channel as ElectronIpcChannel)
}
@@ -370,7 +309,3 @@ export function validateElectronIpcPayload(channel: ElectronIpcChannel, payload:
export function isElectronIpcChannelAllowedForPetWindow(channel: ElectronIpcChannel): boolean {
return petWindowChannels.has(channel)
}
export function isElectronIpcChannelAllowedForOpenProjectMenuWindow(channel: ElectronIpcChannel): boolean {
return openProjectMenuWindowChannels.has(channel)
}
-6
View File
@@ -12,11 +12,6 @@ export const ELECTRON_IPC_CHANNELS = {
shellOpen: 'desktop:shell:open',
shellOpenPath: 'desktop:shell:open-path',
traceOpenWindow: 'desktop:trace:open-window',
openProjectMenuShow: 'desktop:open-project-menu:show',
openProjectMenuGetState: 'desktop:open-project-menu:get-state',
openProjectMenuSelect: 'desktop:open-project-menu:select',
openProjectMenuDismiss: 'desktop:open-project-menu:dismiss',
openProjectMenuReady: 'desktop:open-project-menu:ready',
petsList: 'desktop:pets:list',
petsCreateFromImage: 'desktop:pets:create-from-image',
petsCreateFromAtlas: 'desktop:pets:create-from-atlas',
@@ -80,7 +75,6 @@ export const ELECTRON_EVENT_CHANNELS = {
updateDownloadEvent: 'desktop:update:download-event',
windowResized: 'desktop:window:resized',
nativeMenuNavigate: 'desktop:window:native-menu-navigate',
openProjectMenuState: 'desktop:open-project-menu:state',
terminalOutput: 'desktop:terminal:output',
terminalExit: 'desktop:terminal:exit',
previewEvent: 'desktop:preview:event',
+3 -53
View File
@@ -5,7 +5,6 @@ import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANN
import {
isElectronIpcChannel,
isElectronIpcChannelAllowedForPetWindow,
isElectronIpcChannelAllowedForOpenProjectMenuWindow,
validateElectronIpcPayload,
} from './ipc/capabilities'
import { ElectronServerRuntime } from './services/serverRuntime'
@@ -24,7 +23,6 @@ import { ElectronUpdaterService, updaterSessionProxyConfig } from './services/up
import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke'
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
import { ElectronPreviewService, type PreviewBounds } from './services/preview'
import { OpenProjectMenuWindowController } from './services/openProjectMenuWindow'
import {
configureLocalServerRequestAuth,
configurePreviewSessionPermissions,
@@ -66,7 +64,6 @@ import {
writeLocalePreference,
} from './services/localePreference'
import type { Locale } from '../src/i18n/locale'
import type { DesktopOpenProjectMenuInput } from '../src/lib/desktopHost/types'
import {
createCustomPetCatalogLoader,
createCustomPetFromAtlas,
@@ -95,7 +92,6 @@ let serverRuntime: ElectronServerRuntime | null = null
let updaterService: ElectronUpdaterService | null = null
let terminalService: ElectronTerminalService | null = null
let previewService: ElectronPreviewService | null = null
let openProjectMenuWindowController: OpenProjectMenuWindowController | null = null
let petWindowController: PetWindowController | null = null
const traceWindows = new Map<string, BrowserWindow>()
let isQuitting = false
@@ -127,10 +123,6 @@ function petPreloadPath() {
return path.join(appRoot(), 'electron-dist', 'pet-preload.cjs')
}
function openProjectMenuPreloadPath() {
return path.join(appRoot(), 'electron-dist', 'open-project-menu-preload.cjs')
}
function previewAgentPath() {
return path.join(appRoot(), 'src-tauri', 'resources', 'preview-agent.js')
}
@@ -344,20 +336,6 @@ function getPetWindowController() {
return petWindowController
}
function getOpenProjectMenuWindowController() {
openProjectMenuWindowController ??= new OpenProjectMenuWindowController({
createWindow: options => new BrowserWindow(options),
getWorkArea: parent => screen.getDisplayMatching(parent.getContentBounds()).workArea,
preloadPath: openProjectMenuPreloadPath(),
platform: process.platform,
onCreated: window => {
installMainWindowNavigationGuards(window.webContents, { openExternal: openExternalUrl })
},
load: window => loadRendererEntry(window as BrowserWindow, { openProjectMenuWindow: '1' }),
})
return openProjectMenuWindowController
}
const loadCustomPetCatalog = createCustomPetCatalogLoader(() => loadCustomPets({
inspectImageSize: ({ data }) => nativeImage.createFromBuffer(data).getSize(),
}))
@@ -393,12 +371,6 @@ function registerHandler<T>(
) {
throw new Error(`Electron IPC channel ${channel} is not available to the pet window`)
}
if (
openProjectMenuWindowController?.owns(senderWindow) &&
!isElectronIpcChannelAllowedForOpenProjectMenuWindow(channel)
) {
throw new Error(`Electron IPC channel ${channel} is not available to the open-project menu window`)
}
return handler(event, payload)
})
}
@@ -481,25 +453,6 @@ function registerIpcHandlers() {
registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuShow, (event, payload) => {
const window = currentWindow(event)
if (window !== mainWindow) throw new Error('Only the main window can show the open-project menu')
return getOpenProjectMenuWindowController().show(window, payload as DesktopOpenProjectMenuInput)
})
registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuGetState, event =>
getOpenProjectMenuWindowController().getState(currentWindow(event)))
registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuSelect, (event, payload) =>
getOpenProjectMenuWindowController().select(currentWindow(event), String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuDismiss, event => {
const window = currentWindow(event)
const controller = getOpenProjectMenuWindowController()
if (window !== mainWindow && !controller.owns(window)) {
throw new Error('Only the main or open-project menu window can dismiss the menu')
}
controller.dismiss(controller.owns(window) ? window : null)
})
registerHandler(ELECTRON_IPC_CHANNELS.openProjectMenuReady, (event, payload) =>
getOpenProjectMenuWindowController().markReady(currentWindow(event), Number(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.petsList, () => listCustomPets())
registerHandler(ELECTRON_IPC_CHANNELS.petsCreateFromImage, async (event, payload) => {
const input = payload as {
@@ -753,12 +706,9 @@ async function createMainWindow() {
configureLocalServerRequestAuth(
mainWindow.webContents.session.webRequest,
resolveMainRendererServerAccess,
details => (
isAllowlistedMainRendererMediaRequest(details, mainWindow!.webContents.id)
|| (
openProjectMenuWindowController?.ownsWebContentsId(details.webContentsId) === true
&& isAllowlistedMainRendererMediaRequest(details, details.webContentsId!)
)
details => isAllowlistedMainRendererMediaRequest(
details,
mainWindow!.webContents.id,
),
)
installMainWindowNavigationGuards(mainWindow.webContents, { openExternal: openExternalUrl })
@@ -1,73 +0,0 @@
import { contextBridge, ipcRenderer } from 'electron'
import {
isElectronIpcChannelAllowedForOpenProjectMenuWindow,
validateElectronIpcPayload,
} from './ipc/capabilities'
import {
ELECTRON_EVENT_CHANNELS,
ELECTRON_IPC_CHANNELS,
type ElectronIpcChannel,
} from './ipc/channels'
import type {
DesktopHost,
DesktopOpenProjectMenuInput,
DesktopOpenProjectMenuState,
} from '../src/lib/desktopHost/types'
import type { Locale } from '../src/i18n/locale'
function invoke<T>(channel: ElectronIpcChannel, payload?: unknown): Promise<T> {
if (!isElectronIpcChannelAllowedForOpenProjectMenuWindow(channel)) {
return Promise.reject(new Error(`Electron IPC channel ${channel} is not available to the open-project menu`))
}
if (!validateElectronIpcPayload(channel, payload)) {
return Promise.reject(new Error(`Invalid Electron IPC payload for ${channel}`))
}
return ipcRenderer.invoke(channel, payload) as Promise<T>
}
const openProjectMenuHost = {
kind: 'electron',
isDesktop: true,
capabilities: {
appMode: false,
clipboard: false,
dialogs: false,
notifications: false,
previewWebview: false,
shell: false,
terminal: false,
updates: false,
windowControls: false,
zoom: true,
},
app: {
getLocalePreference: () => invoke<Locale | null>(ELECTRON_IPC_CHANNELS.appGetLocalePreference),
getPreferredSystemLanguages: () => invoke<string[]>(ELECTRON_IPC_CHANNELS.appGetPreferredSystemLanguages),
onLocaleChanged: (handler: (locale: Locale) => void) => {
const listener = (_event: Electron.IpcRendererEvent, locale: Locale) => handler(locale)
ipcRenderer.on(ELECTRON_EVENT_CHANNELS.appLocaleChanged, listener)
return Promise.resolve(() => ipcRenderer.removeListener(ELECTRON_EVENT_CHANNELS.appLocaleChanged, listener))
},
},
openProjectMenu: {
show: (_input: DesktopOpenProjectMenuInput) => Promise.reject(new Error('Nested open-project menus are unavailable')),
getState: () => invoke<DesktopOpenProjectMenuState | null>(ELECTRON_IPC_CHANNELS.openProjectMenuGetState),
select: (targetId: string) => invoke<void>(ELECTRON_IPC_CHANNELS.openProjectMenuSelect, targetId),
dismiss: () => invoke<void>(ELECTRON_IPC_CHANNELS.openProjectMenuDismiss),
ready: (requestId: number) => invoke<void>(ELECTRON_IPC_CHANNELS.openProjectMenuReady, requestId),
onState: (handler: (state: DesktopOpenProjectMenuState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: DesktopOpenProjectMenuState) => handler(state)
ipcRenderer.on(ELECTRON_EVENT_CHANNELS.openProjectMenuState, listener)
return Promise.resolve(() => ipcRenderer.removeListener(ELECTRON_EVENT_CHANNELS.openProjectMenuState, listener))
},
},
zoom: {
set: (level: number) => invoke<void>(ELECTRON_IPC_CHANNELS.zoomSet, level),
},
appearance: {
setApplied: () => Promise.resolve(),
},
} as unknown as DesktopHost
contextBridge.exposeInMainWorld('desktopHost', openProjectMenuHost)
@@ -1,160 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
OPEN_PROJECT_MENU_PANEL_WIDTH,
OpenProjectMenuWindowController,
openProjectMenuWindowBounds,
openProjectMenuWindowOptions,
} from './openProjectMenuWindow'
const input = {
anchor: { x: 700, y: 10, width: 50, height: 32 },
targets: [
{ id: 'vscode', kind: 'ide' as const, label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager' as const, label: 'Finder', icon: 'finder', platform: 'darwin' },
],
zoom: 1,
}
describe('open project menu native window', () => {
it('creates a transparent child window above the parent', () => {
const parent = { id: 'parent' }
expect(openProjectMenuWindowOptions({
parent: parent as never,
preload: '/app/open-project-menu-preload.cjs',
platform: 'darwin',
})).toMatchObject({
parent,
width: 1,
height: 1,
frame: false,
transparent: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
hasShadow: false,
show: false,
type: 'panel',
webPreferences: {
preload: '/app/open-project-menu-preload.cjs',
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
})
it('aligns the reused React panel to the trigger while leaving room for its shadow', () => {
expect(openProjectMenuWindowBounds({
anchor: { x: 1200, y: 10, width: 50, height: 32 },
contentBounds: { x: 100, y: 50, width: 1400, height: 900 },
itemCount: 7,
workArea: { x: 0, y: 0, width: 1600, height: 1000 },
zoom: 1,
})).toEqual({
x: 1114,
y: 82,
width: OPEN_PROJECT_MENU_PANEL_WIDTH + 32,
height: 378,
})
})
it('flips above the trigger when the native window would leave the work area', () => {
const bounds = openProjectMenuWindowBounds({
anchor: { x: 700, y: 710, width: 50, height: 32 },
contentBounds: { x: 0, y: 0, width: 1000, height: 800 },
itemCount: 7,
workArea: { x: 0, y: 0, width: 1000, height: 800 },
zoom: 1,
})
expect(bounds.x).toBe(514)
expect(bounds.y).toBe(342)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(800)
})
it('scales the window and anchor geometry with the app zoom', () => {
const bounds = openProjectMenuWindowBounds({
anchor: { x: 400, y: 10, width: 25, height: 16 },
contentBounds: { x: 100, y: 50, width: 1400, height: 900 },
itemCount: 2,
workArea: { x: 0, y: 0, width: 1600, height: 1000 },
zoom: 2,
})
expect(bounds).toEqual({
x: 478,
y: 82,
width: 504,
height: 276,
})
})
it('shows only after the shared renderer reports ready and resolves the selected target', async () => {
const popup = new FakePopupWindow()
const controller = createController(popup)
const parent = { getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 800 }) }
const selection = controller.show(parent as never, input)
await vi.waitFor(() => expect(popup.webContents.send).toHaveBeenCalled())
const state = controller.getState(popup as never)
expect(state?.targets).toEqual(input.targets)
expect(popup.show).not.toHaveBeenCalled()
controller.markReady(popup as never, state!.requestId)
await vi.waitFor(() => expect(popup.show).toHaveBeenCalledTimes(1))
controller.select(popup as never, 'finder')
await expect(selection).resolves.toBe('finder')
expect(popup.hide).toHaveBeenCalled()
})
it('dismisses the pending menu when the native popup loses focus', async () => {
const popup = new FakePopupWindow()
const controller = createController(popup)
const parent = { getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 800 }) }
const selection = controller.show(parent as never, input)
await vi.waitFor(() => expect(popup.webContents.send).toHaveBeenCalled())
const state = controller.getState(popup as never)
controller.markReady(popup as never, state!.requestId)
await vi.waitFor(() => expect(popup.show).toHaveBeenCalled())
popup.emit('blur')
await expect(selection).resolves.toBeNull()
})
})
function createController(popup: FakePopupWindow) {
return new OpenProjectMenuWindowController({
createWindow: () => popup as never,
getWorkArea: () => ({ x: 0, y: 0, width: 1000, height: 800 }),
load: async () => {},
platform: 'darwin',
preloadPath: '/app/open-project-menu-preload.cjs',
})
}
class FakePopupWindow {
readonly listeners = new Map<string, Array<() => void>>()
readonly webContents = {
id: 42,
send: vi.fn(),
}
readonly focus = vi.fn()
readonly hide = vi.fn()
readonly setBounds = vi.fn()
readonly show = vi.fn()
isDestroyed() { return false }
on(event: string, listener: () => void) {
const listeners = this.listeners.get(event) ?? []
listeners.push(listener)
this.listeners.set(event, listeners)
}
emit(event: string) {
for (const listener of this.listeners.get(event) ?? []) listener()
}
}
@@ -1,301 +0,0 @@
import type { BrowserWindowConstructorOptions, Rectangle } from 'electron'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import type {
DesktopOpenProjectMenuInput,
DesktopOpenProjectMenuState,
} from '../../src/lib/desktopHost/types'
export const OPEN_PROJECT_MENU_PANEL_WIDTH = 220
export const OPEN_PROJECT_MENU_ROW_HEIGHT = 48
const PANEL_VERTICAL_CHROME = 10
const WINDOW_PADDING = 16
const ANCHOR_GAP = 6
const READY_TIMEOUT_MS = 3_000
type ParentWindow = {
getContentBounds(): Rectangle
}
type PopupWebContents = {
id: number
isDestroyed?(): boolean
send(channel: string, payload: unknown): void
}
type PopupWindow = {
webContents: PopupWebContents
focus(): void
hide(): void
isDestroyed(): boolean
on(event: 'blur' | 'closed', listener: () => void): void
setBounds(bounds: Rectangle): void
show(): void
}
export type OpenProjectMenuWindowControllerOptions = {
createWindow(options: BrowserWindowConstructorOptions): PopupWindow
getWorkArea(parent: ParentWindow): Rectangle
load(window: PopupWindow): Promise<void>
onCreated?(window: PopupWindow): void
platform?: NodeJS.Platform
preloadPath: string
}
export function openProjectMenuWindowOptions({
parent,
preload,
platform = process.platform,
}: {
parent: BrowserWindowConstructorOptions['parent']
preload: string
platform?: NodeJS.Platform
}): BrowserWindowConstructorOptions {
return {
parent,
width: 1,
height: 1,
autoHideMenuBar: true,
backgroundColor: '#00000000',
frame: false,
fullscreenable: false,
hasShadow: false,
maximizable: false,
minimizable: false,
movable: false,
resizable: false,
show: false,
skipTaskbar: true,
transparent: true,
type: platform === 'darwin' ? 'panel' : undefined,
webPreferences: {
preload,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
}
}
export function openProjectMenuWindowBounds({
anchor,
contentBounds,
itemCount,
workArea,
zoom,
}: {
anchor: DesktopOpenProjectMenuInput['anchor']
contentBounds: Rectangle
itemCount: number
workArea: Rectangle
zoom: number
}): Rectangle {
const scale = Math.min(2, Math.max(0.5, zoom))
const padding = WINDOW_PADDING * scale
const panelWidth = OPEN_PROJECT_MENU_PANEL_WIDTH * scale
const panelHeight = (itemCount * OPEN_PROJECT_MENU_ROW_HEIGHT + PANEL_VERTICAL_CHROME) * scale
const width = panelWidth + padding * 2
const height = panelHeight + padding * 2
const anchorLeft = contentBounds.x + anchor.x * scale
const anchorTop = contentBounds.y + anchor.y * scale
const anchorRight = anchorLeft + anchor.width * scale
const anchorBottom = anchorTop + anchor.height * scale
const preferredX = anchorRight - panelWidth - padding
const preferredBelow = anchorBottom + ANCHOR_GAP * scale - padding
const preferredAbove = anchorTop - ANCHOR_GAP * scale - panelHeight - padding
const maxX = workArea.x + workArea.width - width
const maxY = workArea.y + workArea.height - height
const x = clamp(preferredX, workArea.x, maxX)
const y = preferredBelow + height <= workArea.y + workArea.height
? preferredBelow
: preferredAbove >= workArea.y
? preferredAbove
: clamp(preferredBelow, workArea.y, maxY)
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
}
}
export class OpenProjectMenuWindowController {
private readonly options: OpenProjectMenuWindowControllerOptions
private window: PopupWindow | null = null
private loadPromise: Promise<void> | null = null
private state: DesktopOpenProjectMenuState | null = null
private nextRequestId = 0
private result: {
requestId: number
resolve(value: string | null): void
reject(reason: unknown): void
} | null = null
private ready: {
requestId: number
resolve(): void
reject(reason: unknown): void
timeout: ReturnType<typeof setTimeout>
} | null = null
private readyRequestId: number | null = null
private visible = false
constructor(options: OpenProjectMenuWindowControllerOptions) {
this.options = options
}
owns(window: PopupWindow | null | undefined): boolean {
return !!window && window === this.window
}
ownsWebContentsId(id: number | undefined): boolean {
return typeof id === 'number' && id === this.window?.webContents.id
}
getState(window: PopupWindow): DesktopOpenProjectMenuState | null {
this.assertOwner(window)
return this.state
}
async show(
parent: ParentWindow & BrowserWindowConstructorOptions['parent'],
input: DesktopOpenProjectMenuInput,
): Promise<string | null> {
this.finish(null)
const requestId = ++this.nextRequestId
this.readyRequestId = null
this.state = { requestId, targets: input.targets }
const popup = this.ensureWindow(parent)
const resultPromise = new Promise<string | null>((resolve, reject) => {
this.result = { requestId, resolve, reject }
})
try {
await this.loadPromise
if (this.result?.requestId !== requestId) return await resultPromise
popup.setBounds(openProjectMenuWindowBounds({
anchor: input.anchor,
contentBounds: parent.getContentBounds(),
itemCount: input.targets.length,
workArea: this.options.getWorkArea(parent),
zoom: input.zoom,
}))
popup.webContents.send(ELECTRON_EVENT_CHANNELS.openProjectMenuState, this.state)
await this.waitUntilReady(requestId)
if (this.result?.requestId !== requestId) return await resultPromise
this.visible = true
popup.show()
popup.focus()
return await resultPromise
} catch (error) {
if (this.result?.requestId !== requestId) return await resultPromise
if (this.result?.requestId === requestId) {
this.result = null
this.state = null
this.visible = false
popup.hide()
}
throw error
}
}
markReady(window: PopupWindow, requestId: number): void {
this.assertOwner(window)
if (this.state?.requestId === requestId) this.readyRequestId = requestId
if (this.ready?.requestId !== requestId) return
clearTimeout(this.ready.timeout)
const { resolve } = this.ready
this.ready = null
resolve()
}
select(window: PopupWindow, targetId: string): void {
this.assertOwner(window)
if (!this.state?.targets.some((target) => target.id === targetId)) {
throw new Error('Unknown open-project target')
}
this.finish(targetId)
}
dismiss(window?: PopupWindow | null): void {
if (window && !this.owns(window)) throw new Error('Open-project menu window does not own this request')
this.finish(null)
}
private ensureWindow(parent: ParentWindow & BrowserWindowConstructorOptions['parent']): PopupWindow {
if (this.window && !this.window.isDestroyed()) return this.window
const popup = this.options.createWindow(openProjectMenuWindowOptions({
parent,
preload: this.options.preloadPath,
platform: this.options.platform,
}))
this.window = popup
popup.on('blur', () => {
if (this.visible) this.finish(null)
})
popup.on('closed', () => {
if (this.window !== popup) return
this.rejectPending(new Error('Open-project menu window closed'))
this.window = null
this.loadPromise = null
})
this.options.onCreated?.(popup)
this.loadPromise = this.options.load(popup)
return popup
}
private waitUntilReady(requestId: number): Promise<void> {
if (this.readyRequestId === requestId) return Promise.resolve()
this.ready?.reject(new Error('Open-project menu state was replaced'))
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.ready?.requestId !== requestId) return
this.ready = null
reject(new Error('Open-project menu renderer did not become ready'))
}, READY_TIMEOUT_MS)
this.ready = { requestId, resolve, reject, timeout }
})
}
private finish(value: string | null): void {
const pending = this.result
this.result = null
this.state = null
this.visible = false
if (this.ready) {
clearTimeout(this.ready.timeout)
this.ready.reject(new Error('Open-project menu dismissed before it became ready'))
this.ready = null
}
if (this.window && !this.window.isDestroyed()) this.window.hide()
pending?.resolve(value)
}
private rejectPending(error: Error): void {
const pending = this.result
this.result = null
this.state = null
this.visible = false
if (this.ready) {
clearTimeout(this.ready.timeout)
this.ready.reject(error)
this.ready = null
}
pending?.reject(error)
}
private assertOwner(window: PopupWindow): void {
if (!this.owns(window)) throw new Error('IPC sender is not the open-project menu window')
}
}
function clamp(value: number, min: number, max: number): number {
if (max < min) return min
return Math.min(max, Math.max(min, value))
}
+1 -1
View File
@@ -80,7 +80,7 @@
"dev": "vite",
"build": "bun run build:preview-agent && node ./node_modules/typescript/bin/tsc -b && node ./node_modules/vite/bin/vite.js build",
"prepare:node-pty": "bun run ./scripts/prepare-node-pty.ts",
"build:electron": "bun run prepare:node-pty && bun build ./electron/main.ts --outfile ./electron-dist/main.cjs --target node --format cjs --external electron --external node-pty && bun build ./electron/preload.ts --outfile ./electron-dist/preload.cjs --target node --format cjs --external electron && bun build ./electron/pet-preload.ts --outfile ./electron-dist/pet-preload.cjs --target node --format cjs --external electron && bun build ./electron/open-project-menu-preload.ts --outfile ./electron-dist/open-project-menu-preload.cjs --target node --format cjs --external electron && bun build ./electron/preview-preload.ts --outfile ./electron-dist/preview-preload.cjs --target node --format cjs --external electron",
"build:electron": "bun run prepare:node-pty && bun build ./electron/main.ts --outfile ./electron-dist/main.cjs --target node --format cjs --external electron --external node-pty && bun build ./electron/preload.ts --outfile ./electron-dist/preload.cjs --target node --format cjs --external electron && bun build ./electron/pet-preload.ts --outfile ./electron-dist/pet-preload.cjs --target node --format cjs --external electron && bun build ./electron/preview-preload.ts --outfile ./electron-dist/preview-preload.cjs --target node --format cjs --external electron",
"build:preview-agent": "bun run ./scripts/build-preview-agent.ts",
"prepare:ripgrep": "bun run ./scripts/prepare-ripgrep.ts",
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
@@ -51,12 +51,9 @@ vi.mock('../../stores/openTargetStore', () => ({
import { OpenProjectMenu } from './OpenProjectMenu'
import { useOverlayStore } from '../../stores/overlayStore'
import { browserHost } from '../../lib/desktopHost/browserHost'
import type { DesktopHost } from '../../lib/desktopHost/types'
describe('OpenProjectMenu', () => {
beforeEach(() => {
delete window.desktopHost
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
storeMocks.ensureTargets.mockReset()
storeMocks.openTarget.mockReset()
@@ -69,7 +66,6 @@ describe('OpenProjectMenu', () => {
})
afterEach(() => {
delete window.desktopHost
vi.restoreAllMocks()
})
@@ -114,59 +110,7 @@ describe('OpenProjectMenu', () => {
expect(storeMocks.openTarget).toHaveBeenCalledWith('finder', '/repo')
})
it('uses the native popup window in Electron without hiding the browser preview', async () => {
storeMocks.state.targets = [
{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
]
storeMocks.state.primaryTargetId = 'vscode'
storeMocks.openTarget.mockResolvedValue(undefined)
let resolveSelection: (targetId: string | null) => void = () => {}
const show = vi.fn(() => new Promise<string | null>((resolve) => {
resolveSelection = resolve
}))
const dismiss = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
openProjectMenu: {
...browserHost.openProjectMenu,
show,
dismiss,
},
} as DesktopHost
render(<OpenProjectMenu path="/repo" />)
const trigger = screen.getByRole('button', { name: 'Open project' })
trigger.getBoundingClientRect = () => domRect({
top: 8,
right: 944,
bottom: 40,
left: 900,
width: 44,
height: 32,
})
await act(async () => {
fireEvent.click(trigger)
})
expect(show).toHaveBeenCalledWith(expect.objectContaining({
anchor: { x: 900, y: 8, width: 44, height: 32 },
targets: storeMocks.state.targets,
}))
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(useOverlayStore.getState().count).toBe(0)
await act(async () => {
resolveSelection('finder')
await Promise.resolve()
})
await waitFor(() => expect(storeMocks.openTarget).toHaveBeenCalledWith('finder', '/repo'))
})
it('keeps the browser preview visible and positions the DOM fallback outside it', async () => {
it('keeps the native browser preview visible and positions the menu outside it', async () => {
storeMocks.state.targets = [
{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
@@ -6,9 +6,6 @@ import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useAnchoredPosition } from '@/hooks/useAnchoredPosition'
import { useDismissable } from '@/hooks/useDismissable'
import { TargetIcon } from '@/components/composite/TargetIcon'
import { getDesktopHost } from '@/lib/desktopHost'
import { useSettingsStore } from '@/stores/settingsStore'
import { OpenProjectMenuPanel } from './OpenProjectMenuPanel'
type Props = {
path: string | null | undefined
@@ -20,14 +17,10 @@ export function OpenProjectMenu({ path }: Props) {
const primaryTargetId = useOpenTargetStore((state) => state.primaryTargetId)
const ensureTargets = useOpenTargetStore((state) => state.ensureTargets)
const openTarget = useOpenTargetStore((state) => state.openTarget)
const appZoom = useSettingsStore((state) => state.uiZoom)
const [open, setOpen] = useState(false)
const [nativeMenuFailed, setNativeMenuFailed] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const browserPreviewRef = useRef<HTMLElement | null>(null)
const desktopHost = getDesktopHost()
const useNativeMenu = desktopHost.kind === 'electron' && !nativeMenuFailed
useEffect(() => {
if (!path) {
@@ -37,14 +30,10 @@ export function OpenProjectMenu({ path }: Props) {
void ensureTargets()
}, [ensureTargets, path])
useEffect(() => () => {
if (desktopHost.kind === 'electron') void desktopHost.openProjectMenu.dismiss()
}, [desktopHost])
const handleDismiss = useCallback(() => setOpen(false), [])
useDismissable({
open: open && !useNativeMenu,
open,
refs: [menuRef],
triggerRef: buttonRef,
onDismiss: handleDismiss,
@@ -63,7 +52,7 @@ export function OpenProjectMenu({ path }: Props) {
}, [open])
const { style: menuPosition } = useAnchoredPosition({
open: open && hasMenu && !useNativeMenu,
open: open && hasMenu,
anchorRef: buttonRef,
floatingRef: menuRef,
avoidRef: browserPreviewRef,
@@ -81,45 +70,6 @@ export function OpenProjectMenu({ path }: Props) {
}
}
const handleMenuToggle = () => {
if (!hasMenu) {
void handleOpenTarget(primaryTarget!.id)
return
}
if (!useNativeMenu) {
setOpen((value) => !value)
return
}
if (open) {
setOpen(false)
void desktopHost.openProjectMenu.dismiss()
return
}
const anchor = buttonRef.current?.getBoundingClientRect()
if (!anchor) return
setOpen(true)
void desktopHost.openProjectMenu.show({
anchor: {
x: anchor.left,
y: anchor.top,
width: anchor.width,
height: anchor.height,
},
targets,
zoom: appZoom,
}).then((targetId) => {
setOpen(false)
if (targetId) void handleOpenTarget(targetId)
}).catch(() => {
// Keep the in-page collision-avoiding menu as a runtime fallback.
setNativeMenuFailed(true)
setOpen(true)
})
}
if (!path || !primaryTarget) return null
const buttonLabel = hasMenu
@@ -135,7 +85,13 @@ export function OpenProjectMenu({ path }: Props) {
aria-haspopup={hasMenu ? 'menu' : undefined}
aria-expanded={hasMenu ? open : undefined}
title={buttonLabel}
onClick={handleMenuToggle}
onClick={() => {
if (hasMenu) {
setOpen((value) => !value)
return
}
void handleOpenTarget(primaryTarget.id)
}}
className={`inline-flex h-8 items-center justify-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-tertiary)] transition-[background-color,color,border-color,box-shadow] duration-150 ease-out hover:border-[var(--color-outline)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] ${
hasMenu
? 'min-w-[2.75rem] px-2 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
@@ -146,14 +102,28 @@ export function OpenProjectMenu({ path }: Props) {
{hasMenu && <ChevronDown size={14} strokeWidth={1.9} />}
</button>
{open && hasMenu && !useNativeMenu ? createPortal(
<OpenProjectMenuPanel
{open && hasMenu ? createPortal(
<div
ref={menuRef}
targets={targets}
onSelect={(targetId) => void handleOpenTarget(targetId)}
className="fixed z-[var(--z-dropdown)]"
role="menu"
className="glass-panel fixed z-[var(--z-dropdown)] min-w-[220px] overflow-hidden rounded-[var(--radius-lg)] py-1"
style={menuPosition}
/>,
>
{targets.map((target) => (
<button
key={target.id}
type="button"
role="menuitem"
onClick={() => void handleOpenTarget(target.id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
>
<span className="flex h-7 w-7 items-center justify-center text-[var(--color-text-secondary)]">
<TargetIcon target={target} size={24} />
</span>
<span className="min-w-0 truncate">{target.label}</span>
</button>
))}
</div>,
document.body,
) : null}
</div>
@@ -1,46 +0,0 @@
import { forwardRef, type CSSProperties } from 'react'
import { TargetIcon } from '@/components/composite/TargetIcon'
import type { OpenTarget } from '@/stores/openTargetStore'
type Props = {
targets: OpenTarget[]
onSelect(targetId: string): void
className?: string
style?: CSSProperties
autoFocusFirst?: boolean
}
/** Shared markup for the in-page fallback and the native popup window. */
export const OpenProjectMenuPanel = forwardRef<HTMLDivElement, Props>(function OpenProjectMenuPanel({
targets,
onSelect,
className = '',
style,
autoFocusFirst = false,
}, ref) {
return (
<div
ref={ref}
role="menu"
className={`glass-panel min-w-[220px] overflow-hidden rounded-[var(--radius-lg)] py-1 ${className}`}
style={style}
>
{targets.map((target, index) => (
<button
key={target.id}
type="button"
role="menuitem"
autoFocus={autoFocusFirst && index === 0}
onClick={() => onSelect(target.id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
>
<span className="flex h-7 w-7 items-center justify-center text-[var(--color-text-secondary)]">
<TargetIcon target={target} size={24} />
</span>
<span className="min-w-0 truncate">{target.label}</span>
</button>
))}
</div>
)
})
@@ -1,66 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { browserHost } from '@/lib/desktopHost/browserHost'
import type { DesktopHost, DesktopOpenProjectMenuState } from '@/lib/desktopHost/types'
import { OpenProjectMenuWindow } from './OpenProjectMenuWindow'
const state: DesktopOpenProjectMenuState = {
requestId: 7,
targets: [
{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
],
}
describe('OpenProjectMenuWindow', () => {
afterEach(() => {
delete window.desktopHost
vi.restoreAllMocks()
})
it('reuses the exact project-menu panel and reports readiness to the native host', async () => {
const ready = vi.fn().mockResolvedValue(undefined)
const select = vi.fn().mockResolvedValue(undefined)
window.desktopHost = createHost({ ready, select })
render(<OpenProjectMenuWindow />)
const menu = await screen.findByRole('menu')
expect(menu).toHaveClass('glass-panel', 'min-w-[220px]', 'rounded-[var(--radius-lg)]', 'py-1')
expect(screen.getByRole('menuitem', { name: 'VS Code' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Finder' })).toBeInTheDocument()
await waitFor(() => expect(ready).toHaveBeenCalledWith(7))
fireEvent.click(screen.getByRole('menuitem', { name: 'Finder' }))
expect(select).toHaveBeenCalledWith('finder')
})
it('dismisses the native window on Escape', async () => {
const dismiss = vi.fn().mockResolvedValue(undefined)
window.desktopHost = createHost({ dismiss })
render(<OpenProjectMenuWindow />)
await screen.findByRole('menu')
fireEvent.keyDown(document, { key: 'Escape' })
expect(dismiss).toHaveBeenCalled()
})
})
function createHost(overrides: Partial<DesktopHost['openProjectMenu']>): DesktopHost {
return {
...browserHost,
kind: 'electron',
isDesktop: true,
openProjectMenu: {
...browserHost.openProjectMenu,
getState: vi.fn().mockResolvedValue(state),
onState: vi.fn().mockResolvedValue(() => {}),
ready: vi.fn().mockResolvedValue(undefined),
select: vi.fn().mockResolvedValue(undefined),
...overrides,
},
}
}
@@ -1,54 +0,0 @@
import { useEffect, useLayoutEffect, useState } from 'react'
import { getDesktopHost } from '@/lib/desktopHost'
import type { DesktopOpenProjectMenuState } from '@/lib/desktopHost/types'
import { OpenProjectMenuPanel } from './OpenProjectMenuPanel'
export function OpenProjectMenuWindow() {
const [state, setState] = useState<DesktopOpenProjectMenuState | null>(null)
useEffect(() => {
const host = getDesktopHost().openProjectMenu
let cancelled = false
let unlisten: (() => void) | undefined
void host.onState((next) => setState(next)).then((stop) => {
if (cancelled) stop()
else unlisten = stop
})
void host.getState().then((initial) => {
if (!cancelled && initial) setState(initial)
})
return () => {
cancelled = true
unlisten?.()
}
}, [])
useEffect(() => {
const dismiss = (event: KeyboardEvent) => {
if (event.key === 'Escape') void getDesktopHost().openProjectMenu.dismiss()
}
document.addEventListener('keydown', dismiss)
return () => document.removeEventListener('keydown', dismiss)
}, [])
useLayoutEffect(() => {
if (!state) return
void getDesktopHost().openProjectMenu.ready(state.requestId)
}, [state])
if (!state) return null
return (
<main className="h-full w-full bg-transparent p-4" data-testid="open-project-menu-window">
<OpenProjectMenuPanel
key={state.requestId}
targets={state.targets}
autoFocusFirst
onSelect={(targetId) => void getDesktopHost().openProjectMenu.select(targetId)}
/>
</main>
)
}
@@ -122,24 +122,6 @@ export const browserHost: DesktopHost = {
unsupported('Opening trace windows')
},
},
openProjectMenu: {
async show() {
return null
},
async getState() {
return null
},
async select() {
unsupported('Native open-project menu')
},
async dismiss() {},
async ready() {
unsupported('Native open-project menu')
},
async onState() {
return noopUnlisten
},
},
pets: {
async list() {
unsupported('Custom pets')
@@ -131,27 +131,6 @@ describe('electron desktop host', () => {
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.traceOpenWindow, 'session-123')
})
it('routes the native open-project popup through its dedicated IPC contract', async () => {
const invoke = vi.fn().mockResolvedValue('finder')
const subscribe = vi.fn().mockResolvedValue(() => {})
const host = createElectronHost({ invoke, subscribe })
const input = {
anchor: { x: 900, y: 8, width: 44, height: 32 },
targets: [
{ id: 'vscode', kind: 'ide' as const, label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager' as const, label: 'Finder', icon: 'finder', platform: 'darwin' },
],
zoom: 1,
}
const onState = vi.fn()
await expect(host.openProjectMenu.show(input)).resolves.toBe('finder')
await host.openProjectMenu.onState(onState)
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.openProjectMenuShow, input)
expect(subscribe).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.openProjectMenuState, onState)
})
it('routes preview zoom through the preview IPC channel', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
const host = createElectronHost({
@@ -113,14 +113,6 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
trace: {
openWindow: sessionId => invoke(ELECTRON_IPC_CHANNELS.traceOpenWindow, sessionId),
},
openProjectMenu: {
show: input => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuShow, input),
getState: () => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuGetState),
select: targetId => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuSelect, targetId),
dismiss: () => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuDismiss),
ready: requestId => invoke(ELECTRON_IPC_CHANNELS.openProjectMenuReady, requestId),
onState: handler => subscribe(ELECTRON_EVENT_CHANNELS.openProjectMenuState, handler),
},
pets: {
list: () => invoke(ELECTRON_IPC_CHANNELS.petsList),
createFromImage: input => invoke(ELECTRON_IPC_CHANNELS.petsCreateFromImage, input),
-33
View File
@@ -136,31 +136,6 @@ export type PreviewBounds = {
height: number
}
export type DesktopOpenProjectTarget = {
id: string
kind: 'ide' | 'file_manager'
label: string
icon: string
iconUrl?: string
platform: string
}
export type DesktopOpenProjectMenuInput = {
anchor: {
x: number
y: number
width: number
height: number
}
targets: DesktopOpenProjectTarget[]
zoom: number
}
export type DesktopOpenProjectMenuState = {
requestId: number
targets: DesktopOpenProjectTarget[]
}
export type PreviewEvent = {
type: string
payload?: unknown
@@ -328,14 +303,6 @@ export type DesktopHost = {
trace?: {
openWindow(sessionId: string): Promise<void>
}
openProjectMenu: {
show(input: DesktopOpenProjectMenuInput): Promise<string | null>
getState(): Promise<DesktopOpenProjectMenuState | null>
select(targetId: string): Promise<void>
dismiss(): Promise<void>
ready(requestId: number): Promise<void>
onState(handler: (state: DesktopOpenProjectMenuState) => void): Promise<DesktopHostUnlisten>
}
pets: {
list(): Promise<DesktopPetListResult>
createFromImage(input: DesktopPetCreateInput): Promise<DesktopPetCreateResult | null>
+2 -5
View File
@@ -53,15 +53,12 @@ describe('desktop bootstrap', () => {
expect(window.__CC_HAHA_BOOTSTRAPPED__).toBe(true)
})
it('recognizes dedicated auxiliary window queries', async () => {
const { isOpenProjectMenuWindowLocation, isPetWindowLocation } = await import('./main')
it('recognizes only the dedicated pet window query', async () => {
const { isPetWindowLocation } = await import('./main')
expect(isPetWindowLocation('?petWindow=1')).toBe(true)
expect(isPetWindowLocation('?petWindow=0')).toBe(false)
expect(isPetWindowLocation('?traceWindow=1')).toBe(false)
expect(isOpenProjectMenuWindowLocation('?openProjectMenuWindow=1')).toBe(true)
expect(isOpenProjectMenuWindowLocation('?openProjectMenuWindow=0')).toBe(false)
expect(isOpenProjectMenuWindowLocation('?petWindow=1')).toBe(false)
})
it('surfaces bootstrap failures in the root element', async () => {
+5 -16
View File
@@ -26,16 +26,10 @@ export function isPetWindowLocation(search = window.location.search): boolean {
return new URLSearchParams(search).get('petWindow') === '1'
}
export function isOpenProjectMenuWindowLocation(search = window.location.search): boolean {
return new URLSearchParams(search).get('openProjectMenuWindow') === '1'
}
function loadDesktopBootstrapModules() {
const appModule = isOpenProjectMenuWindowLocation()
? import('./components/layout/OpenProjectMenuWindow').then(({ OpenProjectMenuWindow }) => ({ App: OpenProjectMenuWindow }))
: isPetWindowLocation()
? import('./features/pets/PetApp').then(({ PetApp }) => ({ App: PetApp }))
: import('./App')
const appModule = isPetWindowLocation()
? import('./features/pets/PetApp').then(({ PetApp }) => ({ App: PetApp }))
: import('./App')
return Promise.all([
appModule,
import('./components/ErrorBoundary'),
@@ -46,8 +40,6 @@ function loadDesktopBootstrapModules() {
if (isPetWindowLocation()) {
document.documentElement.dataset.windowKind = 'pet'
} else if (isOpenProjectMenuWindowLocation()) {
document.documentElement.dataset.windowKind = 'open-project-menu'
}
export async function bootstrapDesktopApp(
@@ -86,9 +78,6 @@ export async function bootstrapDesktopApp(
runDesktopPersistenceMigrations()
initializeTouchH5()
void initializeAppZoom()
void (async () => {
if (isOpenProjectMenuWindowLocation()) await initializeAppZoom()
else void initializeAppZoom()
await bootstrapDesktopApp()
})()
void bootstrapDesktopApp()
-7
View File
@@ -2148,13 +2148,6 @@ html[data-window-kind='pet'] #root {
overflow: hidden;
}
html[data-window-kind='open-project-menu'],
html[data-window-kind='open-project-menu'] body,
html[data-window-kind='open-project-menu'] #root {
background: transparent !important;
overflow: hidden;
}
.pet-window-root {
position: relative;
box-sizing: border-box;