mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): shift project menu around browser preview
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user