fix: make changed-file open-with actions launch editors

Changed-file menus pass file paths while the workspace header passes directories. The server-side open-target path validation only accepted directories, so file-level editor launches failed before reaching the existing desktop open-with integration.

Constraint: Preserve the existing open-target API and frontend menu contract.
Rejected: Add a desktop-only file-opening endpoint | duplicates the shared open-target launch path.
Confidence: high
Scope-risk: narrow
Directive: Keep file-manager behavior platform-aware; Finder and Explorer can reveal/select files, while Linux xdg-open falls back to the parent directory.
Tested: bun test src/server/__tests__/open-target-service.test.ts src/server/__tests__/open-target-api.test.ts
Tested: cd desktop && bun run test src/components/chat/CurrentTurnChangeCard.test.tsx src/lib/openWithItems.test.ts
Tested: bun run check:server
Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 00:35:55 +08:00
parent 99caf8fdf5
commit a5c13848fc
2 changed files with 75 additions and 13 deletions

View File

@ -196,7 +196,7 @@ describe('openTargetService', () => {
}
})
it('rejects non-directory paths', async () => {
it('opens file paths in IDE targets', async () => {
const dir = await makeDir()
const file = join(dir, 'note.txt')
await writeFile(file, 'not a directory')
@ -206,7 +206,7 @@ describe('openTargetService', () => {
try {
await expect(service.openTarget({ targetId: 'vscode', path: file }))
.rejects.toMatchObject({ code: 'OPEN_TARGET_PATH_NOT_DIRECTORY' })
.resolves.toMatchObject({ ok: true, targetId: 'vscode', path: file })
} finally {
await rm(dir, { recursive: true, force: true })
}
@ -245,6 +245,21 @@ describe('openTargetService', () => {
}
})
it('reveals files in Finder instead of opening them with the default app', async () => {
const dir = await makeDir()
const file = join(dir, 'note.txt')
await writeFile(file, 'contents')
const { service, launched } = createService('darwin')
try {
await service.openTarget({ targetId: 'finder', path: file })
expect(launched).toEqual([{ command: 'open', args: ['-R', file] }])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('opens Windows command-shim targets through the resolved executable', async () => {
const dir = await makeDir()
const commandPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd'
@ -280,6 +295,38 @@ describe('openTargetService', () => {
}
})
it('selects files in Windows Explorer through the file-manager fallback', async () => {
const dir = await makeDir()
const file = join(dir, 'note.txt')
await writeFile(file, 'contents')
const { service, launched } = createService('win32')
try {
await service.openTarget({ targetId: 'explorer', path: file })
expect(launched).toEqual([{ command: 'explorer.exe', args: [`/select,${file}`] }])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('opens a file parent directory through the Linux file-manager fallback', async () => {
const dir = await makeDir()
const file = join(dir, 'note.txt')
await writeFile(file, 'contents')
const { service, launched } = createService('linux', {
commands: { 'xdg-open': true },
})
try {
await service.openTarget({ targetId: 'file-manager', path: file })
expect(launched).toEqual([{ command: 'xdg-open', args: [dir] }])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('reports launch failures instead of returning success', async () => {
const dir = await makeDir()
const { service } = createService('darwin', {

View File

@ -1,7 +1,7 @@
import { execFile as execFileCallback, spawn } from 'node:child_process'
import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { extname, join, posix as posixPath, resolve, win32 as winPath } from 'node:path'
import { dirname, extname, join, posix as posixPath, resolve, win32 as winPath } from 'node:path'
import { promisify } from 'node:util'
import { ApiError } from '../middleware/errorHandler.js'
@ -59,6 +59,11 @@ type LaunchPlan = {
args: string[]
}
type ResolvedOpenPath = {
path: string
isDirectory: boolean
}
type TargetDefinition = {
id: string
kind: OpenTargetKind
@ -557,20 +562,27 @@ async function isDetected(definition: TargetDefinition, runtime: Runtime): Promi
async function resolveLaunchPlan(
definition: TargetDefinition,
runtime: Runtime,
targetPath: string,
target: ResolvedOpenPath,
): Promise<LaunchPlan | null> {
if (!isSupportedOnPlatform(definition, runtime.platform)) {
return null
}
const targetPath = target.path
if (definition.fallback) {
switch (runtime.platform) {
case 'darwin':
if (definition.kind === 'file_manager' && !target.isDirectory) {
return { command: 'open', args: ['-R', targetPath] }
}
return { command: 'open', args: [targetPath] }
case 'win32':
if (definition.kind === 'file_manager' && !target.isDirectory) {
return { command: 'explorer.exe', args: [`/select,${targetPath}`] }
}
return { command: 'cmd.exe', args: ['/d', '/c', 'start', '', targetPath] }
case 'linux':
return { command: 'xdg-open', args: [targetPath] }
return { command: 'xdg-open', args: [target.isDirectory ? targetPath : dirname(targetPath)] }
default:
return null
}
@ -598,7 +610,7 @@ async function resolveLaunchPlan(
return null
}
async function validateDirectory(targetPath: string): Promise<string> {
async function validateOpenPath(targetPath: string): Promise<ResolvedOpenPath> {
const resolvedPath = resolve(targetPath)
let entry
try {
@ -606,20 +618,23 @@ async function validateDirectory(targetPath: string): Promise<string> {
} catch {
throw openTargetError(
400,
`Directory does not exist: ${resolvedPath}`,
`Path does not exist: ${resolvedPath}`,
'OPEN_TARGET_PATH_MISSING',
)
}
if (!entry.isDirectory()) {
if (!entry.isDirectory() && !entry.isFile()) {
throw openTargetError(
400,
`Path is not a directory: ${resolvedPath}`,
'OPEN_TARGET_PATH_NOT_DIRECTORY',
`Path is not a file or directory: ${resolvedPath}`,
'OPEN_TARGET_PATH_UNSUPPORTED',
)
}
return resolvedPath
return {
path: resolvedPath,
isDirectory: entry.isDirectory(),
}
}
function normalizeIconFileName(iconFile: string): string {
@ -957,7 +972,7 @@ export function createOpenTargetService(overrides: Partial<Runtime> = {}) {
)
}
const resolvedPath = await validateDirectory(input.path)
const resolvedPath = await validateOpenPath(input.path)
const launchPlan = await resolveLaunchPlan(definition, runtime, resolvedPath)
if (!launchPlan) {
throw openTargetError(
@ -979,7 +994,7 @@ export function createOpenTargetService(overrides: Partial<Runtime> = {}) {
return {
ok: true as const,
targetId: target.id,
path: resolvedPath,
path: resolvedPath.path,
}
}