diff --git a/packages/devtools/docs/ios-safe-area-and-notch.md b/packages/devtools/docs/ios-safe-area-and-notch.md index abc0d5a0..8cbc0ce6 100644 --- a/packages/devtools/docs/ios-safe-area-and-notch.md +++ b/packages/devtools/docs/ios-safe-area-and-notch.md @@ -14,7 +14,7 @@ devtools 只用其中几个入口: | 入口 | 用途 | |---|---| -| `CLASSIC_DEVICES` | 工具栏设备下拉只列这份精选子集(≤20 台,同一批对象,按 iOS → Android → HarmonyOS 分组) | +| `DEVICES` | 工具栏的设备选择面板列出整张表,按 iOS → Android → HarmonyOS 分组,靠搜索和机型/尺寸筛选缩小范围 | | `findDevice(name)` / `DEFAULT_DEVICE` | 按名字回查机型;找不到时回落到默认机型 | | `resolveDevice` / `statusBarHeightFor` / `safeAreaInsetsFor` | 按当前横竖屏解析出已经旋转过的数值 | | `PLATFORM_DEFAULTS` | 第一份设备信息到达前,DeviceShell 用平台默认状态栏高度占位 | diff --git a/packages/devtools/e2e/device-frame-integration.spec.ts b/packages/devtools/e2e/device-frame-integration.spec.ts index 86a16d65..dda0e342 100644 --- a/packages/devtools/e2e/device-frame-integration.spec.ts +++ b/packages/devtools/e2e/device-frame-integration.spec.ts @@ -33,6 +33,8 @@ import { findMainWindow, installConsoleCollector, readConsoleErrors, + devicePickerToolbarButton, + selectDeviceInPicker, } from './helpers' import { AutomationChannel } from '../src/shared/ipc-channels' import { @@ -71,8 +73,7 @@ function expectedLayoutMode(deviceName: string, orientation: Orientation): strin // ── Toolbar driving ──────────────────────────────────────────────────── async function selectDevice(win: PwPage, deviceName: string): Promise { - const sel = win.locator('select', { has: win.locator(`option[value="${deviceName}"]`) }).first() - await sel.selectOption(deviceName) + await selectDeviceInPicker(win, electronApp, deviceName) } async function selectOrientation(win: PwPage, orientation: Orientation): Promise { @@ -209,8 +210,8 @@ test.describe('device-frame integration e2e', () => { test('1. boots with device-frame reflecting the toolbar default device', async () => { // Fresh per-run userDataDir (mkdir'd above under this process's pid) — no // persisted device setting to override the boot default. - const deviceSelect = workbench.locator('select', { has: workbench.locator(`option[value="${DEVICE_NAMES.iPhone_X}"]`) }).first() - const toolbarDevice = await deviceSelect.inputValue() + // The toolbar button's label IS the selected device's name. + const toolbarDevice = (await devicePickerToolbarButton(workbench).innerText()).trim() const snap = await pollUntil( () => readFrameSnapshot(electronApp), diff --git a/packages/devtools/e2e/device-picker-overlay.spec.ts b/packages/devtools/e2e/device-picker-overlay.spec.ts new file mode 100644 index 00000000..5f0fe65e --- /dev/null +++ b/packages/devtools/e2e/device-picker-overlay.spec.ts @@ -0,0 +1,192 @@ +/** + * The toolbar's device picker as the user meets it, on a real running project. + * + * The picker's list lives in its OWN overlay WebContentsView instead of the + * workbench renderer, because the simulator is itself a native + * WebContentsView painted over that renderer: a centred DOM dialog there is + * sliced in half by the simulator (see `shared/view-ids.ts` — CSS z-index + * cannot cross the native/DOM boundary). This spec pins that with the real + * `win.contentView.children` order and the two views' real bounds, and then + * walks the whole path: search, pick, and the device the simulator actually + * renders afterwards. + */ +import { test, expect, useSharedProject } from './fixtures' +import type { ElectronApplication } from '@playwright/test' +import { + DEMO_APP_DIR, + devicePickerToolbarButton, + openDevicePicker, + closeDevicePicker, + selectDeviceInPicker, + findDevicePickerWebContentsId, + evalInDevicePicker, + evalInSimulator, + pollUntil, +} from './helpers' +import { DEVICE_NAMES } from '@devicekit/devices' + +interface ViewEntry { + id: number + url: string + bounds: { x: number; y: number; width: number; height: number } +} + +interface Rect { + x: number + y: number + width: number + height: number +} + +/** + * The child views of the window that actually hosts the simulator, in paint + * order (last = topmost). Finding the window by its simulator child is the + * ground truth for "the window under test" — other windows (an internal + * devtools inspector, the project list) can outrank it in creation order. + */ +async function readSimulatorWindowViews(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(({ BrowserWindow }) => { + for (const win of BrowserWindow.getAllWindows()) { + const children = win.contentView.children as Array<{ + webContents?: { id: number; getURL(): string } + getBounds(): { x: number; y: number; width: number; height: number } + }> + const entries = children + .filter((v) => v.webContents !== undefined) + .map((v) => ({ + id: v.webContents!.id, + url: v.webContents!.getURL(), + bounds: v.getBounds(), + })) + if (entries.some((e) => e.url.includes('simulator.html'))) return entries + } + return [] + }) +} + +function contains(outer: Rect, inner: Rect): boolean { + return ( + outer.x <= inner.x + && outer.y <= inner.y + && outer.x + outer.width >= inner.x + inner.width + && outer.y + outer.height >= inner.y + inner.height + ) +} + +function overlapArea(a: Rect, b: Rect): number { + const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) + const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) + return w > 0 && h > 0 ? w * h : 0 +} + +/** The panel's own dialog box, in the overlay view's coordinates. */ +async function readDialogRect(electronApp: ElectronApplication): Promise { + return evalInDevicePicker(electronApp, `(() => { + const el = document.querySelector('[role="dialog"]') + if (!el) throw new Error('device picker dialog not rendered') + const r = el.getBoundingClientRect() + return { x: r.x, y: r.y, width: r.width, height: r.height } + })()`) +} + +test.describe('Device picker overlay (real Electron)', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(120_000) + + useSharedProject(test, DEMO_APP_DIR, { openTimeoutMs: 180_000 }) + + test('the panel paints above the live simulator WCV, over the area the simulator covers', async ({ workbench, electronApp }) => { + await openDevicePicker(workbench, electronApp) + + const pickerWcId = await findDevicePickerWebContentsId(electronApp) + const views = await pollUntil( + () => readSimulatorWindowViews(electronApp), + (list) => list.some((v) => v.id === pickerWcId), + 15_000, + 300, + ) + + const pickerIndex = views.findIndex((v) => v.id === pickerWcId) + const simulatorIndex = views.findIndex((v) => v.url.includes('simulator.html')) + expect(simulatorIndex, 'simulator WCV must be live for the z-order check to mean anything').toBeGreaterThanOrEqual(0) + // ANTI-CHEAT: the original bug rendered the dialog fine — it was just + // ordered BELOW the simulator in this very array. Moving the panel back + // into the workbench renderer, or attaching it under the simulator, fails + // here. + expect(pickerIndex).toBeGreaterThan(simulatorIndex) + + const picker = views[pickerIndex] + const simulator = views[simulatorIndex] + expect(contains(picker.bounds, simulator.bounds), 'the overlay must span the whole window, not dodge the simulator').toBe(true) + + // The dialog itself has to sit over the simulator's area — that overlap is + // exactly what a workbench-renderer dialog could not survive. + const dialog = await readDialogRect(electronApp) + const dialogInWindow = { + x: picker.bounds.x + dialog.x, + y: picker.bounds.y + dialog.y, + width: dialog.width, + height: dialog.height, + } + expect(dialog.width, 'dialog must be laid out').toBeGreaterThan(0) + expect(contains(picker.bounds, dialogInWindow), 'dialog must be fully inside its own overlay view').toBe(true) + expect(overlapArea(dialogInWindow, simulator.bounds)).toBeGreaterThan(0) + + await closeDevicePicker(electronApp) + + // Closing withdraws the view from the window's tree; the WebContents stays + // alive for the next open. + const afterClose = await pollUntil( + () => readSimulatorWindowViews(electronApp), + (list) => !list.some((v) => v.id === pickerWcId), + 15_000, + 300, + ) + expect(afterClose.some((v) => v.id === pickerWcId)).toBe(false) + }) + + test('searching for a device and picking it switches the running simulator', async ({ workbench, electronApp }) => { + const before = await evalInSimulator( + electronApp, + `(() => { const el = document.querySelector('device-frame'); return el ? el.getAttribute('device') : null })()`, + ) + expect(before, 'the simulator must render a device frame before the switch').not.toBeNull() + expect(before).not.toBe(DEVICE_NAMES.Pixel_8) + + await selectDeviceInPicker(workbench, electronApp, DEVICE_NAMES.Pixel_8) + + // The toolbar button's label IS the selected device's name. + await expect(devicePickerToolbarButton(workbench)).toHaveText(DEVICE_NAMES.Pixel_8) + + const after = await pollUntil( + () => evalInSimulator( + electronApp, + `(() => { const el = document.querySelector('device-frame'); return el ? el.getAttribute('device') : null })()`, + ).catch(() => null), + (name) => name === DEVICE_NAMES.Pixel_8, + 20_000, + 500, + ) + expect(after).toBe(DEVICE_NAMES.Pixel_8) + }) + + test('reopening the panel starts from a clean search box on the device now selected', async ({ workbench, electronApp }) => { + await openDevicePicker(workbench, electronApp) + + const state = await evalInDevicePicker<{ query: string; current: string | null }>(electronApp, `(() => { + const input = document.querySelector('input[role="combobox"]') || document.querySelector('input') + const current = document.querySelector('[role="option"][data-current="true"]') + return { + query: input ? input.value : 'NO INPUT', + current: current ? current.getAttribute('aria-label') : null, + } + })()`) + + // The overlay view is reused across opens, so a stale query from the last + // open would silently hide most of the table. + expect(state.query).toBe('') + expect(state.current).toBe(DEVICE_NAMES.Pixel_8) + + await closeDevicePicker(electronApp) + }) +}) diff --git a/packages/devtools/e2e/devtools-panel.spec.ts b/packages/devtools/e2e/devtools-panel.spec.ts index cca47c54..bece6968 100644 --- a/packages/devtools/e2e/devtools-panel.spec.ts +++ b/packages/devtools/e2e/devtools-panel.spec.ts @@ -2,8 +2,8 @@ import { test, expect, useSharedProject } from './fixtures' import { DEMO_APP_DIR, findButtonByText, + devicePickerToolbarButton, } from './helpers' -import { DEVICE_NAMES } from '@devicekit/devices' test.describe('Simulator Panel', () => { test.describe.configure({ mode: 'serial' }) @@ -41,21 +41,21 @@ test.describe('Simulator Panel', () => { // gate visibility. // // The observable visibility signal in the workbench DOM is the - // SimulatorPanel itself: its device-picker `` - // carrying the device options, e.g. `iPhone SE (3rd gen)`) mounts when the simulator - // cell is in the compiled layout and unmounts when the cell is pruned. The - // toolbar toggle flips `layoutStore.simulatorVisible`, which the layout - // compile pass turns into the cell being present/absent (collapseInvisibleCells). - const deviceSelect = workbench.locator(`select:has(option[value="${DEVICE_NAMES.iPhone_SE_3rd_gen}"])`) + // SimulatorPanel's own toolbar: the device button (which opens the + // device-picker overlay WebContentsView) mounts when the simulator cell is + // in the compiled layout and unmounts when the cell is pruned. The toolbar + // toggle flips `layoutStore.simulatorVisible`, which the layout compile + // pass turns into the cell being present/absent (collapseInvisibleCells). + const deviceButton = devicePickerToolbarButton(workbench) const toggle = workbench.getByTestId('layout-toolbar-toggle-simulator') - await expect(deviceSelect).toHaveCount(1) + await expect(deviceButton).toHaveCount(1) await toggle.click() - await expect(deviceSelect).toHaveCount(0) + await expect(deviceButton).toHaveCount(0) await toggle.click() - await expect(deviceSelect).toHaveCount(1) + await expect(deviceButton).toHaveCount(1) }) test('right panel tabs are rendered in the workbench window', async ({ workbench }) => { diff --git a/packages/devtools/e2e/helpers.ts b/packages/devtools/e2e/helpers.ts index de87acf9..e94a8b5c 100644 --- a/packages/devtools/e2e/helpers.ts +++ b/packages/devtools/e2e/helpers.ts @@ -781,6 +781,143 @@ const SET_NATIVE_VALUE_JS = ` } ` +// ── Device-picker helpers ────────────────────────────────────────────── +// +// The toolbar's device button no longer opens a DOM ` -> setNativeDeviceInfo IPC -> main process + * Real user path: toolbar device picker -> setNativeDeviceInfo IPC -> main process * host-env + `hostEnvUpdate` push into the RUNNING dimina service -> what the * page's own JS (`wx.getWindowInfo` / `wx.getSystemInfo(Sync)`) and the page's * own CSS (`env(safe-area-inset-*)`) observe. @@ -34,6 +34,7 @@ import { evalInWebContentsByUrl, RENDER_GUEST_URL_MARKER, findMainWindow, + selectDeviceInPicker, } from './helpers' import { AutomationChannel } from '../src/shared/ipc-channels' import { @@ -96,8 +97,7 @@ const IPHONE_15_SHARED_FIELDS = windowFieldsOf(IPHONE_15_WINDOW_INFO) // ── Toolbar driving ──────────────────────────────────────────────────── async function selectDevice(win: PwPage, deviceName: string): Promise { - const sel = win.locator('select', { has: win.locator(`option[value="${deviceName}"]`) }).first() - await sel.selectOption(deviceName) + await selectDeviceInPicker(win, electronApp, deviceName) } async function waitForFrameDevice(app: ElectronApplication, deviceName: string): Promise { diff --git a/packages/devtools/src/main/app/app.ts b/packages/devtools/src/main/app/app.ts index 268b1dec..308f4b87 100644 --- a/packages/devtools/src/main/app/app.ts +++ b/packages/devtools/src/main/app/app.ts @@ -19,6 +19,7 @@ import { registerInternalDevtoolsIpc, registerTooltipIpc, registerProjectCreateIpc, + registerDevicePickerIpc, registerViewsIpc, } from '../ipc/index.js' import { registerProjectFsIpc } from '../ipc/project-fs.js' @@ -90,6 +91,11 @@ function registerWorkbenchIpc( // Unconditional: the project-create dialog is core UI chrome (the built-in // "新建项目" flow every host falls back to), not a host-configurable feature. appRegistry.add(registerProjectCreateIpc(router)) + // Unconditional for the same reason as the tooltip above: the device picker + // is an overlay WebContentsView the simulator toolbar opens, and `ctx.views` + // exists regardless of the `simulator` module toggle. A host that turns the + // simulator off simply never renders the trigger. + appRegistry.add(registerDevicePickerIpc(router)) // Unconditional (not a toggleable BUILTIN_MODULES entry): placement/host- // slot IPC has no real dependency on the simulator module — `ctx.views` // (ViewManager) is constructed unconditionally regardless of diff --git a/packages/devtools/src/main/ipc/device-picker.ts b/packages/devtools/src/main/ipc/device-picker.ts new file mode 100644 index 00000000..14d35fbe --- /dev/null +++ b/packages/devtools/src/main/ipc/device-picker.ts @@ -0,0 +1,40 @@ +import type { WorkbenchModule } from '../services/module.js' +import type { ViewManager } from '../services/views/view-manager.js' +import type { RendererNotifier } from '../services/notifications/renderer-notifier.js' +import { DevicePickerChannel } from '../../shared/ipc-channels-overlays.js' +import { DevicePickerDeviceSchema } from '../../shared/ipc-schemas.js' +import type { Disposable } from '@dimina-kit/electron-deck/main' +import { validate } from '../utils/ipc-schema.js' +import { IpcRegistry, type SenderPolicy } from '../utils/ipc-registry.js' +import { toIpcContextSource, type IpcInput } from '../utils/ipc-context-source.js' + +/** Module-local narrow deps — deliberately NOT `Pick` + * (the gate in eslint.config.* is shrink-only; see its message). */ +export interface DevicePickerIpcDeps { + views: Pick + notify: Pick + senderPolicy?: SenderPolicy +} + +export function registerDevicePickerIpc(input: IpcInput): Disposable { + return new IpcRegistry(toIpcContextSource(input)) + .onRouted(DevicePickerChannel.Show, (ctx, _event, ...args: unknown[]) => { + const [data] = validate(DevicePickerChannel.Show, DevicePickerDeviceSchema, args) + ctx.views.showDevicePicker(data) + }) + .onRouted(DevicePickerChannel.Cancel, (ctx) => { + ctx.views.hideDevicePicker() + }) + .onRouted(DevicePickerChannel.Select, (ctx, _event, ...args: unknown[]) => { + const [data] = validate(DevicePickerChannel.Select, DevicePickerDeviceSchema, args) + ctx.views.hideDevicePicker() + ctx.notify.devicePickerSelected(data) + }) + // OverlayChannel.Ready is registered once, in tooltip.ts — every overlay + // renderer (this one included) funnels readiness through that single + // listener, so a second registration here would double-fire markOverlayReady. +} + +export const devicePickerModule: WorkbenchModule = { + setup: (ctx) => registerDevicePickerIpc(ctx), +} diff --git a/packages/devtools/src/main/ipc/index.ts b/packages/devtools/src/main/ipc/index.ts index 07b71ea7..d036ec89 100644 --- a/packages/devtools/src/main/ipc/index.ts +++ b/packages/devtools/src/main/ipc/index.ts @@ -3,6 +3,7 @@ export { registerSimulatorIpc } from './simulator.js' export { registerPopoverIpc, popoverModule } from './popover.js' export { registerTooltipIpc, tooltipModule } from './tooltip.js' export { registerProjectCreateIpc, projectCreateModule } from './project-create.js' +export { registerDevicePickerIpc, devicePickerModule } from './device-picker.js' export { registerProjectsIpc, projectsModule } from './projects.js' export { registerSessionIpc, sessionModule } from './session.js' export { registerSettingsIpc, settingsModule } from './settings.js' diff --git a/packages/devtools/src/main/services/notifications/renderer-notifier.ts b/packages/devtools/src/main/services/notifications/renderer-notifier.ts index 618508ac..412038ab 100644 --- a/packages/devtools/src/main/services/notifications/renderer-notifier.ts +++ b/packages/devtools/src/main/services/notifications/renderer-notifier.ts @@ -13,6 +13,7 @@ import { PopoverChannel, TooltipChannel, ProjectCreateChannel, + DevicePickerChannel, UpdateChannel, ViewChannel, } from '../../../shared/ipc-channels-overlays.js' @@ -167,6 +168,12 @@ export interface RendererNotifier { path: string templateId: string }): void + /** + * Relay the device picked in the device-picker overlay back to the main + * renderer, whose simulator toolbar owns the device state — the panel only + * renders the list. + */ + devicePickerSelected(payload: { deviceName: string }): void // ── Embedded overlays ──────────────────────────────────────────────────── /** Initialise the currently shown compile popover overlay. */ @@ -182,6 +189,8 @@ export interface RendererNotifier { ): void /** Push a discovered update's info into the update overlay. */ updateAvailable(dialogView: WebContentsView, payload: UpdateInfo): void + /** Push the currently selected device into the device-picker overlay. */ + devicePickerInit(pickerView: WebContentsView, payload: { deviceName: string }): void // ── Standalone windows ─────────────────────────────────────────────────── /** Initialise the standalone workbench-settings window. */ @@ -260,6 +269,9 @@ export function createRendererNotifier(ctx: NotifierContext): RendererNotifier { projectCreateSubmitted(payload) { sendToMain(ProjectCreateChannel.Submitted, payload) }, + devicePickerSelected(payload) { + sendToMain(DevicePickerChannel.Selected, payload) + }, popoverInit(popoverView, payload) { const wc = liveWebContents(popoverView.webContents) @@ -281,6 +293,11 @@ export function createRendererNotifier(ctx: NotifierContext): RendererNotifier { if (!wc) return wc.send(UpdateChannel.Available, payload) }, + devicePickerInit(pickerView, payload) { + const wc = liveWebContents(pickerView.webContents) + if (!wc) return + wc.send(DevicePickerChannel.Init, payload) + }, settingsInit(payload) { const wc = liveWebContents(ctx.views.getSettingsWebContents()) if (!wc) return diff --git a/packages/devtools/src/main/services/views/host-toolbar.test.ts b/packages/devtools/src/main/services/views/host-toolbar.test.ts index 811c4533..d10b7222 100644 --- a/packages/devtools/src/main/services/views/host-toolbar.test.ts +++ b/packages/devtools/src/main/services/views/host-toolbar.test.ts @@ -369,6 +369,7 @@ function makeSenderPolicyCtx(hostToolbarId: number | null, tooltipId: number | n getTooltipWebContentsId: () => tooltipId, getProjectCreateDialogWebContentsId: () => null, getUpdateDialogWebContentsId: () => null, + getDevicePickerWebContentsId: () => null, getHostToolbarWebContentsId: () => hostToolbarId, } as unknown as import('../workbench-context.js').WorkbenchContext['views'], } diff --git a/packages/devtools/src/main/services/views/overlay-panels-view.ts b/packages/devtools/src/main/services/views/overlay-panels-view.ts index fc2f9c53..adcc3fb1 100644 --- a/packages/devtools/src/main/services/views/overlay-panels-view.ts +++ b/packages/devtools/src/main/services/views/overlay-panels-view.ts @@ -28,6 +28,11 @@ export interface ProjectCreateShowPayload { defaultBaseDir: string } +/** Payload shown by the device-picker overlay panel (`devicePicker:show`). */ +export interface DevicePickerShowPayload { + deviceName: string +} + /** * The main-owned overlay panels: the settings sheet (right-side panel over a * transparent backdrop), the transient compile-mode popover, and the tooltip. @@ -76,8 +81,17 @@ export interface OverlayPanelsView { hideUpdateDialog(): void /** Forward a download-progress tick into the (already-shown) update overlay. */ notifyUpdateDownloadProgress(percent: number): void + /** + * Show the simulator toolbar's device picker (VIEW_LAYER.dialog). Same + * reason as `showProjectCreateDialog`: the simulator WCV overlaps the + * centred search panel, so it cannot be a DOM dialog in the toolbar's own + * renderer. + */ + showDevicePicker(data: DevicePickerShowPayload): void + hideDevicePicker(): void getProjectCreateDialogWebContentsId(): number | null getUpdateDialogWebContentsId(): number | null + getDevicePickerWebContentsId(): number | null } export function createOverlayPanelsView( @@ -216,6 +230,24 @@ export function createOverlayPanelsView( readyMode: 'manual', }) + const devicePickerPanel: OverlayPanel = createOverlayPanel({ + electron: { createWebContentsView: (opts) => new WebContentsView(opts) }, + rendererDir: ctx.rendererDir, + entry: 'entries/device-picker/index.html', + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: false, + preload: mainPreloadPath, + }, + hardenNavigation: (wc) => applyNavigationHardening(wc, ctx.rendererDir), + setDesired: overlayDesiredSetter(VIEW_ID.devicePicker, VIEW_LAYER.dialog), + registerView: (getView) => reconciler.registerView(VIEW_ID.devicePicker, { getView }), + destroyView: (view) => reconciler.destroyView(VIEW_ID.devicePicker, view), + pushData: (view, data) => ctx.notify.devicePickerInit(view, data), + readyMode: 'manual', + }) + let tooltipRequestId = 0 let activeTooltip: { requestId: number; anchor: TooltipShowPayload['anchor'] } | null = null @@ -282,6 +314,14 @@ export function createOverlayPanelsView( updateDialogPanel.hide() } + function showDevicePicker(data: DevicePickerShowPayload): void { + devicePickerPanel.show(data, fullWindowBounds()) + } + + function hideDevicePicker(): void { + devicePickerPanel.hide() + } + function notifyUpdateDownloadProgress(percent: number): void { const wc = updateDialogPanel.getWebContents() if (!wc) return @@ -294,6 +334,7 @@ export function createOverlayPanelsView( tooltipPanel.markReady(webContentsId) projectCreateDialogPanel.markReady(webContentsId) updateDialogPanel.markReady(webContentsId) + devicePickerPanel.markReady(webContentsId) } function applyTooltipMeasurement( @@ -324,6 +365,9 @@ export function createOverlayPanelsView( if (updateDialogPanel.isPresent() && reconciler.hasOverlayDesired(VIEW_ID.updateDialog)) { updateDialogPanel.reposition(fullWindowBounds()) } + if (devicePickerPanel.isPresent() && reconciler.hasOverlayDesired(VIEW_ID.devicePicker)) { + devicePickerPanel.reposition(fullWindowBounds()) + } } function applySettingsBoundsIfPresent(): void { @@ -342,6 +386,7 @@ export function createOverlayPanelsView( function destroyDialogs(): void { projectCreateDialogPanel.destroy() updateDialogPanel.destroy() + devicePickerPanel.destroy() } return { @@ -359,6 +404,8 @@ export function createOverlayPanelsView( showUpdateDialog, hideUpdateDialog, notifyUpdateDownloadProgress, + showDevicePicker, + hideDevicePicker, reapplyPresentOverlays, applySettingsBoundsIfPresent, destroySettings, @@ -369,5 +416,6 @@ export function createOverlayPanelsView( getTooltipWebContentsId: () => tooltipPanel.getWebContentsId(), getProjectCreateDialogWebContentsId: () => projectCreateDialogPanel.getWebContentsId(), getUpdateDialogWebContentsId: () => updateDialogPanel.getWebContentsId(), + getDevicePickerWebContentsId: () => devicePickerPanel.getWebContentsId(), } } diff --git a/packages/devtools/src/main/services/views/view-manager-dialog-zorder.test.ts b/packages/devtools/src/main/services/views/view-manager-dialog-zorder.test.ts index 7f050549..d1a8c99e 100644 --- a/packages/devtools/src/main/services/views/view-manager-dialog-zorder.test.ts +++ b/packages/devtools/src/main/services/views/view-manager-dialog-zorder.test.ts @@ -89,7 +89,7 @@ vi.mock('../../utils/paths.js', () => ({ // Import AFTER mocks so view-manager picks up the stubs. import { createViewManager } from './view-manager.js' -import { hostToolbarBounds, hostSidebarBounds } from './placement-test-driver.js' +import { hostToolbarBounds, hostSidebarBounds, simulatorBounds } from './placement-test-driver.js' import { createConnectionRegistry } from '@dimina-kit/electron-deck/main' function makeContext() { @@ -109,6 +109,7 @@ function makeContext() { tooltipInit: vi.fn(), projectCreateInit: vi.fn(), updateAvailable: vi.fn(), + devicePickerInit: vi.fn(), } return { addChildView, @@ -126,6 +127,7 @@ function makeContext() { const TOOLBAR_RECT = { x: 0, y: 0, width: 1280, height: 48 } const SIDEBAR_RECT = { x: 0, y: 48, width: 240, height: 900 } +const SIMULATOR_RECT = { x: 0, y: 85, width: 437, height: 833, zoom: 100 } // Last addChildView call's first arg = the topmost view. function lastAdded(addChildView: ReturnType): StubView { @@ -192,4 +194,29 @@ describe('ViewManager dialog overlay z-order: dialogs stay above host-toolbar/ho expect(lastAdded(addChildView)).toBe(dialogView) }) + + // The device picker's occluder is the SIMULATOR view, not a host slot: the + // toolbar that opens it sits in the same panel the simulator WCV is painted + // over, and the centred panel is wide enough to reach into it. + it('device picker attaches above the open simulator, and stays above it when the simulator republishes bounds', () => { + const { addChildView, ctx } = makeContext() + const mgr = createViewManager(ctx) + + simulatorBounds(mgr, SIMULATOR_RECT) + hostToolbarBounds(mgr, TOOLBAR_RECT) + + mgr.showDevicePicker({ deviceName: 'iPhone 14 Pro' }) + const webContentsId = mgr.getDevicePickerWebContentsId() + expect(webContentsId).not.toBeNull() + mgr.markOverlayReady(webContentsId!) + const pickerView = viewFor(webContentsId!) + + expect(lastAdded(addChildView)).toBe(pickerView) + + // A wider device (or a dock resize) re-publishes the simulator's rect while + // the picker is open; that base-tier re-attach must not jump above it. + simulatorBounds(mgr, { ...SIMULATOR_RECT, width: 820 }) + + expect(lastAdded(addChildView)).toBe(pickerView) + }) }) diff --git a/packages/devtools/src/main/services/views/view-manager.ts b/packages/devtools/src/main/services/views/view-manager.ts index 15c4ed1d..96186fc3 100644 --- a/packages/devtools/src/main/services/views/view-manager.ts +++ b/packages/devtools/src/main/services/views/view-manager.ts @@ -126,8 +126,11 @@ export interface ViewManager extends Pick< | 'showUpdateDialog' | 'hideUpdateDialog' | 'notifyUpdateDownloadProgress' + | 'showDevicePicker' + | 'hideDevicePicker' | 'getProjectCreateDialogWebContentsId' | 'getUpdateDialogWebContentsId' + | 'getDevicePickerWebContentsId' >, HostSidebarViewManagerMembers, HostDialogViewManagerMembers { @@ -418,8 +421,11 @@ export function createViewManager(ctx: ViewManagerContext): ViewManager { showUpdateDialog: overlayPanels.showUpdateDialog, hideUpdateDialog: overlayPanels.hideUpdateDialog, notifyUpdateDownloadProgress: overlayPanels.notifyUpdateDownloadProgress, + showDevicePicker: overlayPanels.showDevicePicker, + hideDevicePicker: overlayPanels.hideDevicePicker, getProjectCreateDialogWebContentsId: overlayPanels.getProjectCreateDialogWebContentsId, getUpdateDialogWebContentsId: overlayPanels.getUpdateDialogWebContentsId, + getDevicePickerWebContentsId: overlayPanels.getDevicePickerWebContentsId, repositionAll: () => { overlayPanels.reapplyPresentOverlays() hostDialog.reposition() diff --git a/packages/devtools/src/main/utils/sender-policy.ts b/packages/devtools/src/main/utils/sender-policy.ts index 7b5c3be6..e0d5db67 100644 --- a/packages/devtools/src/main/utils/sender-policy.ts +++ b/packages/devtools/src/main/utils/sender-policy.ts @@ -89,6 +89,12 @@ export function createWorkbenchOwnedSenderCheck( const updateDialogViewId = ctx.views.getUpdateDialogWebContentsId() if (updateDialogViewId != null && sender.id === updateDialogViewId) return true + // Device-picker overlay view. Same devtools-owned trust level — the + // simulator toolbar's device selector, moved out of the main renderer's + // DOM so the simulator WCV can't occlude it. + const devicePickerViewId = ctx.views.getDevicePickerWebContentsId() + if (devicePickerViewId != null && sender.id === devicePickerViewId) return true + // The host-toolbar overlay is DELIBERATELY NOT trusted here. The host loads // arbitrary content into it, so granting it the global white-list would open // all ~72 IpcRegistry channels to that content. Its one channel (the reverse diff --git a/packages/devtools/src/renderer/entries/device-picker/index.html b/packages/devtools/src/renderer/entries/device-picker/index.html new file mode 100644 index 00000000..4023fe15 --- /dev/null +++ b/packages/devtools/src/renderer/entries/device-picker/index.html @@ -0,0 +1,16 @@ + + + + + + + Device Picker + + + +
+ + + diff --git a/packages/devtools/src/renderer/entries/device-picker/main.tsx b/packages/devtools/src/renderer/entries/device-picker/main.tsx new file mode 100644 index 00000000..67f9bb11 --- /dev/null +++ b/packages/devtools/src/renderer/entries/device-picker/main.tsx @@ -0,0 +1,5 @@ +import ReactDOM from 'react-dom/client' +import '../../design.css' +import DevicePickerPanel from '../../modules/device-picker/device-picker-panel' + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/devtools/src/renderer/modules/device-picker/device-picker-panel.test.tsx b/packages/devtools/src/renderer/modules/device-picker/device-picker-panel.test.tsx new file mode 100644 index 00000000..a033881b --- /dev/null +++ b/packages/devtools/src/renderer/modules/device-picker/device-picker-panel.test.tsx @@ -0,0 +1,125 @@ +/** + * The device-picker overlay WebContentsView is reused across openings, so the + * shell must drop its device back to null on select/dismiss: that unmounts + * `DevicePicker`, and the NEXT `devicePicker:show` mounts a fresh one instead + * of one still carrying the previous session's search text and chip filters. + * The shell also owns the two outbound channels — a pick goes out as + * `selectDevice`, a dismissal as `cancelDevicePicker`. + */ +import { render, screen, fireEvent, act } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DEVICE_NAMES } from '@devicekit/devices' + +const { + onDevicePickerInitMock, + notifyOverlayReadyMock, + selectDeviceMock, + cancelDevicePickerMock, + initHandlers, +} = vi.hoisted(() => { + const initHandlers: Array<(payload: { deviceName: string }) => void> = [] + return { + onDevicePickerInitMock: vi.fn((handler: (payload: { deviceName: string }) => void) => { + initHandlers.push(handler) + return () => { + const idx = initHandlers.indexOf(handler) + if (idx >= 0) initHandlers.splice(idx, 1) + } + }), + notifyOverlayReadyMock: vi.fn(), + selectDeviceMock: vi.fn(), + cancelDevicePickerMock: vi.fn(), + initHandlers, + } +}) + +vi.mock('@/shared/api', () => ({ + notifyOverlayReady: notifyOverlayReadyMock, + onDevicePickerInit: onDevicePickerInitMock, + selectDevice: selectDeviceMock, + cancelDevicePicker: cancelDevicePickerMock, +})) + +// cmdk measures its list via ResizeObserver, which jsdom does not implement. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +import DevicePickerPanel from './device-picker-panel' + +const SEARCH_PLACEHOLDER = '搜索机型:名称 / 系统 / 尺寸' + +beforeEach(() => { + initHandlers.length = 0 + notifyOverlayReadyMock.mockClear() + selectDeviceMock.mockClear() + cancelDevicePickerMock.mockClear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) + +function fireInit(deviceName: string) { + act(() => { for (const h of [...initHandlers]) h({ deviceName }) }) +} + +describe('DevicePickerPanel: nothing renders before main pushes a device', () => { + it('announces readiness and stays empty until the first init', () => { + render() + + expect(notifyOverlayReadyMock).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('dialog')).toBeNull() + }) +}) + +describe('DevicePickerPanel: relaying the outcome to main', () => { + it('sends the picked device name', async () => { + render() + fireInit(DEVICE_NAMES.iPhone_14_Pro) + + fireEvent.click(await screen.findByRole('option', { name: DEVICE_NAMES.Galaxy_Tab_S9 })) + + expect(selectDeviceMock).toHaveBeenCalledWith({ deviceName: DEVICE_NAMES.Galaxy_Tab_S9 }) + expect(cancelDevicePickerMock).not.toHaveBeenCalled() + }) + + it('sends a cancel when the panel is dismissed with Escape', async () => { + render() + fireInit(DEVICE_NAMES.iPhone_14_Pro) + + fireEvent.keyDown(await screen.findByRole('dialog'), { key: 'Escape' }) + + expect(cancelDevicePickerMock).toHaveBeenCalledTimes(1) + expect(selectDeviceMock).not.toHaveBeenCalled() + }) +}) + +describe('DevicePickerPanel: reopening starts from a clean panel', () => { + it('drops the previous session\'s search text before the next show renders', async () => { + render() + fireInit(DEVICE_NAMES.iPhone_14_Pro) + + fireEvent.change(await screen.findByPlaceholderText(SEARCH_PLACEHOLDER), { + target: { value: 'tab s9' }, + }) + expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toHaveValue('tab s9') + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + fireInit(DEVICE_NAMES.iPhone_14_Pro) + + expect(await screen.findByPlaceholderText(SEARCH_PLACEHOLDER)).toHaveValue('') + }) + + it('drops the previous session\'s os chip filter before the next show renders', async () => { + render() + fireInit(DEVICE_NAMES.iPhone_14_Pro) + + fireEvent.click(await screen.findByRole('button', { name: 'iOS' })) + expect(screen.queryByRole('option', { name: DEVICE_NAMES.Galaxy_Tab_S9 })).toBeNull() + + fireEvent.click(screen.getByRole('option', { name: DEVICE_NAMES.iPhone_15 })) + fireInit(DEVICE_NAMES.iPhone_15) + + expect(await screen.findByRole('option', { name: DEVICE_NAMES.Galaxy_Tab_S9 })).toBeInTheDocument() + }) +}) diff --git a/packages/devtools/src/renderer/modules/device-picker/device-picker-panel.tsx b/packages/devtools/src/renderer/modules/device-picker/device-picker-panel.tsx new file mode 100644 index 00000000..d6cbf473 --- /dev/null +++ b/packages/devtools/src/renderer/modules/device-picker/device-picker-panel.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' +import { DEVICES, DEFAULT_DEVICE, findDevice } from '@devicekit/devices' +import { + notifyOverlayReady, + onDevicePickerInit, + selectDevice, + cancelDevicePicker, +} from '@/shared/api' +import { DevicePicker } from '@/modules/main/features/project-runtime/components/device-picker' + +/** + * Thin stateful shell for the top-tier native device-picker overlay surface. + * `DevicePicker` stays a pure props/callback component; this mounts it against + * the device main pushes after `showDevicePicker`, and relays the choice back + * to the toolbar that owns the device state. + * + * Dropping `deviceName` back to null on select/dismiss unmounts the picker, so + * the NEXT open mounts a fresh one: this view is reused across openings and a + * previous session's search text, chip filters and highlighted row must not be + * what the user sees next time. + */ +export default function DevicePickerPanel() { + const [deviceName, setDeviceName] = useState(null) + + useEffect(() => { + const off = onDevicePickerInit((payload) => setDeviceName(payload.deviceName)) + notifyOverlayReady() + return off + }, []) + + if (deviceName === null) return null + + function handleSelect(name: string) { + setDeviceName(null) + selectDevice({ deviceName: name }) + } + + function handleClose() { + setDeviceName(null) + cancelDevicePicker() + } + + return ( + + ) +} diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/device-picker.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/device-picker.test.tsx new file mode 100644 index 00000000..8a14285b --- /dev/null +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/device-picker.test.tsx @@ -0,0 +1,194 @@ +/** + * DevicePicker: the searchable device selector behind the simulator toolbar's + * device button. It renders inside the device-picker overlay WebContentsView, + * which mounts it only while the panel is shown — so the component is always + * open, takes the whole device table as a prop, and reports the outcome + * through `onSelect` / `onClose` instead of owning an open/closed state. + * + * Invariants this suite locks in: + * - every device in the table it is given is offered (the full + * `@devicekit/devices` set, not the CLASSIC_DEVICES subset); + * - each option row carries `aria-label` equal to the device's bare `name`, so + * rows sharing a prefix ("iPhone 14" vs "iPhone 14 Pro") stay unambiguous to + * query, and `data-current="true"` marks the active device; + * - the active device starts highlighted, so Enter on a fresh open keeps the + * current device instead of jumping to the first row. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react' +import { + DEVICES, + DEFAULT_DEVICE, + DEVICE_NAMES, + findDevice, + type DeviceProfile, +} from '@devicekit/devices' +import { DevicePicker, buildSearchValue } from './device-picker' + +// cmdk measures its list via ResizeObserver, which jsdom does not implement. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +function renderPicker( + overrides: Partial<{ + device: DeviceProfile + devices: readonly DeviceProfile[] + onSelect: (name: string) => void + onClose: () => void + }> = {}, +) { + const onSelect = overrides.onSelect ?? vi.fn() + const onClose = overrides.onClose ?? vi.fn() + const device = overrides.device ?? DEFAULT_DEVICE + const devices = overrides.devices ?? DEVICES + render( + , + ) + return { onSelect, onClose, device, devices } +} + +describe('DevicePicker: full device table', () => { + it('renders all 171 devices from @devicekit/devices as options once opened', async () => { + renderPicker() + + expect(await screen.findAllByRole('option')).toHaveLength(DEVICES.length) + }) +}) + +describe('DevicePicker: search', () => { + it('narrows to Galaxy Tab S9 when searching "tab s9"', async () => { + renderPicker() + + const input = await screen.findByPlaceholderText('搜索机型:名称 / 系统 / 尺寸') + fireEvent.change(input, { target: { value: 'tab s9' } }) + + const options = await screen.findAllByRole('option') + expect(options).toHaveLength(1) + expect(options[0]).toHaveAccessibleName(DEVICE_NAMES.Galaxy_Tab_S9) + }) + + it('matches the size typed with the × the row displays, not only ASCII x', async () => { + renderPicker() + + const input = await screen.findByPlaceholderText('搜索机型:名称 / 系统 / 尺寸') + fireEvent.change(input, { target: { value: '393×852' } }) + + const options = await screen.findAllByRole('option') + expect(options.map((o) => o.getAttribute('aria-label'))).toContain(DEVICE_NAMES.iPhone_14_Pro) + }) +}) + +describe('DevicePicker: 平板 form-factor chip', () => { + it('shows only formFactor=tablet devices once toggled on', async () => { + renderPicker() + await screen.findAllByRole('option') + + fireEvent.click(screen.getByRole('button', { name: '平板' })) + + const expectedCount = DEVICES.filter((d) => d.formFactor === 'tablet').length + expect(await screen.findAllByRole('option')).toHaveLength(expectedCount) + expect(screen.getByRole('option', { name: DEVICE_NAMES.Galaxy_Tab_S9 })).toBeInTheDocument() + }) +}) + +describe('DevicePicker: iOS os chip', () => { + it('shows only os=ios devices once toggled on', async () => { + renderPicker() + await screen.findAllByRole('option') + + fireEvent.click(screen.getByRole('button', { name: 'iOS' })) + + const expectedCount = DEVICES.filter((d) => d.os === 'ios').length + expect(await screen.findAllByRole('option')).toHaveLength(expectedCount) + expect(screen.queryByRole('option', { name: DEVICE_NAMES.Galaxy_Tab_S9 })).not.toBeInTheDocument() + }) +}) + +describe('DevicePicker: selecting a device', () => { + it('reports the picked device by name and does not report a dismissal', async () => { + const onSelect = vi.fn() + const onClose = vi.fn() + renderPicker({ onSelect, onClose }) + + const row = await screen.findByRole('option', { name: DEVICE_NAMES.iPhone_14_Pro }) + fireEvent.click(row) + + expect(onSelect).toHaveBeenCalledWith(DEVICE_NAMES.iPhone_14_Pro) + expect(onClose).not.toHaveBeenCalled() + }) +}) + +describe('DevicePicker: current-device marker', () => { + it('marks only the row for the currently active device', async () => { + const current = findDevice(DEVICE_NAMES.iPhone_14_Pro)! + renderPicker({ device: current }) + + const currentRow = await screen.findByRole('option', { name: current.name }) + expect(currentRow).toHaveAttribute('data-current', 'true') + + const otherRow = screen.getByRole('option', { name: DEVICE_NAMES.Galaxy_Tab_S9 }) + expect(otherRow).not.toHaveAttribute('data-current', 'true') + }) + + it('pre-highlights the current device on open so Enter keeps it instead of the first row', async () => { + const current = findDevice(DEVICE_NAMES.iPhone_14_Pro)! + renderPicker({ device: current }) + + const currentRow = await screen.findByRole('option', { name: current.name }) + expect(currentRow).toHaveAttribute('aria-selected', 'true') + const firstRow = screen.getByRole('option', { name: DEVICE_NAMES.iPhone_SE }) + expect(firstRow).not.toHaveAttribute('aria-selected', 'true') + }) + + it('Enter on a fresh open re-selects the current device', async () => { + const current = findDevice(DEVICE_NAMES.iPhone_14_Pro)! + const { onSelect } = renderPicker({ device: current }) + + const input = await screen.findByRole('combobox') + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onSelect).toHaveBeenCalledWith(current.name) + }) +}) + +describe('DevicePicker: dialog accessibility and dismissal', () => { + it('names the dialog for assistive tech', async () => { + renderPicker() + expect(await screen.findByRole('dialog', { name: '选择机型' })).toBeInTheDocument() + }) + + it('Escape reports a dismissal without selecting', async () => { + const { onSelect, onClose } = renderPicker() + const dialog = await screen.findByRole('dialog') + + fireEvent.keyDown(dialog, { key: 'Escape' }) + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + expect(onSelect).not.toHaveBeenCalled() + }) +}) + +describe('buildSearchValue', () => { + it('concatenates name, system, and the WxH screen size', () => { + const iphone14Pro = findDevice(DEVICE_NAMES.iPhone_14_Pro)! + + const value = buildSearchValue(iphone14Pro) + + expect(value).toContain(iphone14Pro.name) + expect(value).toContain(iphone14Pro.system) + expect(value).toContain(`${iphone14Pro.screen.width}x${iphone14Pro.screen.height}`) + expect(value).toContain(`${iphone14Pro.screen.width}×${iphone14Pro.screen.height}`) + }) +}) diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/device-picker.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/device-picker.tsx new file mode 100644 index 00000000..8888c466 --- /dev/null +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/device-picker.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/shared/components/ui/command' +import { cn } from '@/shared/lib/utils' +import type { DeviceFormFactor, DeviceOS, DeviceProfile } from '@devicekit/devices' + +// Groups mirror DEVICES' own os ordering (iOS → Android → HarmonyOS); a +// group is only rendered once it has at least one matching device, so an os +// chip filter doesn't leave two empty headings behind. +const OS_GROUPS: Array<{ os: DeviceOS; label: string }> = [ + { os: 'ios', label: 'iOS' }, + { os: 'android', label: 'Android' }, + { os: 'harmony', label: 'HarmonyOS' }, +] + +const OS_CHIPS: Array<{ os: DeviceOS | null; label: string }> = [ + { os: null, label: '全部' }, + ...OS_GROUPS, +] + +const FORM_FACTOR_CHIPS: Array<{ formFactor: DeviceFormFactor; label: string }> = [ + { formFactor: 'phone', label: '手机' }, + { formFactor: 'tablet', label: '平板' }, +] + +/** + * Search haystack for cmdk's built-in fuzzy filter, one string per device so + * "14 pro", "android 14", and "390" all resolve to the same matches. The size + * is indexed both as typed on a keyboard ("393x852") and as the row displays + * it ("393×852"), since cmdk's filter does not fold the two characters. + */ +export function buildSearchValue(d: DeviceProfile): string { + const { width, height } = d.screen + return [d.name, d.system ?? '', `${width}x${height}`, `${width}×${height}`].join(' ') +} + +interface DevicePickerProps { + device: DeviceProfile + devices: readonly DeviceProfile[] + onSelect: (name: string) => void + /** Dismissed without picking (Escape, or a click on the backdrop). */ + onClose: () => void +} + +/** + * Searchable panel over the full device table, always open: it is mounted by + * the device-picker overlay WebContentsView for exactly as long as that panel + * is shown, and the trigger button lives in the toolbar's own renderer, one + * WebContents away. Chip filters (os / form factor) narrow the candidate list + * in React; free-text search is left to cmdk's own fuzzy match against + * `buildSearchValue`, so the two filters stack without either duplicating the + * other's logic. + */ +export function DevicePicker({ device, devices, onSelect, onClose }: DevicePickerProps) { + const [osFilter, setOsFilter] = useState(null) + const [formFactorFilter, setFormFactorFilter] = useState(null) + const currentRowRef = useRef(null) + + // cmdk mounts the list synchronously with the dialog (no lazy content), so + // the current row's ref is already attached on this first effect run. + useEffect(() => { + const el = currentRowRef.current + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center' }) + } + }, []) + + const filtered = useMemo( + () => + devices.filter( + (d) => + (!osFilter || d.os === osFilter) && + (!formFactorFilter || (d.formFactor ?? 'phone') === formFactorFilter), + ), + [devices, osFilter, formFactorFilter], + ) + + const groups = useMemo( + () => + OS_GROUPS.map((g) => ({ ...g, devices: filtered.filter((d) => d.os === g.os) })).filter( + (g) => g.devices.length > 0, + ), + [filtered], + ) + + return ( + <> + {/* Pre-highlight the current device so Enter on a fresh open keeps it + instead of jumping to the first row of the list. */} + { + if (!next) onClose() + }} + title="选择机型" + commandProps={{ defaultValue: buildSearchValue(device) }} + > + +
+ {OS_CHIPS.map((chip) => ( + setOsFilter(chip.os)} + /> + ))} + + {FORM_FACTOR_CHIPS.map((chip) => ( + + setFormFactorFilter((cur) => (cur === chip.formFactor ? null : chip.formFactor)) + } + /> + ))} +
+ + 没有匹配的机型 + {groups.map((g) => ( + + {g.devices.map((d) => { + const isCurrent = d.name === device.name + return ( + onSelect(d.name)} + className="flex items-center justify-between gap-2" + > + + + {d.name} + {d.formFactor === 'tablet' && ( + + 平板 + + )} + + + {d.screen.width}×{d.screen.height} @{d.pixelRatio}x + {d.system ? ` · ${d.system}` : ''} + + + ) + })} + + ))} + +
+ + ) +} + +function Chip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ) +} diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-auto-zoom.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-auto-zoom.test.tsx index 573aff29..47d82de6 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-auto-zoom.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-auto-zoom.test.tsx @@ -55,7 +55,7 @@ function panelElement(zoom: ZoomSetting, device: typeof DEVICE = DEVICE) { {}} + onOpenDevicePicker={() => {}} onZoomChange={() => {}} compileStatus={{ status: 'ready', message: '' }} currentPage="pages/index/index" @@ -204,7 +204,7 @@ describe('SimulatorPanel: auto-fit zoom', () => { {}} + onOpenDevicePicker={() => {}} onZoomChange={() => {}} compileStatus={{ status: 'ready', message: '' }} currentPage="pages/index/index" diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-collapse-on-deactivate.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-collapse-on-deactivate.test.tsx index 106d1c04..efbce986 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-collapse-on-deactivate.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-collapse-on-deactivate.test.tsx @@ -64,7 +64,7 @@ function renderPanel() { {}} + onOpenDevicePicker={() => {}} onZoomChange={() => {}} compileStatus={{ status: 'ready', message: '' }} currentPage="pages/index/index" diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-compiling-indicator.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-compiling-indicator.test.tsx index 1068d861..8737044d 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-compiling-indicator.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-compiling-indicator.test.tsx @@ -45,7 +45,7 @@ function panel(compileStatus: { status: string; message: string }) { {}} + onOpenDevicePicker={() => {}} onZoomChange={() => {}} compileStatus={compileStatus} currentPage="pages/index/index" diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-fallback-banner.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-fallback-banner.test.tsx index 2f3c2a14..75ef6535 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-fallback-banner.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-fallback-banner.test.tsx @@ -51,7 +51,7 @@ function panel(runtimeStatus: SessionRuntimeStatusPayload | null) { const props: PanelPropsWithRuntime = { device: DEVICE, zoom: 100, - onDeviceChange: () => {}, + onOpenDevicePicker: () => {}, onZoomChange: () => {}, compileStatus: { status: 'ready', message: '' }, currentPage: RESOLVED, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-follow-layout-reorder.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-follow-layout-reorder.test.tsx index e5e03341..f34dbe7a 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-follow-layout-reorder.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-follow-layout-reorder.test.tsx @@ -98,7 +98,7 @@ function panelElement(extra: Record = {}) { {}} + onOpenDevicePicker={() => {}} onZoomChange={() => {}} compileStatus={{ status: 'ready', message: '' }} currentPage="pages/index/index" diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-internal-devtools-button.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-internal-devtools-button.test.tsx index 9a3bba87..f15ce7a8 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-internal-devtools-button.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-internal-devtools-button.test.tsx @@ -42,7 +42,7 @@ function panel(overrides: { const props: Parameters[0] = { device: DEVICE, zoom: 100, - onDeviceChange: () => {}, + onOpenDevicePicker: () => {}, onZoomChange: () => {}, compileStatus: { status: 'ready', message: '' }, currentPage: overrides.currentPage ?? '', diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-page-path-bar.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-page-path-bar.test.tsx index e86cbb3e..293fa1b0 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-page-path-bar.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-page-path-bar.test.tsx @@ -30,7 +30,7 @@ function panel(currentPage: string) { const props: Parameters[0] = { device: findDevice(DEVICE_NAMES.iPhone_15)!, zoom: 100, - onDeviceChange: () => {}, + onOpenDevicePicker: () => {}, onZoomChange: () => {}, compileStatus: { status: 'ready', message: '' }, currentPage, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-runtime-error-overlay.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-runtime-error-overlay.test.tsx index 1e2b5f41..27ba2bd4 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-runtime-error-overlay.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-runtime-error-overlay.test.tsx @@ -53,7 +53,7 @@ function panel( const props: PanelPropsWithRuntime = { device: DEVICE, zoom: 100, - onDeviceChange: () => {}, + onOpenDevicePicker: () => {}, onZoomChange: () => {}, compileStatus, currentPage: 'pages/index/index', diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.test.tsx index 5001a939..f56e2db0 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.test.tsx @@ -1,15 +1,17 @@ /** - * SimulatorPanel's device/orientation pickers against the @devicekit/devices - * table: the device (portrait/landscape) reports changes via onOrientationChange. + * SimulatorPanel's device/orientation controls. The device control is a button + * showing the current device name; the searchable list itself lives in the + * device-picker overlay WebContentsView, because the simulator's own WCV is + * painted over this panel and would cut a centred dialog in half. So the panel + * only reports the click through `onOpenDevicePicker` and must render no device + * options of its own. A separate orientation ', () => { const { container } = render(panelElement()) - const groups = Array.from(container.querySelectorAll('optgroup')) - expect(groups.map((g) => g.getAttribute('label')).sort()).toEqual( - ['Android', 'HarmonyOS', 'iOS'].sort(), - ) - }) - it('lists exactly the classic subset, each once, and not the full table', () => { - const { container } = render(panelElement()) - const options = Array.from(container.querySelectorAll('optgroup option'), (o) => (o as HTMLOptionElement).value) - expect(options).toEqual(CLASSIC_DEVICES.map((d) => d.name)) - expect(options.length).toBeLessThan(DEVICES.length) + expect(screen.getByRole('button', { name: DEFAULT_DEVICE.name })).toBeInTheDocument() + expect(container.querySelector('select option[value="' + DEFAULT_DEVICE.name + '"]')).toBeNull() }) - it('puts each classic device under the optgroup of its own platform', () => { - const { container } = render(panelElement()) - for (const group of Array.from(container.querySelectorAll('optgroup'))) { - const os = { iOS: 'ios', Android: 'android', HarmonyOS: 'harmony' }[group.getAttribute('label') ?? ''] - for (const o of Array.from(group.querySelectorAll('option'))) { - expect(CLASSIC_DEVICES.find((d) => d.name === o.value)?.os, o.value).toBe(os) - } - } + it('reports the click to the device-state owner and renders no device list of its own', () => { + const onOpenDevicePicker = vi.fn() + render(panelElement(() => {}, onOpenDevicePicker)) + + fireEvent.click(screen.getByRole('button', { name: DEFAULT_DEVICE.name })) + + expect(onOpenDevicePicker).toHaveBeenCalledTimes(1) + // The device list belongs to the overlay view. A dialog or device rows + // rendered here would be painted behind the simulator WCV — the options + // still present in this DOM are the orientation/zoom - {DEVICE_GROUPS.map((group) => ( - - {group.devices.map((d) => ( - - ))} - - ))} - + {/* The label is the selected device's own name, so it is not a stable + handle for tests or automation — `data-testid` is, like the + compile-mode button next to it. */} + ChangeEvent). */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -import type React from 'react' import { DEFAULT_DEVICE, DEVICE_NAMES, @@ -19,23 +21,39 @@ import { frameOuterSize } from '@devicekit/frame' import { computeSimPanelWidth } from '../lib/device-geometry' import { useDevice } from './use-device' +const { devicePickerHandlers } = vi.hoisted(() => ({ + devicePickerHandlers: [] as Array<(payload: { deviceName: string }) => void>, +})) + vi.mock('@/shared/api', () => ({ setNativeDeviceInfo: vi.fn(), + showDevicePicker: vi.fn(), + onDevicePickerSelected: vi.fn((handler: (payload: { deviceName: string }) => void) => { + devicePickerHandlers.push(handler) + return () => { + const idx = devicePickerHandlers.indexOf(handler) + if (idx >= 0) devicePickerHandlers.splice(idx, 1) + } + }), })) -import { setNativeDeviceInfo } from '@/shared/api' - -function changeEvent(value: string): React.ChangeEvent { - return { target: { value } } as React.ChangeEvent -} +import { setNativeDeviceInfo, showDevicePicker } from '@/shared/api' function lastPayload() { const calls = vi.mocked(setNativeDeviceInfo).mock.calls return calls[calls.length - 1]![0] } +function firePicked(deviceName: string) { + act(() => { + for (const handler of [...devicePickerHandlers]) handler({ deviceName }) + }) +} + beforeEach(() => { + devicePickerHandlers.length = 0 vi.mocked(setNativeDeviceInfo).mockClear() + vi.mocked(showDevicePicker).mockClear() }) describe('useDevice: initial state', () => { @@ -52,7 +70,7 @@ describe('useDevice: selecting an Android device', () => { const pixel8 = resolveDevice(findDevice(DEVICE_NAMES.Pixel_8)!) act(() => { - result.current.handleDeviceChange(changeEvent(DEVICE_NAMES.Pixel_8)) + result.current.handleDeviceChange(DEVICE_NAMES.Pixel_8) }) const payload = lastPayload() @@ -77,7 +95,7 @@ describe('useDevice: rotating to landscape', () => { const iphone15 = resolveDevice(findDevice(DEVICE_NAMES.iPhone_15)!) act(() => { - result.current.handleDeviceChange(changeEvent(DEVICE_NAMES.iPhone_15)) + result.current.handleDeviceChange(DEVICE_NAMES.iPhone_15) }) vi.mocked(setNativeDeviceInfo).mockClear() @@ -103,7 +121,7 @@ describe('useDevice: selecting an unknown device name', () => { const { result } = renderHook(() => useDevice({ initialDevice: DEFAULT_DEVICE })) act(() => { - result.current.handleDeviceChange(changeEvent('Definitely Not A Real Phone')) + result.current.handleDeviceChange('Definitely Not A Real Phone') }) expect(result.current.device).toBe(DEFAULT_DEVICE) @@ -116,7 +134,7 @@ describe('useDevice: simPanelWidth follows the framed (bezel-inclusive) size', ( const pixel8Profile = findDevice(DEVICE_NAMES.Pixel_8)! act(() => { - result.current.handleDeviceChange(changeEvent(DEVICE_NAMES.Pixel_8)) + result.current.handleDeviceChange(DEVICE_NAMES.Pixel_8) }) expect(result.current.simPanelWidth).toBe(computeSimPanelWidth(frameOuterSize(pixel8Profile, 'portrait').width)) @@ -133,3 +151,46 @@ describe('useDevice: simPanelWidth follows the framed (bezel-inclusive) size', ( expect(result.current.simPanelWidth).toBe(computeSimPanelWidth(frameOuterSize(defaultProfile, 'landscape').width)) }) }) + +/** + * The searchable picker lives in its own overlay WebContentsView (the simulator + * WCV would otherwise paint over a DOM dialog), so the toolbar button only asks + * main to show it and the pick arrives back as an IPC push. Device state stays + * owned here: the push takes the same path as a pick made in this window. + */ +describe('useDevice: the device-picker overlay', () => { + it('opens the picker on whichever device is selected at that moment', () => { + const { result } = renderHook(() => useDevice({ initialDevice: DEFAULT_DEVICE })) + + act(() => { + result.current.handleDeviceChange(DEVICE_NAMES.Pixel_8) + }) + act(() => { + result.current.openDevicePicker() + }) + + expect(showDevicePicker).toHaveBeenLastCalledWith({ deviceName: DEVICE_NAMES.Pixel_8 }) + }) + + it('applies the device the picker reports back and pushes it to the running mini-app', () => { + const { result } = renderHook(() => useDevice({ initialDevice: DEFAULT_DEVICE })) + + firePicked(DEVICE_NAMES.Pixel_8) + + expect(result.current.device.name).toBe(DEVICE_NAMES.Pixel_8) + expect(lastPayload()).toMatchObject({ + device: DEVICE_NAMES.Pixel_8, + platform: 'android', + orientation: 'portrait', + }) + }) + + it('unsubscribes on unmount so a later pick cannot reach a torn-down window', () => { + const { unmount } = renderHook(() => useDevice({ initialDevice: DEFAULT_DEVICE })) + expect(devicePickerHandlers).toHaveLength(1) + + unmount() + + expect(devicePickerHandlers).toHaveLength(0) + }) +}) diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.ts b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.ts index 64a1c9dc..235b746f 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.ts +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.ts @@ -17,7 +17,7 @@ import { } from '@devicekit/devices' import { frameOuterSize } from '@devicekit/frame' import { AUTO_ZOOM, type ZoomSetting } from '@/shared/constants' -import { setNativeDeviceInfo } from '@/shared/api' +import { onDevicePickerSelected, setNativeDeviceInfo, showDevicePicker } from '@/shared/api' import { clampPanelWidth, computeSimPanelWidth } from '../lib/device-geometry' export type DeviceType = DeviceProfile @@ -32,7 +32,9 @@ export interface DeviceHookResult { zoom: ZoomSetting simPanelWidth: number setSimPanelWidth: (width: number) => void - handleDeviceChange: (e: React.ChangeEvent) => void + handleDeviceChange: (name: string) => void + /** Ask main to show the device-picker overlay on the current device. */ + openDevicePicker: () => void handleOrientationChange: (orientation: Orientation) => void handleZoomChange: (e: React.ChangeEvent) => void /** @@ -121,8 +123,8 @@ export function useDevice(props: UseDeviceProps): DeviceHookResult { }, []) const handleDeviceChange = useCallback( - (e: React.ChangeEvent) => { - const d = findDevice(e.target.value) ?? DEFAULT_DEVICE + (name: string) => { + const d = findDevice(name) ?? DEFAULT_DEVICE setDevice(d) pushDeviceInfo(d, orientation) // React layout state is the single width authority: the panel re-renders @@ -133,6 +135,19 @@ export function useDevice(props: UseDeviceProps): DeviceHookResult { [orientation, pushDeviceInfo], ) + // The searchable device picker is an overlay WebContentsView of its own (a + // DOM dialog in this renderer would be painted over by the simulator's WCV, + // see view-ids.ts), so opening it is a request to main and the pick comes + // back as a push. Device state stays owned here: an incoming pick takes the + // exact same path as one made in this window. + const openDevicePicker = useCallback(() => { + showDevicePicker({ deviceName: deviceRef.current.name }) + }, []) + + useEffect(() => { + return onDevicePickerSelected(({ deviceName }) => handleDeviceChange(deviceName)) + }, [handleDeviceChange]) + const handleOrientationChange = useCallback( (o: Orientation) => { setOrientation(o) @@ -191,6 +206,7 @@ export function useDevice(props: UseDeviceProps): DeviceHookResult { simPanelWidth, setSimPanelWidth, handleDeviceChange, + openDevicePicker, handleOrientationChange, handleZoomChange, handleSplitterDrag, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller.ts b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller.ts index adeea2d1..ea29e33b 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller.ts +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller.ts @@ -65,6 +65,7 @@ type DeviceSlice = Pick< | 'simPanelWidth' | 'setSimPanelWidth' | 'handleDeviceChange' + | 'openDevicePicker' | 'handleOrientationChange' | 'handleZoomChange' | 'sendDeviceInfo' @@ -213,6 +214,7 @@ export function useProjectRuntimeController( simPanelWidth: deviceHook.simPanelWidth, setSimPanelWidth: deviceHook.setSimPanelWidth, handleDeviceChange: deviceHook.handleDeviceChange, + openDevicePicker: deviceHook.openDevicePicker, handleOrientationChange: deviceHook.handleOrientationChange, handleZoomChange: deviceHook.handleZoomChange, handleSplitterDrag: deviceHook.handleSplitterDrag, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/project-runtime.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/project-runtime.tsx index 9c5c7707..b25df8e4 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/project-runtime.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/project-runtime.tsx @@ -192,7 +192,7 @@ export function ProjectRuntime({ project }: ProjectRuntimeProps) { device={device.device} orientation={device.orientation} zoom={device.zoom} - onDeviceChange={device.handleDeviceChange} + onOpenDevicePicker={device.openDevicePicker} onOrientationChange={device.handleOrientationChange} onZoomChange={device.handleZoomChange} compileStatus={session.compileStatus} diff --git a/packages/devtools/src/renderer/shared/api/view-api.ts b/packages/devtools/src/renderer/shared/api/view-api.ts index fbda0662..5d860c0f 100644 --- a/packages/devtools/src/renderer/shared/api/view-api.ts +++ b/packages/devtools/src/renderer/shared/api/view-api.ts @@ -10,6 +10,7 @@ import { OverlayChannel, TooltipChannel, ProjectCreateChannel, + DevicePickerChannel, ViewChannel, } from '../../../shared/ipc-channels-overlays' import type { PlacementSnapshot } from '@dimina-kit/electron-deck/layout' @@ -185,6 +186,45 @@ export function onProjectCreateSubmitted( return on<[ProjectCreateSubmitPayload]>(ProjectCreateChannel.Submitted, (input) => handler(input)) } +/** The device-picker overlay's whole state: which device is currently selected. */ +export interface DevicePickerDevicePayload { + deviceName: string +} + +/** + * Ask main to show the device-picker overlay panel (VIEW_LAYER.dialog), seeded + * with the currently selected device. The simulator's own WebContentsView sits + * on top of the toolbar's renderer and overlaps a centred dialog, so this panel + * cannot live in the toolbar's DOM. + */ +export function showDevicePicker(payload: DevicePickerDevicePayload): void { + send(DevicePickerChannel.Show, payload) +} + +/** Hide the device-picker overlay panel without changing the device. */ +export function cancelDevicePicker(): void { + send(DevicePickerChannel.Cancel) +} + +/** Commit the device picked in the overlay panel; main hides it and relays the choice. */ +export function selectDevice(payload: DevicePickerDevicePayload): void { + send(DevicePickerChannel.Select, payload) +} + +/** Subscribe to the selected device pushed into the device-picker overlay panel. */ +export function onDevicePickerInit( + handler: (payload: DevicePickerDevicePayload) => void, +): () => void { + return on<[DevicePickerDevicePayload]>(DevicePickerChannel.Init, (payload) => handler(payload)) +} + +/** Subscribe to the relayed device choice (received by the toolbar, not the panel itself). */ +export function onDevicePickerSelected( + handler: (payload: DevicePickerDevicePayload) => void, +): () => void { + return on<[DevicePickerDevicePayload]>(DevicePickerChannel.Selected, (payload) => handler(payload)) +} + // ── Event subscriptions ───────────────────────────────────────────────────── /** Listen for popover-closed broadcasts emitted by the main process. */ diff --git a/packages/devtools/src/renderer/shared/components/ui/command.tsx b/packages/devtools/src/renderer/shared/components/ui/command.tsx index 06d4583c..9b4e29b0 100644 --- a/packages/devtools/src/renderer/shared/components/ui/command.tsx +++ b/packages/devtools/src/renderer/shared/components/ui/command.tsx @@ -6,7 +6,7 @@ import { Command as CommandPrimitive } from "cmdk" import { Search } from "lucide-react" import { cn } from "@/shared/lib/utils" -import { Dialog, DialogContent } from "@/shared/components/ui/dialog" +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog" const Command = React.forwardRef< React.ElementRef, @@ -23,11 +23,27 @@ const Command = React.forwardRef< )) Command.displayName = CommandPrimitive.displayName -const CommandDialog = ({ children, ...props }: DialogProps) => { +type CommandDialogProps = DialogProps & { + /** Accessible name for the dialog (rendered visually hidden) and the cmdk root. */ + title: string + /** + * Element that opens the dialog. Rendering it inside the same Dialog root + * lets Radix return focus to it on close; a button outside the root would + * leave focus on `body` since the modal content refocuses its own + * (then empty) trigger ref. + */ + trigger?: React.ReactNode + /** Forwarded to the inner cmdk root (e.g. `defaultValue` to pre-highlight a row). */ + commandProps?: React.ComponentPropsWithoutRef +} + +const CommandDialog = ({ children, title, trigger, commandProps, ...props }: CommandDialogProps) => { return ( + {trigger ? {trigger} : null} - + {title} + {children} diff --git a/packages/devtools/src/shared/ipc-channels-overlays.ts b/packages/devtools/src/shared/ipc-channels-overlays.ts index d2340a39..6fe03685 100644 --- a/packages/devtools/src/shared/ipc-channels-overlays.ts +++ b/packages/devtools/src/shared/ipc-channels-overlays.ts @@ -183,6 +183,23 @@ export const ProjectCreateChannel = { Submitted: 'projectCreate:submitted', } as const +// ── Device picker ──────────────────────────────────────────────────────── +// +// A top-tier overlay WebContentsView (VIEW_LAYER.dialog), for the same reason +// as the project-create dialog above: the simulator's own WebContentsView is +// mounted on top of the main renderer and overlaps the centred search panel, +// so a DOM-portaled dialog is cut in half by it. The toolbar keeps only the +// trigger button; it asks main to show this panel with the currently selected +// device, and main relays the picked device name back to the toolbar. + +export const DevicePickerChannel = { + Show: 'devicePicker:show', + Init: 'devicePicker:init', + Select: 'devicePicker:select', + Cancel: 'devicePicker:cancel', + Selected: 'devicePicker:selected', +} as const + // ── Embedded settings overlay ──────────────────────────────────────────── export const SettingsChannel = { diff --git a/packages/devtools/src/shared/ipc-schemas.ts b/packages/devtools/src/shared/ipc-schemas.ts index b56e6a42..f6b80f0b 100644 --- a/packages/devtools/src/shared/ipc-schemas.ts +++ b/packages/devtools/src/shared/ipc-schemas.ts @@ -145,6 +145,17 @@ export const ProjectCreateSubmitSchema = z.tuple([ }), ]) +/** + * devicePicker:show / devicePicker:select — the device name only. The panel + * reads the device table from `@devicekit/devices` itself, the same source the + * toolbar reads, so the catalog never crosses IPC. + */ +export const DevicePickerDeviceSchema = z.tuple([ + z.object({ + deviceName: z.string().min(1), + }), +]) + /** * Reasonable simulator width range. Window width is clamped UI-side, but * we still reject obvious garbage (negative, zero, absurdly large). diff --git a/packages/devtools/src/shared/view-ids.ts b/packages/devtools/src/shared/view-ids.ts index 255ccab2..9c65dd28 100644 --- a/packages/devtools/src/shared/view-ids.ts +++ b/packages/devtools/src/shared/view-ids.ts @@ -16,6 +16,7 @@ export const VIEW_ID = { tooltip: 'tooltip', projectCreateDialog: 'project-create-dialog', updateDialog: 'update-dialog', + devicePicker: 'device-picker', } as const export type DevtoolsViewId = (typeof VIEW_ID)[keyof typeof VIEW_ID] @@ -47,7 +48,8 @@ export const VIEW_LAYER = { settings: 10, popover: 20, tooltip: 30, - // devtools' own ProjectCreateDialog/UpdateDialog panels (createOverlayPanel). + // devtools' own ProjectCreateDialog/UpdateDialog/DevicePicker panels + // (createOverlayPanel). dialog: 40, // Host-controlled fully-custom dialog (loadURL-level takeover); sits above // devtools' own dialog layer so host content is never occluded by it. diff --git a/packages/devtools/vite.config.ts b/packages/devtools/vite.config.ts index 3cd7bce1..c52916fb 100644 --- a/packages/devtools/vite.config.ts +++ b/packages/devtools/vite.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ settings: resolve(rendererRoot, 'entries/settings/index.html'), tooltip: resolve(rendererRoot, 'entries/tooltip/index.html'), projectCreateDialog: resolve(rendererRoot, 'entries/project-create-dialog/index.html'), + devicePicker: resolve(rendererRoot, 'entries/device-picker/index.html'), updateDialog: resolve(rendererRoot, 'entries/update-dialog/index.html'), workbenchSettings: resolve(rendererRoot, 'entries/workbench-settings/index.html'), hostSidebarDefault: resolve(rendererRoot, 'entries/host-sidebar-default/index.html'),