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:
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 () => {
|
||||
|
||||
Reference in new issue
Block a user