diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index 94389b7331..33f9f0026a 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -9,8 +9,8 @@ en: f8f2ccf11332c1c1 zh: 58075651e5dd36d9 /deepseek-ai-dsh-host-directory-picker-native/summary: - en: d02a09798cbe9a37 - zh: c0231f9dacf8f6d0 + en: 45ee8141495e7107 + zh: 576ca76dfbf7534d /deepseek-ai-dsh-host-directory-picker-native/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -33,8 +33,8 @@ en: 98afddc74b2d7010 zh: 39ed0d1a2508f78e /deepseek-ai-dsh-host-directory-picker-native/understand-the-implementation/platform-mechanics: - en: a78d75054b81753a - zh: 52867e73630b1be2 + en: cbc4ceea15a89d43 + zh: ce659dc6993cb25b /deepseek-ai-dsh-host-directory-picker-native/understand-the-implementation/source-map: en: 22ecabd6b74d6ecb zh: af59381cf2957b68 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 1449e3e2fc..2bed180062 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -An operator at the host's display picks a workspace directory through a native OS chooser: `dsh-host-directory-picker-native` opens one platform directory chooser per pick and resolves the chosen absolute path (`null` on cancel). macOS drives `osascript`, Linux uses Zenity with a KDialog fallback, and Windows opens the modern `IFileOpenDialog` in a spawned child process. Only viable when the operator sits at the host's display — remote deployments compose the [browse backend](../directory-picker-browse/README.md) instead. One composition row also registers the matching browser-side interaction in the workspace flow, so it selects both sides. +An operator at the host's display picks a workspace directory through a native OS chooser: `dsh-host-directory-picker-native` opens one platform directory chooser per pick and resolves the chosen absolute path (`null` on cancel). macOS drives a scripted AppKit panel that carries the standard Edit menu, Linux uses Zenity with a KDialog fallback, and Windows opens the modern `IFileOpenDialog` in a spawned child process. Only viable when the operator sits at the host's display — remote deployments compose the [browse backend](../directory-picker-browse/README.md) instead. One composition row also registers the matching browser-side interaction in the workspace flow, so it selects both sides. ## Table of Contents @@ -53,7 +53,7 @@ The backend is a thin service over a platform chooser: `NativeDirectoryPicker` r ### Platform mechanics -Platform tools run without a shell: `osascript` on macOS, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Immediately before `Show`, the child synthesizes one Alt press through `keybd_event`, which lets the dialog activate as the foreground window even when a background host process spawned the child. +Platform tools run without a shell: on macOS one `osascript -l JavaScript` script runs through the system JXA host, installs a main menu carrying the standard Edit key equivalents, and opens `NSOpenPanel`. A native panel resolves ⌘C/⌘V/⌘A/⌘X against that menu rather than the focused field, which AppleScript's menu-less `choose folder` dialog could not provide. Linux uses Zenity with a KDialog fallback, and the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Immediately before `Show`, the child synthesizes one Alt press through `keybd_event`, which lets the dialog activate as the foreground window even when a background host process spawned the child. ### Source map diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 968c26555e..65401564ba 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -坐在宿主屏幕前的操作者通过原生 OS 选择器选择工作区目录:`dsh-host-directory-picker-native` 每次选择打开一个平台目录选择器,并解析出所选绝对路径(取消时为 `null`)。macOS 驱动 `osascript`,Linux 使用 Zenity 并以 KDialog 回退,Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合[浏览后端](../directory-picker-browse/README.zh.md)。一行组合配置还会在工作区流程中注册匹配的浏览器侧交互,因此同时选择两侧。 +坐在宿主屏幕前的操作者通过原生 OS 选择器选择工作区目录:`dsh-host-directory-picker-native` 每次选择打开一个平台目录选择器,并解析出所选绝对路径(取消时为 `null`)。macOS 驱动带标准「编辑」菜单的脚本化 AppKit 面板,Linux 使用 Zenity 并以 KDialog 回退,Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合[浏览后端](../directory-picker-browse/README.zh.md)。一行组合配置还会在工作区流程中注册匹配的浏览器侧交互,因此同时选择两侧。 ## 目录 @@ -53,7 +53,7 @@ kind: "package-reference" ### 平台机制 -平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。在 `Show` 之前,子进程立即通过 `keybd_event` 合成一次 Alt 按键,让对话框即使由后台宿主进程 spawn 也能激活为前台窗口。 +平台工具不经 shell 调用:macOS 上由系统 JXA 宿主运行一段 `osascript -l JavaScript` 脚本,先安装携带标准编辑快捷键的菜单,再打开 `NSOpenPanel`;原生面板的 ⌘C/⌘V/⌘A/⌘X 由该菜单解析,而非由获得焦点的输入框解析——AppleScript 的 `choose folder` 对话框没有菜单,无法提供这一点。Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。在 `Show` 之前,子进程立即通过 `keybd_event` 合成一次 Alt 按键,让对话框即使由后台宿主进程 spawn 也能激活为前台窗口。 ### 源码地图 diff --git a/packages/host/directory-picker-native/src/index.ts b/packages/host/directory-picker-native/src/index.ts index 2fa0489ca3..0e01087e4a 100644 --- a/packages/host/directory-picker-native/src/index.ts +++ b/packages/host/directory-picker-native/src/index.ts @@ -1,7 +1,8 @@ /** * Native backend of the directory-picker seam: registers `ctx.directoryPicker` * with the `native` capability, opening one native OS chooser on the host - * display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback; + * display per pick (macOS a scripted AppKit panel that installs the standard + * Edit menu before opening `NSOpenPanel`, Linux Zenity with a KDialog fallback; * Windows opens the modern `IFileOpenDialog` in a spawned child process — a * koffi-driven COM conversation on the child's main thread, preceded by a * synthesized Alt press so the dialog activates as foreground even when a diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index fb682599ec..6355471745 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -14,6 +14,52 @@ export interface DirectoryPickerInternals { pickWin32Dialog?: (signal: AbortSignal) => Promise } +/** The prompt every native chooser shows. */ +const DIALOG_TITLE = 'Select Workspace Directory' + +/** + * macOS panel source, run by the system JXA host (`osascript -l JavaScript`). + * + * A native panel resolves ⌘C/⌘V/⌘A/⌘X as main-menu key equivalents rather than + * as focused-field behavior, so the script installs that menu before opening + * `NSOpenPanel`; AppleScript's `choose folder` runs in a helper that has no + * menu, which leaves every text field of its panel (Go to Folder, Search, + * Tags) un-pastable. Menu items stay untargeted, so AppKit enables them + * against the responder chain that owns the panel's field editor. The prompt + * arrives as argv, never as executable source, and a cancel prints an empty + * line instead of failing. + */ +const MAC_PANEL = ` +ObjC.import('AppKit'); +function run(argv) { + var app = $.NSApplication.sharedApplication; + app.setActivationPolicy($.NSApplicationActivationPolicyAccessory); + var mainMenu = $.NSMenu.alloc.init; + mainMenu.addItem($.NSMenuItem.alloc.init); + var editMenu = $.NSMenu.alloc.initWithTitle('Edit'); + var editItem = $.NSMenuItem.alloc.init; + editItem.submenu = editMenu; + mainMenu.addItem(editItem); + editMenu.addItemWithTitleActionKeyEquivalent('Undo', 'undo:', 'z'); + editMenu.addItemWithTitleActionKeyEquivalent('Redo', 'redo:', 'Z'); + editMenu.addItemWithTitleActionKeyEquivalent('Cut', 'cut:', 'x'); + editMenu.addItemWithTitleActionKeyEquivalent('Copy', 'copy:', 'c'); + editMenu.addItemWithTitleActionKeyEquivalent('Paste', 'paste:', 'v'); + editMenu.addItemWithTitleActionKeyEquivalent('Select All', 'selectAll:', 'a'); + app.mainMenu = mainMenu; + app.activateIgnoringOtherApps(true); + var panel = $.NSOpenPanel.openPanel; + panel.canChooseFiles = false; + panel.canChooseDirectories = true; + panel.canCreateDirectories = true; + panel.allowsMultipleSelection = false; + panel.message = argv[0]; + panel.runModal; + var urls = panel.URLs; + if (urls.count < 1) return ''; + return ObjC.unwrap(urls.objectAtIndex(0).path); +}` + function outputPath(stdout: string): string | null { const path = stdout.replace(/[\r\n]+$/, '') return path === '' ? null : path @@ -25,12 +71,6 @@ function errorCode(error: unknown): string | number | undefined { return typeof code === 'string' || typeof code === 'number' ? code : undefined } -function errorStderr(error: unknown): string { - if (typeof error !== 'object' || error === null || !('stderr' in error)) return '' - const stderr = (error as { stderr?: unknown }).stderr - return typeof stderr === 'string' ? stderr : '' -} - function isMissingCommand(error: unknown): boolean { return errorCode(error) === 'ENOENT' } @@ -53,17 +93,8 @@ export async function pickNativeDirectory( const run = internals.run ?? runNativeCommand if (platform === 'darwin') { - try { - const result = await run('osascript', [ - '-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', - '-e', 'POSIX path of selectedFolder', - ], signal, 'hidden') - return outputPath(result.stdout) - } catch (error: unknown) { - if (!signal.aborted && errorCode(error) === 1 - && /(?:User canceled|-128)/i.test(errorStderr(error))) return null - throw error - } + const result = await run('osascript', ['-l', 'JavaScript', '-e', MAC_PANEL, DIALOG_TITLE], signal, 'hidden') + return outputPath(result.stdout) } if (platform === 'win32') { @@ -79,7 +110,7 @@ export async function pickNativeDirectory( if (platform === 'linux') { try { const result = await run('zenity', [ - '--file-selection', '--directory', '--title=Select Workspace Directory', + '--file-selection', '--directory', `--title=${DIALOG_TITLE}`, ], signal, 'hidden') return outputPath(result.stdout) } catch (error: unknown) { @@ -90,7 +121,7 @@ export async function pickNativeDirectory( try { const result = await run('kdialog', [ - '--getexistingdirectory', '.', '--title', 'Select Workspace Directory', + '--getexistingdirectory', '.', '--title', DIALOG_TITLE, ], signal, 'hidden') return outputPath(result.stdout) } catch (error: unknown) { diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 45e04fd325..85aae1b409 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -1,7 +1,7 @@ /** - * Native picker tier selection and the execFile adapter: the Win32 dialog - * primary (failures surface as-is, no fallback tier), the abort rule, and - * the POSIX command tiers (osascript, Zenity → KDialog). + * Native picker tier selection and the execFile adapter: the macOS Edit-menu + * panel, the Win32 dialog primary (failures surface as-is, no fallback tier), + * the abort rule, and the POSIX command tiers (Zenity → KDialog). */ type ExecFileCallback = ( @@ -33,26 +33,36 @@ const signal = () => new AbortController().signal const noDialog = async (): Promise => { throw new Error('dialog unavailable') } describe('native directory picker', () => { - it('uses the macOS folder chooser and maps user cancellation to null', async () => { + it('opens the macOS Edit-menu panel and maps an unanswered panel to null', async () => { const run = vi.fn(async () => ({ stdout: '/Users/test/project/\n', stderr: '' })) await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/') - expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal), 'hidden') - run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)')) + const call = run.mock.calls[0]! + expect(call[0]).toBe('osascript') + expect(call[1].slice(0, 3)).toEqual(['-l', 'JavaScript', '-e']) + // The panel's own Edit menu is what makes ⌘V reach the field editor: the + // macOS key equivalents resolve against the menu, never the focused field. + expect(call[1][3] ?? '').toContain("addItemWithTitleActionKeyEquivalent('Paste', 'paste:', 'v')") + expect(call[1][3] ?? '').toContain('canChooseDirectories = true') + expect(call[1][4]).toBe('Select Workspace Directory') + expect(call[2]).toBeInstanceOf(AbortSignal) + expect(call[3]).toBe('hidden') + + run.mockResolvedValueOnce({ stdout: '\n', stderr: '' }) await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull() + }) - run.mockRejectedValueOnce(failure(2, 'permission denied')) + it('surfaces a macOS panel failure instead of reading it as a cancel', async () => { + const run = vi.fn(async () => { throw failure(1, 'execution error: User canceled. (-128)') }) await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed') }) it.each([ ['a primitive error', 'failed'], ['an invalid code type', { code: true }], - ['a missing stderr property', { code: 1 }], - ['a non-string stderr property', { code: 1, stderr: 42 }], - ])('does not mistake %s for macOS cancellation', async (_label, reason) => { + ])('does not mistake %s for a user cancel', async (_label, reason) => { const run = vi.fn(async () => { throw reason }) - await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).rejects.toBe(reason) }) it('uses the Win32 dialog and never spawns a command when it answers', async () => {