Merge pull request #6126 from deepseek-harness/fix/macos-directory-picker-edit-menu

fix(directory-picker-native): install the macOS panel's Edit menu
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-13 16:23:30 +08:00
commit f98cfe3cf3
6 files changed
+81 -39

No files matched your search

@@ -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
@@ -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
@@ -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 也能激活为前台窗口。
### 源码地图
@@ -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
@@ -14,6 +14,52 @@ export interface DirectoryPickerInternals {
pickWin32Dialog?: (signal: AbortSignal) => Promise<string | null>
}
/** 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) {
@@ -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<string | null> => { 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<DirectoryPickerRunner>(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<DirectoryPickerRunner>(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<DirectoryPickerRunner>(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 () => {