ui-workspace's two trigger surfaces each declare a single-kind directory-flow hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow, same owner contract) and keep only the trigger and the adoption: the Open-local- folder entry renders while the surface's hole is occupied, and the occupant reports one picked path per open through the hole's owner conversation (open/busy/onPicked/onCancel/onError). directory-picker-native becomes dual-face: its browser half fills both holes with a renderless occupant driving host.pickDirectory, so the cordis.yml row that mounts the backend also composes the client interaction — a mismatch is impossible and a second flow package fails at client load. With composition wiring both sides, the host.describe.directoryPicker advertisement and the client's kind branching lose their last consumer: the field, WorkspacesService.directoryPickerKind(), the DirectoryPickerKind wire type, and the picker's per-open describe read are deleted. The connection fixture now serves a deterministic pickDirectory path so the keyless snapshot drives the full pick-then-adopt flow. ui-workspace's hand-rolled declaration deferral is replaced by the deferRegistration helper it duplicated.
112 lines
3.9 KiB
TypeScript
112 lines
3.9 KiB
TypeScript
/** Cross-platform native single-directory chooser behind the native backend's capability. */
|
|
|
|
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
|
|
|
/** Testable command boundary; native implementations never invoke a shell. */
|
|
export type DirectoryPickerRunner = NativeCommandRunner
|
|
|
|
/** Injectable platform facts for deterministic adapter tests. */
|
|
export interface DirectoryPickerInternals {
|
|
platform?: NodeJS.Platform
|
|
run?: DirectoryPickerRunner
|
|
}
|
|
|
|
function outputPath(stdout: string): string | null {
|
|
const path = stdout.replace(/[\r\n]+$/, '')
|
|
return path === '' ? null : path
|
|
}
|
|
|
|
function errorCode(error: unknown): string | number | undefined {
|
|
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined
|
|
const code = (error as { code?: unknown }).code
|
|
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'
|
|
}
|
|
|
|
function rethrowIfAborted(signal: AbortSignal, error: unknown): void {
|
|
if (signal.aborted) throw error
|
|
}
|
|
|
|
/**
|
|
* Open the platform directory picker.
|
|
* @param signal - caller/connection lifetime; abort terminates the native command.
|
|
* @param internals - platform and runner seam for deterministic tests.
|
|
* @returns the selected path, or null when the user cancels.
|
|
*/
|
|
export async function pickNativeDirectory(
|
|
signal: AbortSignal,
|
|
internals: DirectoryPickerInternals = {},
|
|
): Promise<string | null> {
|
|
const platform = internals.platform ?? process.platform
|
|
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)
|
|
return outputPath(result.stdout)
|
|
} catch (error: unknown) {
|
|
if (!signal.aborted && errorCode(error) === 1
|
|
&& /(?:User canceled|-128)/i.test(errorStderr(error))) return null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
if (platform === 'win32') {
|
|
const script = [
|
|
"$ErrorActionPreference = 'Stop'",
|
|
'Add-Type -AssemblyName System.Windows.Forms',
|
|
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
"$dialog.Description = 'Select Workspace Directory'",
|
|
'$dialog.ShowNewFolderButton = $true',
|
|
'$result = $dialog.ShowDialog()',
|
|
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
|
|
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
' [Console]::WriteLine($dialog.SelectedPath)',
|
|
'}',
|
|
].join('; ')
|
|
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
|
|
return outputPath(result.stdout)
|
|
}
|
|
|
|
if (platform === 'linux') {
|
|
try {
|
|
const result = await run('zenity', [
|
|
'--file-selection', '--directory', '--title=Select Workspace Directory',
|
|
], signal)
|
|
return outputPath(result.stdout)
|
|
} catch (error: unknown) {
|
|
rethrowIfAborted(signal, error)
|
|
if (errorCode(error) === 1) return null
|
|
if (!isMissingCommand(error)) throw error
|
|
}
|
|
|
|
try {
|
|
const result = await run('kdialog', [
|
|
'--getexistingdirectory', '.', '--title', 'Select Workspace Directory',
|
|
], signal)
|
|
return outputPath(result.stdout)
|
|
} catch (error: unknown) {
|
|
rethrowIfAborted(signal, error)
|
|
if (errorCode(error) === 1) return null
|
|
if (isMissingCommand(error)) {
|
|
throw new Error('no supported native directory picker found (install zenity or kdialog)')
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
throw new Error(`native directory picker is unsupported on ${platform}`)
|
|
}
|