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, } }