mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
99caf8fdf5
commit
a5c13848fc
@ -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', {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user