From a5c13848fc8230f82c20f42bd99fbb2e3c88a1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 1 Jun 2026 00:35:55 +0800 Subject: [PATCH] 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 --- .../__tests__/open-target-service.test.ts | 51 ++++++++++++++++++- src/server/services/openTargetService.ts | 37 ++++++++++---- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts index afd66d8b..12410eb0 100644 --- a/src/server/__tests__/open-target-service.test.ts +++ b/src/server/__tests__/open-target-service.test.ts @@ -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', { diff --git a/src/server/services/openTargetService.ts b/src/server/services/openTargetService.ts index ba9d0744..45f7dd3b 100644 --- a/src/server/services/openTargetService.ts +++ b/src/server/services/openTargetService.ts @@ -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 { 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 { +async function validateOpenPath(targetPath: string): Promise { const resolvedPath = resolve(targetPath) let entry try { @@ -606,20 +618,23 @@ async function validateDirectory(targetPath: string): Promise { } 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 = {}) { ) } - 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 = {}) { return { ok: true as const, targetId: target.id, - path: resolvedPath, + path: resolvedPath.path, } }