diff --git a/src/main/workspace/index.ts b/src/main/workspace/index.ts index 807830bf49..9e5e995e36 100644 --- a/src/main/workspace/index.ts +++ b/src/main/workspace/index.ts @@ -12,6 +12,7 @@ import { type WatchHandle } from '@/platform/fileWatcher' import { readDirectoryShallow } from './directoryReader' +import { listFileOpenApps, openFileWithApp } from './openInApp' import { searchWorkspaceFiles } from './workspaceFileSearch' import { createWorkspacePreviewFileUrl, @@ -25,6 +26,7 @@ import type { WorkspaceServicePort, ResolveMarkdownLinkedFileInput, WorkspaceFileNode, + WorkspaceFileOpenApp, WorkspaceFilePreview, WorkspaceFilePreviewKind, WorkspaceGitChangeType, @@ -796,15 +798,30 @@ export class WorkspaceService implements WorkspaceServicePort { } const normalizedPath = path.resolve(filePath) + const errorMessage = await shell.openPath(normalizedPath) - try { - const errorMessage = await shell.openPath(normalizedPath) - if (errorMessage) { - console.error(`[Workspace] Failed to open path: ${normalizedPath}`, errorMessage) - } - } catch (error) { - console.error(`[Workspace] Failed to open path: ${normalizedPath}`, error) + if (errorMessage) { + throw new Error(errorMessage) + } + } + + async listFileOpenApps(filePath: string): Promise { + if (!this.isPathAllowed(filePath)) { + console.warn(`[Workspace] Blocked open-with listing for unauthorized path: ${filePath}`) + return [] } + + return listFileOpenApps(path.resolve(filePath)) + } + + async openFileWithApp(filePath: string, appId: string): Promise { + if (!this.isPathAllowed(filePath)) { + console.warn(`[Workspace] Blocked open-with attempt for unauthorized path: ${filePath}`) + throw new Error('Path is not authorized for this workspace') + } + + const normalizedPath = path.resolve(filePath) + await openFileWithApp(normalizedPath, appId) } async resolveMarkdownLinkedFile( diff --git a/src/main/workspace/openInApp/detectors.ts b/src/main/workspace/openInApp/detectors.ts new file mode 100644 index 0000000000..25b61efbb3 --- /dev/null +++ b/src/main/workspace/openInApp/detectors.ts @@ -0,0 +1,311 @@ +import fs from 'fs' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { app } from 'electron' +import { + appsForPlatform, + toFileOpenAppPlatform, + type WorkspaceFileOpenAppDefinition, + type WorkspaceFileOpenAppLaunch, + type WorkspaceFileOpenAppPlatform +} from '@shared/workspace/fileOpenApps' +import { extractMacOSIcons } from './iconExtractor' +import { + desktopEntryAcceptsFiles, + findDesktopEntry, + readDesktopEntry, + readDesktopEntryIcon +} from './linuxDesktopEntries' + +const execFileAsync = promisify(execFile) + +const PROBE_TIMEOUT_MS = 8_000 +const SAFE_BINARY_REGEX = /^[\w.-]+$/ + +/** + * A detected app: how to launch it, plus its real icon as a PNG data URL. + * + * `launchTarget` is a bundle path on macOS, an executable path on Windows, and + * either a binary path or a `.desktop` path on Linux. `launchOverride` narrows + * the registry's declared strategy to whichever one detection actually resolved. + */ +export type DetectedApp = { + definition: WorkspaceFileOpenAppDefinition + launchTarget: string + launchOverride?: WorkspaceFileOpenAppLaunch + iconDataUrl?: string +} + +/** + * Batch-resolve macOS bundle ids to bundle paths in one subprocess. Uses Launch + * Services rather than probing hardcoded `/Applications` paths, so apps in + * `~/Applications` are found too. + */ +const MACOS_RESOLVE_SCRIPT = `ObjC.import('AppKit') +function run(argv) { + const workspace = $.NSWorkspace.sharedWorkspace + const result = {} + for (const bundleId of JSON.parse(argv[0])) { + const url = workspace.URLForApplicationWithBundleIdentifier(bundleId) + result[bundleId] = url && !url.isNil() ? ObjC.unwrap(url.path) : null + } + return JSON.stringify(result) +}` + +async function detectDarwin(definitions: WorkspaceFileOpenAppDefinition[]): Promise { + const bundleIds = [ + ...new Set( + definitions.flatMap((definition) => { + const detect = definition.detect.darwin + return detect?.type === 'macBundleId' ? detect.bundleIds : [] + }) + ) + ] + + const { stdout } = await execFileAsync( + 'osascript', + ['-l', 'JavaScript', '-e', MACOS_RESOLVE_SCRIPT, JSON.stringify(bundleIds)], + { timeout: PROBE_TIMEOUT_MS } + ) + const resolved = JSON.parse(stdout.trim()) as Record + + const detected: DetectedApp[] = [] + for (const definition of definitions) { + const detect = definition.detect.darwin + if (detect?.type !== 'macBundleId') { + continue + } + + const launchTarget = detect.bundleIds + .map((bundleId) => resolved[bundleId]) + .find((bundlePath): bundlePath is string => Boolean(bundlePath)) + + if (launchTarget) { + detected.push({ definition, launchTarget }) + } + } + + const icons = await extractMacOSIcons(detected.map((entry) => entry.launchTarget)) + return detected.map((entry) => ({ ...entry, iconDataUrl: icons.get(entry.launchTarget) })) +} + +/** Expand `%VAR%` references, which App Paths stores as REG_EXPAND_SZ. */ +function expandWindowsEnvironmentVariables(value: string): string { + const environment = new Map( + Object.entries(process.env).map(([name, variableValue]) => [name.toLowerCase(), variableValue]) + ) + return value.replace(/%([^%]+)%/g, (reference, name: string) => { + return environment.get(name.toLowerCase()) ?? reference + }) +} + +/** + * Read the default value of a registry key. + * + * Anchors on the `REG_SZ` / `REG_EXPAND_SZ` type column rather than the value + * name: `reg query /ve` localizes the `(Default)` label, so matching that name + * fails on non-English Windows. + */ +async function readRegistryDefault(key: string): Promise { + try { + const { stdout } = await execFileAsync('reg', ['query', key, '/ve'], { + timeout: PROBE_TIMEOUT_MS, + windowsHide: true + }) + + const match = stdout.match(/\s{2,}REG_(EXPAND_SZ|SZ)\s{2,}(.+)/i) + const registryType = match?.[1] + const value = match?.[2]?.trim().replace(/^"|"$/g, '') + if (!registryType || !value) { + return null + } + + return registryType.toUpperCase() === 'EXPAND_SZ' + ? expandWindowsEnvironmentVariables(value) + : value + } catch { + return null + } +} + +/** + * Resolve a Windows executable to a full path: App Paths under HKCU first + * (per-user installers, the default for VS Code and Cursor, cannot write HKLM), + * then HKLM, then PATH. The PATH result is filtered to real `.exe` files because + * the CLI shims are `.cmd` wrappers that cannot be spawned directly. + */ +async function resolveWindowsExecutable(exeName: string): Promise { + const suffix = `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exeName}` + + for (const root of ['HKCU', 'HKLM']) { + const resolved = await readRegistryDefault(`${root}\\${suffix}`) + if (resolved && fs.existsSync(resolved)) { + return resolved + } + } + + try { + const { stdout } = await execFileAsync('where', [exeName], { + timeout: PROBE_TIMEOUT_MS, + windowsHide: true + }) + return ( + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.toLowerCase().endsWith('.exe') && fs.existsSync(line)) ?? null + ) + } catch { + return null + } +} + +async function detectWin32(definitions: WorkspaceFileOpenAppDefinition[]): Promise { + const results = await Promise.all( + definitions.map(async (definition): Promise => { + const detect = definition.detect.win32 + if (detect?.type !== 'winExecutable') { + return null + } + + for (const exeName of detect.exeNames) { + const launchTarget = await resolveWindowsExecutable(exeName) + if (!launchTarget) { + continue + } + + // On Windows the icon lives in the executable, so Electron can read it. + return { + definition, + launchTarget, + iconDataUrl: await readFileIconDataUrl(launchTarget) + } + } + + return null + }) + ) + + return results.filter((entry): entry is DetectedApp => entry !== null) +} + +async function readFileIconDataUrl(filePath: string): Promise { + const icon = await app.getFileIcon(filePath, { size: 'normal' }).catch(() => null) + return icon && !icon.isEmpty() ? icon.toDataURL() : undefined +} + +/** Icon for a PATH-resolved Linux binary: desktop entry first, then the binary itself. */ +async function readLinuxBinaryIcon( + binaryPath: string, + desktopIds?: string[] +): Promise { + for (const desktopId of desktopIds ?? []) { + const entryPath = findDesktopEntry(desktopId) + if (!entryPath) { + continue + } + + const content = readDesktopEntry(entryPath) + const iconDataUrl = content ? readDesktopEntryIcon(content) : undefined + if (iconDataUrl) { + return iconDataUrl + } + } + + return readFileIconDataUrl(binaryPath) +} + +/** `command -v` lookup. The binary name is validated to keep it out of the shell. */ +async function resolveLinuxBinary(binary: string): Promise { + if (!SAFE_BINARY_REGEX.test(binary)) { + console.warn(`[Workspace] Rejecting unsafe binary name: ${binary}`) + return null + } + + try { + const { stdout } = await execFileAsync('/bin/sh', ['-c', `command -v "${binary}"`], { + timeout: PROBE_TIMEOUT_MS + }) + return stdout.trim() || null + } catch { + return null + } +} + +/** + * Detect a Linux app, preferring an exec'able binary over a desktop entry. + * + * A binary can carry CLI flags, which terminals need for their working + * directory. A desktop entry is the fallback for installs that leave no binary + * on PATH (JetBrains Toolbox without a CLI launcher, Flatpaks, some distro + * packages), and is only accepted when it declares a `%f`/`%u` field code — + * `gio launch` silently drops the path otherwise, so the app would open empty. + */ +async function detectLinuxApp( + definition: WorkspaceFileOpenAppDefinition +): Promise { + const detect = definition.detect.linux + if (detect?.type !== 'linuxApp') { + return null + } + + if (detect.binary) { + const binaryPath = await resolveLinuxBinary(detect.binary) + if (binaryPath) { + // No override: the registry's `exec` strategy carries the CLI flags that + // terminals need for their working directory. + return { + definition, + launchTarget: binaryPath, + iconDataUrl: await readLinuxBinaryIcon(binaryPath, detect.desktopIds) + } + } + } + + for (const desktopId of detect.desktopIds ?? []) { + const entryPath = findDesktopEntry(desktopId) + if (!entryPath) { + continue + } + + const content = readDesktopEntry(entryPath) + if (!content || !desktopEntryAcceptsFiles(content)) { + continue + } + + return { + definition, + launchTarget: entryPath, + launchOverride: { type: 'desktopEntry' }, + iconDataUrl: readDesktopEntryIcon(content) + } + } + + return null +} + +async function detectLinux(definitions: WorkspaceFileOpenAppDefinition[]): Promise { + const results = await Promise.all(definitions.map((definition) => detectLinuxApp(definition))) + return results.filter((entry): entry is DetectedApp => entry !== null) +} + +/** + * Detect every registry app available on the current platform. Registry order is + * preserved so callers can apply their own ranking. + * + * Rejects on probe failure rather than returning an empty list, so a caller + * caching the result cannot pin "no apps installed" after one transient error. + */ +export async function detectInstalledApps( + platform: WorkspaceFileOpenAppPlatform = toFileOpenAppPlatform(process.platform) +): Promise { + const definitions = appsForPlatform(platform) + + if (platform === 'darwin') { + return detectDarwin(definitions) + } + if (platform === 'win32') { + return detectWin32(definitions) + } + return detectLinux(definitions) +} diff --git a/src/main/workspace/openInApp/iconExtractor.ts b/src/main/workspace/openInApp/iconExtractor.ts new file mode 100644 index 0000000000..2063879211 --- /dev/null +++ b/src/main/workspace/openInApp/iconExtractor.ts @@ -0,0 +1,169 @@ +import path from 'path' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { mkdtemp, readFile, unlink } from 'fs/promises' +import { tmpdir } from 'os' + +const execFileAsync = promisify(execFile) + +/** 64px keeps the payload small while staying crisp at the renderer's 16px on Retina. */ +const ICON_SIZE = 64 +const EXEC_TIMEOUT_MS = 5_000 + +let tmpDirPromise: Promise | null = null + +async function ensureTmpDir(): Promise { + if (!tmpDirPromise) { + tmpDirPromise = mkdtemp(path.join(tmpdir(), 'deepchat-openinapp-')).catch(() => null) + } + return tmpDirPromise +} + +/** + * Render an app bundle's icon via `plutil` + `sips`. + * + * Both ship with every macOS install. This is preferred over drawing an + * `NSImage` in JXA because it needs no graphics context, so it also works when + * the process has no window server connection. + * + * Returns null when the bundle declares no `CFBundleIconFile` (asset-catalog + * only apps), which is the case the JXA fallback covers. + */ +async function extractIconWithSips(bundlePath: string): Promise { + const tmpDir = await ensureTmpDir() + if (!tmpDir) { + return null + } + + let icnsPath: string + try { + const { stdout } = await execFileAsync( + 'plutil', + ['-extract', 'CFBundleIconFile', 'raw', path.join(bundlePath, 'Contents', 'Info.plist')], + { timeout: EXEC_TIMEOUT_MS } + ) + const iconName = stdout.trim() + if (!iconName) { + return null + } + const fileName = iconName.endsWith('.icns') ? iconName : `${iconName}.icns` + icnsPath = path.join(bundlePath, 'Contents', 'Resources', fileName) + } catch { + return null + } + + const outPath = path.join(tmpDir, `${path.basename(bundlePath, '.app')}-${Date.now()}.png`) + try { + await execFileAsync( + 'sips', + ['-z', `${ICON_SIZE}`, `${ICON_SIZE}`, '-s', 'format', 'png', icnsPath, '--out', outPath], + { timeout: EXEC_TIMEOUT_MS } + ) + const buffer = await readFile(outPath) + return buffer.length > 0 ? `data:image/png;base64,${buffer.toString('base64')}` : null + } catch { + return null + } finally { + void unlink(outPath).catch(() => undefined) + } +} + +/** + * JXA fallback that renders the Launch Services icon for bundles `sips` cannot + * resolve. Batched into one subprocess, keyed by bundle path. + */ +const MACOS_ICON_FALLBACK_SCRIPT = `ObjC.import('AppKit') +function run(argv) { + const size = parseInt(argv[1], 10) + const workspace = $.NSWorkspace.sharedWorkspace + const result = {} + + for (const bundlePath of JSON.parse(argv[0])) { + let icon = null + try { + const image = workspace.iconForFile(bundlePath) + if (image && !image.isNil()) { + const canvas = $.NSImage.alloc.initWithSize($.NSMakeSize(size, size)) + canvas.lockFocus + image.drawInRect($.NSMakeRect(0, 0, size, size)) + canvas.unlockFocus + const tiff = canvas.TIFFRepresentation + if (tiff && !tiff.isNil()) { + const rep = $.NSBitmapImageRep.imageRepWithData(tiff) + const png = rep.representationUsingTypeProperties(4, $()) + icon = ObjC.unwrap(png.base64EncodedStringWithOptions(0)) + } + } + } catch (error) { + icon = null + } + result[bundlePath] = icon + } + + return JSON.stringify(result) +}` + +async function extractIconsWithJxa(bundlePaths: string[]): Promise> { + const icons = new Map() + if (bundlePaths.length === 0) { + return icons + } + + try { + const { stdout } = await execFileAsync( + 'osascript', + [ + '-l', + 'JavaScript', + '-e', + MACOS_ICON_FALLBACK_SCRIPT, + JSON.stringify(bundlePaths), + `${ICON_SIZE}` + ], + { timeout: EXEC_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 } + ) + + for (const [bundlePath, base64] of Object.entries( + JSON.parse(stdout.trim()) as Record + )) { + if (base64) { + icons.set(bundlePath, `data:image/png;base64,${base64}`) + } + } + } catch (error) { + console.warn('[Workspace] JXA icon fallback failed', error) + } + + return icons +} + +/** + * Resolve icons for the given macOS bundle paths: `sips` first, then one batched + * JXA pass for whatever it could not resolve. + */ +export async function extractMacOSIcons(bundlePaths: string[]): Promise> { + const icons = new Map() + + const sipsResults = await Promise.all( + bundlePaths.map( + async (bundlePath) => [bundlePath, await extractIconWithSips(bundlePath)] as const + ) + ) + + const unresolved: string[] = [] + for (const [bundlePath, icon] of sipsResults) { + if (icon) { + icons.set(bundlePath, icon) + } else { + unresolved.push(bundlePath) + } + } + + if (unresolved.length > 0) { + for (const [bundlePath, icon] of await extractIconsWithJxa(unresolved)) { + icons.set(bundlePath, icon) + } + } + + return icons +} diff --git a/src/main/workspace/openInApp/index.ts b/src/main/workspace/openInApp/index.ts new file mode 100644 index 0000000000..22afe9f346 --- /dev/null +++ b/src/main/workspace/openInApp/index.ts @@ -0,0 +1,160 @@ +import path from 'path' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { appsForPlatform, toFileOpenAppPlatform } from '@shared/workspace/fileOpenApps' +import type { WorkspaceFileOpenApp } from '@shared/types/workspace' +import { detectInstalledApps, type DetectedApp } from './detectors' +import { launchApp } from './launchers' + +const execFileAsync = promisify(execFile) + +const PROBE_TIMEOUT_MS = 8_000 +/** Re-probe occasionally so apps installed while running eventually show up. */ +const CACHE_TTL_MS = 60_000 + +type Cached = { probedAt: number; value: Promise } + +/** Detection is expensive but machine-global, so cache it behind a short TTL. */ +let installedCache: Cached | null = null + +/** OS handler lists depend only on file type, so cache them per extension. */ +const handlerCache = new Map>>() + +const isFresh = (cached: Cached | null | undefined): boolean => + Boolean(cached && Date.now() - cached.probedAt < CACHE_TTL_MS) + +async function listInstalledApps(): Promise { + if (isFresh(installedCache)) { + return installedCache!.value + } + + const value = detectInstalledApps() + .catch((error) => { + console.warn('[Workspace] Failed to detect installed editors and terminals', error) + return [] as DetectedApp[] + }) + .then((apps) => { + // Never cache an empty result. It means either a failed probe, which must + // be retried, or genuinely nothing installed, where one extra subprocess + // costs little. Windows and Linux probes swallow per-app errors, so an + // empty list is the only signal that detection went wrong there. + if (apps.length === 0) { + installedCache = null + } + return apps + }) + + installedCache = { probedAt: Date.now(), value } + return value +} + +/** + * Bundle ids Launch Services registers as handlers for a file, used only to rank + * the picker. macOS only; elsewhere ranking falls back to registry order. + */ +const MACOS_HANDLERS_SCRIPT = `ObjC.import('AppKit') +function run(argv) { + const url = $.NSURL.fileURLWithPath(argv[0]) + const workspace = $.NSWorkspace.sharedWorkspace + const bundleIds = [] + const candidates = workspace.URLsForApplicationsToOpenURL(url) + if (candidates && !candidates.isNil()) { + const total = candidates.count + for (let index = 0; index < total; index += 1) { + const bundle = $.NSBundle.bundleWithURL(candidates.objectAtIndex(index)) + const hasId = bundle && !bundle.isNil() && bundle.bundleIdentifier + if (hasId && !bundle.bundleIdentifier.isNil()) { + bundleIds.push(ObjC.unwrap(bundle.bundleIdentifier)) + } + } + } + return JSON.stringify(bundleIds) +}` + +async function listRegisteredHandlerIds(filePath: string): Promise> { + if (process.platform !== 'darwin') { + return new Set() + } + + const cacheKey = path.extname(filePath).toLowerCase() || path.basename(filePath).toLowerCase() + const cached = handlerCache.get(cacheKey) + if (isFresh(cached)) { + return cached!.value + } + + const value = (async () => { + try { + const { stdout } = await execFileAsync( + 'osascript', + ['-l', 'JavaScript', '-e', MACOS_HANDLERS_SCRIPT, filePath], + { timeout: PROBE_TIMEOUT_MS } + ) + const handlerBundleIds = new Set(JSON.parse(stdout.trim()) as string[]) + + return new Set( + appsForPlatform('darwin') + .filter((definition) => { + const detect = definition.detect.darwin + return ( + detect?.type === 'macBundleId' && + detect.bundleIds.some((bundleId) => handlerBundleIds.has(bundleId)) + ) + }) + .map((definition) => definition.id) + ) + } catch (error) { + console.warn(`[Workspace] Failed to read registered handlers for: ${filePath}`, error) + handlerCache.delete(cacheKey) + return new Set() + } + })() + + handlerCache.set(cacheKey, { probedAt: Date.now(), value }) + return value +} + +/** + * List the installed editors, IDEs and terminals offered for a file. + * + * Editors the OS registers for this file type come first, then the remaining + * installed editors, then terminals. Terminals never register as file handlers, + * so they are always included when installed. + */ +export async function listFileOpenApps(filePath: string): Promise { + const [installed, registeredIds] = await Promise.all([ + listInstalledApps(), + listRegisteredHandlerIds(filePath) + ]) + + const toPayload = (entry: DetectedApp): WorkspaceFileOpenApp => ({ + id: entry.definition.id, + name: entry.definition.name, + kind: entry.definition.kind, + iconDataUrl: entry.iconDataUrl + }) + + const editors = installed.filter((entry) => entry.definition.kind === 'editor') + const terminals = installed.filter((entry) => entry.definition.kind === 'terminal') + + return [ + ...editors.filter((entry) => registeredIds.has(entry.definition.id)).map(toPayload), + ...editors.filter((entry) => !registeredIds.has(entry.definition.id)).map(toPayload), + ...terminals.map(toPayload) + ] +} + +/** + * Open a file with one of the apps reported by {@link listFileOpenApps}. + * + * The id must belong to an installed registry app, so a renderer cannot turn + * this into an arbitrary command launcher. + */ +export async function openFileWithApp(filePath: string, appId: string): Promise { + const installed = await listInstalledApps() + const target = installed.find((entry) => entry.definition.id === appId) + if (!target) { + throw new Error(`Unknown or unavailable application: ${appId}`) + } + + await launchApp(target, filePath, toFileOpenAppPlatform(process.platform)) +} diff --git a/src/main/workspace/openInApp/launchers.ts b/src/main/workspace/openInApp/launchers.ts new file mode 100644 index 0000000000..a1676cfe78 --- /dev/null +++ b/src/main/workspace/openInApp/launchers.ts @@ -0,0 +1,70 @@ +import path from 'path' +import { execFile, spawn } from 'child_process' +import { promisify } from 'util' +import { + buildLaunchArgs, + toFileOpenAppPlatform, + type WorkspaceFileOpenAppPlatform +} from '@shared/workspace/fileOpenApps' +import type { DetectedApp } from './detectors' + +const execFileAsync = promisify(execFile) + +const LAUNCH_TIMEOUT_MS = 10_000 + +/** + * Launch a detected app against a path. + * + * Editors receive the file; terminals receive its containing directory, because + * handing a file to a terminal makes some of them try to execute it. + * + * Rejects when the app has no launch strategy for this platform or the process + * fails to start, so the caller can tell the user instead of silently doing + * something else. + */ +export async function launchApp( + target: DetectedApp, + filePath: string, + platform: WorkspaceFileOpenAppPlatform = toFileOpenAppPlatform(process.platform) +): Promise { + // Detection knows whether it resolved a binary or a desktop entry, so its + // override wins over the registry's declared strategy. + const strategy = target.launchOverride ?? target.definition.launch[platform] + if (!strategy) { + throw new Error(`${target.definition.name} is not available on this platform`) + } + + const targetPath = target.definition.kind === 'terminal' ? path.dirname(filePath) : filePath + + if (strategy.type === 'macOpenA') { + // `open` hands off to Launch Services and exits immediately. + await execFileAsync('open', ['-a', target.launchTarget, targetPath], { + timeout: LAUNCH_TIMEOUT_MS + }) + return + } + + if (strategy.type === 'desktopEntry') { + // `gio launch` talks to DBus and then exits. Wait for that exit so a missing + // entry or portal error is not reported as success after a short spawn window. + await execFileAsync('gio', ['launch', target.launchTarget, targetPath], { + timeout: LAUNCH_TIMEOUT_MS + }) + return + } + + // A detached GUI process may stay alive for the whole app session. A successful + // `spawn` is therefore the only reliable handoff boundary; later exit codes are + // application runtime failures, not launcher failures. + const command = target.launchTarget + const args = buildLaunchArgs(strategy.args, targetPath) + + await new Promise((resolve, reject) => { + const child = spawn(command, args, { detached: true, stdio: 'ignore' }) + child.once('error', reject) + child.once('spawn', () => { + child.unref() + resolve() + }) + }) +} diff --git a/src/main/workspace/openInApp/linuxDesktopEntries.ts b/src/main/workspace/openInApp/linuxDesktopEntries.ts new file mode 100644 index 0000000000..b78d1c653c --- /dev/null +++ b/src/main/workspace/openInApp/linuxDesktopEntries.ts @@ -0,0 +1,85 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { nativeImage } from 'electron' + +/** + * Desktop-entry lookup for Linux app detection. + * + * A `command -v` hit is preferred because it can be exec'd with CLI flags, but + * many installs only register a `.desktop` file: JetBrains Toolbox IDEs without + * a CLI launcher, Flatpaks, and distro packages that keep their binary off PATH. + */ + +function desktopEntryDirectories(): string[] { + const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share') + const dataDirs = (process.env.XDG_DATA_DIRS || '/usr/local/share:/usr/share') + .split(':') + .filter(Boolean) + return [dataHome, ...dataDirs].map((dir) => path.join(dir, 'applications')) +} + +/** Absolute path of the first matching desktop entry, or null. */ +export function findDesktopEntry(desktopId: string): string | null { + for (const dir of desktopEntryDirectories()) { + const candidate = path.join(dir, desktopId) + if (fs.existsSync(candidate)) { + return candidate + } + } + return null +} + +export function readDesktopEntry(entryPath: string): string | null { + try { + return fs.readFileSync(entryPath, 'utf-8') + } catch { + return null + } +} + +/** + * Whether the entry's `Exec=` declares a file field code (`%f`/`%F`/`%u`/`%U`). + * + * `gio launch` only forwards a path to entries that declare one; without it the + * app opens with no file and the user sees a silent no-op. Only the + * `[Desktop Entry]` group is considered, so action groups cannot mask a miss. + */ +export function desktopEntryAcceptsFiles(content: string): boolean { + let inDesktopEntry = false + + for (const line of content.split(/\r?\n/)) { + if (line.trim() === '[Desktop Entry]') { + inDesktopEntry = true + continue + } + if (inDesktopEntry && line.startsWith('[')) { + break + } + if (inDesktopEntry && line.startsWith('Exec=')) { + return /(^|[^%])%[fFuU]/.test(line.slice('Exec='.length)) + } + } + + return false +} + +/** + * Icon from the entry's `Icon=` key, as a data URL. + * + * Only absolute paths are resolved; a themed icon name would need an icon-theme + * lookup, and those fall back to the renderer's placeholder. + */ +export function readDesktopEntryIcon(content: string): string | undefined { + const iconValue = content.match(/^Icon=(.*)$/m)?.[1]?.trim() + if (!iconValue?.startsWith('/')) { + return undefined + } + + try { + const icon = nativeImage.createFromPath(iconValue) + return icon.isEmpty() ? undefined : icon.toDataURL() + } catch { + return undefined + } +} diff --git a/src/main/workspace/routes.ts b/src/main/workspace/routes.ts index 6327645c32..0392712314 100644 --- a/src/main/workspace/routes.ts +++ b/src/main/workspace/routes.ts @@ -3,7 +3,9 @@ import { workspaceExpandDirectoryRoute, workspaceGetGitDiffRoute, workspaceGetGitStatusRoute, + workspaceListFileOpenAppsRoute, workspaceOpenFileRoute, + workspaceOpenFileWithAppRoute, workspaceReadDirectoryRoute, workspaceReadFilePreviewRoute, workspaceRegisterRoute, @@ -84,6 +86,23 @@ export function createWorkspaceRoutes(service: WorkspaceServicePort): DeepchatRo return workspaceOpenFileRoute.output.parse({ opened: true }) } ], + [ + workspaceListFileOpenAppsRoute.name, + async (rawInput) => { + const input = workspaceListFileOpenAppsRoute.input.parse(rawInput) + return workspaceListFileOpenAppsRoute.output.parse({ + apps: await service.listFileOpenApps(input.path) + }) + } + ], + [ + workspaceOpenFileWithAppRoute.name, + async (rawInput) => { + const input = workspaceOpenFileWithAppRoute.input.parse(rawInput) + await service.openFileWithApp(input.path, input.appId) + return workspaceOpenFileWithAppRoute.output.parse({ opened: true }) + } + ], [ workspaceReadFilePreviewRoute.name, async (rawInput) => { diff --git a/src/renderer/api/WorkspaceClient.ts b/src/renderer/api/WorkspaceClient.ts index cb37ed876f..b73a0ed4e7 100644 --- a/src/renderer/api/WorkspaceClient.ts +++ b/src/renderer/api/WorkspaceClient.ts @@ -8,7 +8,9 @@ import { workspaceExpandDirectoryRoute, workspaceGetGitDiffRoute, workspaceGetGitStatusRoute, + workspaceListFileOpenAppsRoute, workspaceOpenFileRoute, + workspaceOpenFileWithAppRoute, workspaceReadDirectoryRoute, workspaceReadFilePreviewRoute, workspaceRegisterRoute, @@ -64,6 +66,15 @@ export function createWorkspaceClient(bridge: DeepchatBridge = getDeepchatBridge return await bridge.invoke(workspaceOpenFileRoute.name, { path }) } + async function listFileOpenApps(path: string) { + const result = await bridge.invoke(workspaceListFileOpenAppsRoute.name, { path }) + return result.apps + } + + async function openFileWithApp(path: string, appId: string) { + return await bridge.invoke(workspaceOpenFileWithAppRoute.name, { path, appId }) + } + async function readFilePreview(path: string) { const result = await bridge.invoke(workspaceReadFilePreviewRoute.name, { path }) return result.preview @@ -123,6 +134,8 @@ export function createWorkspaceClient(bridge: DeepchatBridge = getDeepchatBridge expandDirectory, revealFileInFolder, openFile, + listFileOpenApps, + openFileWithApp, readFilePreview, resolveMarkdownLinkedFile, getGitStatus, diff --git a/src/renderer/src/components/markdown/useMarkdownLinkNavigation.ts b/src/renderer/src/components/markdown/useMarkdownLinkNavigation.ts index 0e27c97d8b..0600e00109 100644 --- a/src/renderer/src/components/markdown/useMarkdownLinkNavigation.ts +++ b/src/renderer/src/components/markdown/useMarkdownLinkNavigation.ts @@ -110,7 +110,12 @@ export function useMarkdownLinkNavigation(options: UseMarkdownLinkNavigationOpti return true } - await workspaceClient.openFile(resolution.path) + try { + await workspaceClient.openFile(resolution.path) + } catch (error) { + console.warn('[markdown-links] Failed to open local file:', resolution.path, error) + return false + } return true } diff --git a/src/renderer/src/components/sidepanel/WorkspaceViewer.vue b/src/renderer/src/components/sidepanel/WorkspaceViewer.vue index 0a79cff396..f4fc182ed4 100644 --- a/src/renderer/src/components/sidepanel/WorkspaceViewer.vue +++ b/src/renderer/src/components/sidepanel/WorkspaceViewer.vue @@ -61,15 +61,71 @@ /> - - {{ t('chat.workspace.files.contextMenu.openFile') }} - +
+ + + + + + + + + + + + + + + + + {{ openAppLabel(openApp) }} + + + + + + + + {{ openAppLabel(openApp) }} + + + + + + + {{ t('chat.workspace.files.contextMenu.revealInFolder') }} + + + + {{ t('chat.workspace.files.contextMenu.openWithSystemDefault') }} + + + +
@@ -158,14 +214,26 @@ diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index 1680eef580..a72316eee3 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -229,6 +229,12 @@ "contextMenu": { "insertPath": "Indsæt i inputboksen", "openFile": "åbne fil", + "openInApp": "Åbn i {app}", + "openInTerminalApp": "Åbn mappen i {app}", + "openFailed": "Filen kunne ikke åbnes med det program", + "preferredAppUnavailable": "Det foretrukne program er ikke tilgængeligt lige nu. Åbner med systemets standardprogram.", + "openWith": "Åbn med", + "openWithSystemDefault": "Åbn med systemets standardprogram", "revealInFolder": "Åbn i filhåndtering" }, "empty": "Ingen filer endnu", diff --git a/src/renderer/src/i18n/de-DE/chat.json b/src/renderer/src/i18n/de-DE/chat.json index 82c30f6c9b..056066b056 100644 --- a/src/renderer/src/i18n/de-DE/chat.json +++ b/src/renderer/src/i18n/de-DE/chat.json @@ -295,8 +295,14 @@ }, "contextMenu": { "openFile": "Datei öffnen", + "openWith": "Öffnen mit", + "openInApp": "In {app} öffnen", + "openInTerminalApp": "Ordner in {app} öffnen", + "openFailed": "Die Datei konnte nicht mit dieser Anwendung geöffnet werden", + "preferredAppUnavailable": "Die bevorzugte App ist gerade nicht verfügbar. Es wird mit der Systemstandard-App geöffnet.", "revealInFolder": "Im Dateimanager anzeigen", - "insertPath": "In Eingabe einfügen" + "insertPath": "In Eingabe einfügen", + "openWithSystemDefault": "Mit Systemstandard öffnen" }, "watchStatus": { "degraded": "Überwachung läuft im Fallback-Modus. Änderungen werden möglicherweise langsamer aktualisiert.", diff --git a/src/renderer/src/i18n/en-US/chat.json b/src/renderer/src/i18n/en-US/chat.json index db08528415..807e7f94d0 100644 --- a/src/renderer/src/i18n/en-US/chat.json +++ b/src/renderer/src/i18n/en-US/chat.json @@ -380,8 +380,14 @@ }, "contextMenu": { "openFile": "Open file", + "openWith": "Open with", + "openInApp": "Open in {app}", + "openInTerminalApp": "Open folder in {app}", + "openFailed": "Could not open the file with that application", + "preferredAppUnavailable": "Preferred app is unavailable right now. Opening with the system default.", "revealInFolder": "Show in file manager", - "insertPath": "Insert into input" + "insertPath": "Insert into input", + "openWithSystemDefault": "Open with system default" }, "watchStatus": { "degraded": "Watching in fallback mode. Changes may refresh slower.", diff --git a/src/renderer/src/i18n/es-ES/chat.json b/src/renderer/src/i18n/es-ES/chat.json index 79ecec6014..a6e7927dbf 100644 --- a/src/renderer/src/i18n/es-ES/chat.json +++ b/src/renderer/src/i18n/es-ES/chat.json @@ -295,8 +295,14 @@ }, "contextMenu": { "openFile": "Abrir archivo", + "openWith": "Abrir con", + "openInApp": "Abrir en {app}", + "openInTerminalApp": "Abrir la carpeta en {app}", + "openFailed": "No se pudo abrir el archivo con esa aplicación", + "preferredAppUnavailable": "La aplicación preferida no está disponible ahora. Se abrirá con la aplicación predeterminada.", "revealInFolder": "Mostrar en el administrador de archivos", - "insertPath": "Insertar en el campo de entrada" + "insertPath": "Insertar en el campo de entrada", + "openWithSystemDefault": "Abrir con la aplicación predeterminada" }, "watchStatus": { "degraded": "La supervisión está en modo de reserva. Los cambios pueden actualizarse más lentamente.", diff --git a/src/renderer/src/i18n/fa-IR/chat.json b/src/renderer/src/i18n/fa-IR/chat.json index 9a4218def5..7bb04988be 100644 --- a/src/renderer/src/i18n/fa-IR/chat.json +++ b/src/renderer/src/i18n/fa-IR/chat.json @@ -229,6 +229,12 @@ "contextMenu": { "insertPath": "در جعبه ورودی وارد کنید", "openFile": "باز کردن فایل", + "openInApp": "باز کردن در {app}", + "openInTerminalApp": "باز کردن پوشه در {app}", + "openFailed": "باز کردن فایل با آن برنامه ممکن نشد", + "preferredAppUnavailable": "برنامه ترجیحی فعلاً در دسترس نیست. با برنامه پیش‌فرض سیستم باز می‌شود.", + "openWith": "باز کردن با", + "openWithSystemDefault": "باز کردن با برنامه پیش‌فرض سیستم", "revealInFolder": "در فایل منیجر باز کنید" }, "empty": "هنوز فایلی وجود ندارد", diff --git a/src/renderer/src/i18n/fr-FR/chat.json b/src/renderer/src/i18n/fr-FR/chat.json index bce674dbfe..59629b3aa8 100644 --- a/src/renderer/src/i18n/fr-FR/chat.json +++ b/src/renderer/src/i18n/fr-FR/chat.json @@ -229,6 +229,12 @@ "contextMenu": { "insertPath": "Insérer dans la zone de saisie", "openFile": "ouvrir le fichier", + "openInApp": "Ouvrir dans {app}", + "openInTerminalApp": "Ouvrir le dossier dans {app}", + "openFailed": "Impossible d’ouvrir le fichier avec cette application", + "preferredAppUnavailable": "L’application préférée n’est pas disponible pour le moment. Ouverture avec l’application par défaut.", + "openWith": "Ouvrir avec", + "openWithSystemDefault": "Ouvrir avec l’application par défaut", "revealInFolder": "Ouvrir dans le gestionnaire de fichiers" }, "empty": "Aucun fichier pour l'instant", diff --git a/src/renderer/src/i18n/he-IL/chat.json b/src/renderer/src/i18n/he-IL/chat.json index 33c7bdf8a1..3fb380aef6 100644 --- a/src/renderer/src/i18n/he-IL/chat.json +++ b/src/renderer/src/i18n/he-IL/chat.json @@ -229,6 +229,12 @@ "contextMenu": { "insertPath": "הכנס לתוך תיבת הקלט", "openFile": "לפתוח קובץ", + "openInApp": "פתח ב־{app}", + "openInTerminalApp": "פתח את התיקייה ב־{app}", + "openFailed": "לא ניתן לפתוח את הקובץ באמצעות היישום הזה", + "preferredAppUnavailable": "היישום המועדף אינו זמין כרגע. נפתח באמצעות ברירת המחדל של המערכת.", + "openWith": "פתח באמצעות", + "openWithSystemDefault": "פתח באמצעות ברירת המחדל של המערכת", "revealInFolder": "פתח במנהל הקבצים" }, "empty": "עדיין אין קבצים", diff --git a/src/renderer/src/i18n/id-ID/chat.json b/src/renderer/src/i18n/id-ID/chat.json index 8fe42ba10a..9b215b88d7 100644 --- a/src/renderer/src/i18n/id-ID/chat.json +++ b/src/renderer/src/i18n/id-ID/chat.json @@ -295,8 +295,14 @@ }, "contextMenu": { "openFile": "membuka berkas", + "openWith": "Buka dengan", + "openInApp": "Buka di {app}", + "openInTerminalApp": "Buka folder di {app}", + "openFailed": "Tidak dapat membuka file dengan aplikasi tersebut", + "preferredAppUnavailable": "Aplikasi pilihan sedang tidak tersedia. Membuka dengan aplikasi bawaan sistem.", "revealInFolder": "Buka di pengelola file", - "insertPath": "Masukkan ke dalam kotak masukan" + "insertPath": "Masukkan ke dalam kotak masukan", + "openWithSystemDefault": "Buka dengan aplikasi bawaan sistem" }, "watchStatus": { "degraded": "Pemantauan berjalan dalam mode cadangan. Perubahan mungkin dimuat ulang lebih lambat.", diff --git a/src/renderer/src/i18n/it-IT/chat.json b/src/renderer/src/i18n/it-IT/chat.json index 9a5ee2e5d5..f3a2b3b71d 100644 --- a/src/renderer/src/i18n/it-IT/chat.json +++ b/src/renderer/src/i18n/it-IT/chat.json @@ -295,8 +295,14 @@ }, "contextMenu": { "openFile": "Apri file", + "openWith": "Apri con", + "openInApp": "Apri in {app}", + "openInTerminalApp": "Apri la cartella in {app}", + "openFailed": "Impossibile aprire il file con quell’applicazione", + "preferredAppUnavailable": "L’app preferita non è disponibile in questo momento. Apertura con l’app predefinita di sistema.", "revealInFolder": "Mostra nel file manager", - "insertPath": "Inserisci nell'input" + "insertPath": "Inserisci nell'input", + "openWithSystemDefault": "Apri con l’app predefinita di sistema" }, "watchStatus": { "degraded": "Il monitoraggio è in modalità di fallback. Le modifiche potrebbero aggiornarsi più lentamente.", diff --git a/src/renderer/src/i18n/ja-JP/chat.json b/src/renderer/src/i18n/ja-JP/chat.json index b35e9d5540..9a63f30e25 100644 --- a/src/renderer/src/i18n/ja-JP/chat.json +++ b/src/renderer/src/i18n/ja-JP/chat.json @@ -229,6 +229,12 @@ "contextMenu": { "insertPath": "入力ボックスに挿入", "openFile": "ファイルを開く", + "openInApp": "{app} で開く", + "openInTerminalApp": "{app} でフォルダを開く", + "openFailed": "そのアプリでファイルを開けませんでした", + "preferredAppUnavailable": "優先アプリは現在利用できないため、システムの既定のアプリで開きます。", + "openWith": "開き方", + "openWithSystemDefault": "システムの既定のアプリで開く", "revealInFolder": "ファイルマネージャーで開く" }, "empty": "まだファイルがありません", diff --git a/src/renderer/src/i18n/ko-KR/chat.json b/src/renderer/src/i18n/ko-KR/chat.json index be79a5146b..353e116e85 100644 --- a/src/renderer/src/i18n/ko-KR/chat.json +++ b/src/renderer/src/i18n/ko-KR/chat.json @@ -229,6 +229,12 @@ "contextMenu": { "insertPath": "입력창에 삽입", "openFile": "파일 열기", + "openInApp": "{app}에서 열기", + "openInTerminalApp": "{app}에서 폴더 열기", + "openFailed": "해당 앱으로 파일을 열 수 없습니다", + "preferredAppUnavailable": "기본 앱을 지금은 사용할 수 없어 시스템 기본 앱으로 엽니다.", + "openWith": "연결 프로그램", + "openWithSystemDefault": "시스템 기본 앱으로 열기", "revealInFolder": "파일 관리자에서 열기" }, "empty": "아직 파일이 없습니다", diff --git a/src/renderer/src/i18n/ms-MY/chat.json b/src/renderer/src/i18n/ms-MY/chat.json index 6d772c83ef..0ce4a08bfd 100644 --- a/src/renderer/src/i18n/ms-MY/chat.json +++ b/src/renderer/src/i18n/ms-MY/chat.json @@ -295,8 +295,14 @@ }, "contextMenu": { "openFile": "buka fail", + "openWith": "Buka dengan", + "openInApp": "Buka dalam {app}", + "openInTerminalApp": "Buka folder dalam {app}", + "openFailed": "Tidak dapat membuka fail dengan aplikasi tersebut", + "preferredAppUnavailable": "Aplikasi pilihan tidak tersedia buat masa ini. Membuka dengan aplikasi lalai sistem.", "revealInFolder": "Buka dalam pengurus fail", - "insertPath": "Masukkan ke dalam kotak input" + "insertPath": "Masukkan ke dalam kotak input", + "openWithSystemDefault": "Buka dengan aplikasi lalai sistem" }, "watchStatus": { "degraded": "Pemantauan berjalan dalam mod sandaran. Perubahan mungkin dimuat semula dengan lebih perlahan.", diff --git a/src/renderer/src/i18n/pl-PL/chat.json b/src/renderer/src/i18n/pl-PL/chat.json index cc7e7730c7..bf715e5213 100644 --- a/src/renderer/src/i18n/pl-PL/chat.json +++ b/src/renderer/src/i18n/pl-PL/chat.json @@ -295,8 +295,14 @@ }, "contextMenu": { "openFile": "Otwórz plik", + "openWith": "Otwórz za pomocą", + "openInApp": "Otwórz w {app}", + "openInTerminalApp": "Otwórz folder w {app}", + "openFailed": "Nie udało się otworzyć pliku w tej aplikacji", + "preferredAppUnavailable": "Preferowana aplikacja jest teraz niedostępna. Otwieranie w domyślnej aplikacji systemowej.", "revealInFolder": "Pokaż w menedżerze plików", - "insertPath": "Wstaw do wejścia" + "insertPath": "Wstaw do wejścia", + "openWithSystemDefault": "Otwórz w domyślnej aplikacji systemowej" }, "watchStatus": { "degraded": "Obserwowanie działa w trybie awaryjnym. Zmiany mogą odświeżać się wolniej.", diff --git a/src/renderer/src/i18n/pt-BR/chat.json b/src/renderer/src/i18n/pt-BR/chat.json index 7486c3632a..f3a77ecd5c 100644 --- a/src/renderer/src/i18n/pt-BR/chat.json +++ b/src/renderer/src/i18n/pt-BR/chat.json @@ -229,6 +229,12 @@ "contextMenu": { "insertPath": "Insira na caixa de entrada", "openFile": "abrir arquivo", + "openInApp": "Abrir no {app}", + "openInTerminalApp": "Abrir a pasta no {app}", + "openFailed": "Não foi possível abrir o arquivo com esse aplicativo", + "preferredAppUnavailable": "O aplicativo preferido não está disponível agora. Abrindo com o aplicativo padrão do sistema.", + "openWith": "Abrir com", + "openWithSystemDefault": "Abrir com o aplicativo padrão do sistema", "revealInFolder": "Abrir no gerenciador de arquivos" }, "empty": "Nenhum arquivo ainda", diff --git a/src/renderer/src/i18n/ru-RU/chat.json b/src/renderer/src/i18n/ru-RU/chat.json index c78515beec..b9f21aa883 100644 --- a/src/renderer/src/i18n/ru-RU/chat.json +++ b/src/renderer/src/i18n/ru-RU/chat.json @@ -229,6 +229,12 @@ "contextMenu": { "insertPath": "Вставить в поле ввода", "openFile": "открыть файл", + "openInApp": "Открыть в {app}", + "openInTerminalApp": "Открыть папку в {app}", + "openFailed": "Не удалось открыть файл в этом приложении", + "preferredAppUnavailable": "Предпочтительное приложение сейчас недоступно. Файл будет открыт в приложении по умолчанию.", + "openWith": "Открыть с помощью", + "openWithSystemDefault": "Открыть в приложении по умолчанию", "revealInFolder": "Открыть в файловом менеджере" }, "empty": "Файлов пока нет", diff --git a/src/renderer/src/i18n/tr-TR/chat.json b/src/renderer/src/i18n/tr-TR/chat.json index afc6842584..89cbe24e52 100644 --- a/src/renderer/src/i18n/tr-TR/chat.json +++ b/src/renderer/src/i18n/tr-TR/chat.json @@ -295,8 +295,14 @@ }, "contextMenu": { "openFile": "Dosyayı aç", + "openWith": "Birlikte aç", + "openInApp": "{app} ile aç", + "openInTerminalApp": "Klasörü {app} ile aç", + "openFailed": "Dosya bu uygulamayla açılamadı", + "preferredAppUnavailable": "Tercih edilen uygulama şu anda kullanılamıyor. Sistem varsayılanıyla açılıyor.", "revealInFolder": "Dosya yöneticisinde göster", - "insertPath": "Girişe ekle" + "insertPath": "Girişe ekle", + "openWithSystemDefault": "Sistem varsayılan uygulamasıyla aç" }, "watchStatus": { "degraded": "İzleme yedek modda çalışıyor. Değişiklikler daha yavaş yenilenebilir.", diff --git a/src/renderer/src/i18n/vi-VN/chat.json b/src/renderer/src/i18n/vi-VN/chat.json index 66e4cf8150..ed24b8f073 100644 --- a/src/renderer/src/i18n/vi-VN/chat.json +++ b/src/renderer/src/i18n/vi-VN/chat.json @@ -295,8 +295,14 @@ }, "contextMenu": { "openFile": "Mở tập tin", + "openWith": "Mở bằng", + "openInApp": "Mở trong {app}", + "openInTerminalApp": "Mở thư mục trong {app}", + "openFailed": "Không thể mở tệp bằng ứng dụng đó", + "preferredAppUnavailable": "Ứng dụng ưa thích tạm thời không khả dụng. Đang mở bằng ứng dụng mặc định của hệ thống.", "revealInFolder": "Hiển thị trong trình quản lý tập tin", - "insertPath": "Chèn vào đầu vào" + "insertPath": "Chèn vào đầu vào", + "openWithSystemDefault": "Mở bằng ứng dụng mặc định của hệ thống" }, "watchStatus": { "degraded": "Đang theo dõi ở chế độ dự phòng. Thay đổi có thể được làm mới chậm hơn.", diff --git a/src/renderer/src/i18n/zh-CN/chat.json b/src/renderer/src/i18n/zh-CN/chat.json index e398b2f222..b91fad4a30 100644 --- a/src/renderer/src/i18n/zh-CN/chat.json +++ b/src/renderer/src/i18n/zh-CN/chat.json @@ -380,8 +380,14 @@ }, "contextMenu": { "openFile": "打开文件", + "openWith": "打开方式", + "openInApp": "在 {app} 中打开", + "openInTerminalApp": "在 {app} 中打开所在目录", + "openFailed": "无法用该应用打开此文件", + "preferredAppUnavailable": "首选应用暂时不可用,本次改用系统默认方式打开。", "revealInFolder": "在文件管理器中打开", - "insertPath": "插入到输入框" + "insertPath": "插入到输入框", + "openWithSystemDefault": "用系统默认方式打开" }, "watchStatus": { "degraded": "正在使用降级监听模式,文件变化刷新会变慢。", diff --git a/src/renderer/src/i18n/zh-HK/chat.json b/src/renderer/src/i18n/zh-HK/chat.json index a0bce7086b..2d8635c04c 100644 --- a/src/renderer/src/i18n/zh-HK/chat.json +++ b/src/renderer/src/i18n/zh-HK/chat.json @@ -246,6 +246,12 @@ "contextMenu": { "insertPath": "插入到輸入框", "openFile": "打開文件", + "openInApp": "在 {app} 中開啟", + "openInTerminalApp": "在 {app} 中開啟所在目錄", + "openFailed": "無法用該應用開啟此檔案", + "preferredAppUnavailable": "首選應用程式暫時無法使用,本次改用系統預設方式開啟。", + "openWith": "開啟方式", + "openWithSystemDefault": "用系統預設方式開啟", "revealInFolder": "在文件管理器中打開" }, "empty": "暫無文件", diff --git a/src/renderer/src/i18n/zh-TW/chat.json b/src/renderer/src/i18n/zh-TW/chat.json index 05bb831320..40ea1c53ee 100644 --- a/src/renderer/src/i18n/zh-TW/chat.json +++ b/src/renderer/src/i18n/zh-TW/chat.json @@ -246,6 +246,12 @@ "contextMenu": { "insertPath": "插入到輸入框", "openFile": "打開文件", + "openInApp": "在 {app} 中開啟", + "openInTerminalApp": "在 {app} 中開啟所在目錄", + "openFailed": "無法用該應用開啟此檔案", + "preferredAppUnavailable": "首選應用程式暫時無法使用,本次改用系統預設方式開啟。", + "openWith": "開啟方式", + "openWithSystemDefault": "用系統預設方式開啟", "revealInFolder": "在文件管理器中打開" }, "empty": "暫無文件", diff --git a/src/shared/contracts/domainSchemas.ts b/src/shared/contracts/domainSchemas.ts index 6a14e824ed..7dfb692ffb 100644 --- a/src/shared/contracts/domainSchemas.ts +++ b/src/shared/contracts/domainSchemas.ts @@ -848,6 +848,13 @@ export const WorkspaceFileNodeSchema: z.ZodType<{ }) ) +export const WorkspaceFileOpenAppSchema = z.object({ + id: z.string(), + name: z.string(), + kind: z.enum(['editor', 'terminal']), + iconDataUrl: z.string().optional() +}) + export const WorkspaceFileMetadataSchema = z.object({ fileName: z.string(), fileSize: z.number(), diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 906ea17f00..5954a58677 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -617,7 +617,9 @@ import { workspaceExpandDirectoryRoute, workspaceGetGitDiffRoute, workspaceGetGitStatusRoute, + workspaceListFileOpenAppsRoute, workspaceOpenFileRoute, + workspaceOpenFileWithAppRoute, workspaceReadDirectoryRoute, workspaceReadFilePreviewRoute, workspaceRegisterRoute, @@ -822,6 +824,8 @@ const DEEPCHAT_ROUTE_CATALOG_PART_2 = { [workspaceExpandDirectoryRoute.name]: workspaceExpandDirectoryRoute, [workspaceRevealFileInFolderRoute.name]: workspaceRevealFileInFolderRoute, [workspaceOpenFileRoute.name]: workspaceOpenFileRoute, + [workspaceListFileOpenAppsRoute.name]: workspaceListFileOpenAppsRoute, + [workspaceOpenFileWithAppRoute.name]: workspaceOpenFileWithAppRoute, [workspaceReadFilePreviewRoute.name]: workspaceReadFilePreviewRoute, [workspaceResolveMarkdownLinkedFileRoute.name]: workspaceResolveMarkdownLinkedFileRoute, [workspaceGetGitStatusRoute.name]: workspaceGetGitStatusRoute, diff --git a/src/shared/contracts/routes/workspace.routes.ts b/src/shared/contracts/routes/workspace.routes.ts index f1b7d7f65e..e892e86bbc 100644 --- a/src/shared/contracts/routes/workspace.routes.ts +++ b/src/shared/contracts/routes/workspace.routes.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineRouteContract } from '../common' import { WorkspaceFileNodeSchema, + WorkspaceFileOpenAppSchema, WorkspaceFilePreviewSchema, WorkspaceGitDiffSchema, WorkspaceGitStateSchema, @@ -92,6 +93,27 @@ export const workspaceOpenFileRoute = defineRouteContract({ }) }) +export const workspaceListFileOpenAppsRoute = defineRouteContract({ + name: 'workspace.listFileOpenApps', + input: z.object({ + path: z.string().min(1) + }), + output: z.object({ + apps: z.array(WorkspaceFileOpenAppSchema) + }) +}) + +export const workspaceOpenFileWithAppRoute = defineRouteContract({ + name: 'workspace.openFileWithApp', + input: z.object({ + path: z.string().min(1), + appId: z.string().min(1) + }), + output: z.object({ + opened: z.boolean() + }) +}) + export const workspaceReadFilePreviewRoute = defineRouteContract({ name: 'workspace.readFilePreview', input: z.object({ diff --git a/src/shared/types/workspace.ts b/src/shared/types/workspace.ts index 7ed57b5bbb..32cbe91543 100644 --- a/src/shared/types/workspace.ts +++ b/src/shared/types/workspace.ts @@ -3,6 +3,8 @@ * Types for the unified right sidepanel workspace experience. */ +import type { WorkspaceFileOpenAppKind } from '@shared/workspace/fileOpenApps' + export type SidePanelTab = 'workspace' | 'browser' | 'mcp-app' | 'tape-inspector' export type WorkspaceNavSection = 'artifacts' | 'files' | 'git' | 'subagents' @@ -25,6 +27,13 @@ export type WorkspaceFileNode = { expanded?: boolean } +export type WorkspaceFileOpenApp = { + id: string + name: string + kind: WorkspaceFileOpenAppKind + iconDataUrl?: string +} + export type WorkspaceFilePreviewKind = | 'text' | 'markdown' @@ -188,6 +197,10 @@ export interface WorkspaceServicePort { */ openFile(filePath: string): Promise + listFileOpenApps(filePath: string): Promise + + openFileWithApp(filePath: string, appId: string): Promise + /** * Read a workspace file and normalize it to a preview-friendly payload. * @param filePath Absolute file path diff --git a/src/shared/workspace/fileOpenApps.ts b/src/shared/workspace/fileOpenApps.ts new file mode 100644 index 0000000000..f27215cda1 --- /dev/null +++ b/src/shared/workspace/fileOpenApps.ts @@ -0,0 +1,385 @@ +/** + * Registry of editors, IDEs and terminals offered in the workspace "open with" picker. + * + * Platform support is data, not control flow: an app is offered on a platform + * only when it has both a `detect` and a `launch` entry for it. Enabling or + * disabling a platform is a registry edit. + */ + +export type WorkspaceFileOpenAppKind = 'editor' | 'terminal' + +export type WorkspaceFileOpenAppPlatform = 'darwin' | 'win32' | 'linux' + +/** + * How to decide whether an app is installed. + * + * - `macBundleId` resolves through Launch Services, so `~/Applications` is found + * as well as `/Applications`. + * - `winExecutable` reads App Paths (HKCU before HKLM, because per-user + * installers cannot write HKLM) and falls back to PATH. + * - `linuxApp` prefers a `command -v` hit, because a binary can be exec'd with + * CLI flags. It falls back to a `.desktop` entry, which is the only trace left + * by JetBrains Toolbox IDEs without a CLI launcher, Flatpaks, and distro + * packages that keep their binary off PATH. + */ +export type WorkspaceFileOpenAppDetect = + | { type: 'macBundleId'; bundleIds: string[] } + | { type: 'winExecutable'; exeNames: string[] } + | { type: 'linuxApp'; binary?: string; desktopIds?: string[] } + +/** + * How to hand the target path to the app. + * + * - `macOpenA` runs `open -a `. + * - `exec` runs the resolved binary. `args` defaults to `[]`; when it + * contains `{path}` the placeholder is substituted instead of appending. + * - `desktopEntry` runs `gio launch `, used on Linux when only a + * `.desktop` file was found. Requires the entry to declare a file field code. + */ +export type WorkspaceFileOpenAppLaunch = + | { type: 'macOpenA' } + | { type: 'exec'; args?: string[] } + | { type: 'desktopEntry' } + +export type WorkspaceFileOpenAppDefinition = { + /** Stable IPC identifier; never a filesystem path. */ + id: string + name: string + /** Editors receive the file; terminals receive its containing directory. */ + kind: WorkspaceFileOpenAppKind + detect: Partial> + launch: Partial> +} + +/** + * Build the argument list for an `exec` launch. + * + * `{path}` substitution exists because not every app takes a trailing path: + * Ghostty requires `--working-directory=`, and a bare positional argument + * makes `wt.exe` try to run the directory as a command. + */ +export function buildLaunchArgs(args: readonly string[] | undefined, targetPath: string): string[] { + if (!args?.length) { + return [targetPath] + } + + return args.some((arg) => arg.includes('{path}')) + ? args.map((arg) => arg.replace('{path}', targetPath)) + : [...args, targetPath] +} + +/** + * Editors that take a file path positionally on every platform they support. + * + * Linux gets both a binary and desktop-entry ids where available, so a Toolbox or + * Flatpak install without a CLI launcher is still detected. + */ +function editor( + id: string, + name: string, + targets: { bundleIds?: string[]; exeNames?: string[]; binary?: string; desktopIds?: string[] } +): WorkspaceFileOpenAppDefinition { + const detect: WorkspaceFileOpenAppDefinition['detect'] = {} + const launch: WorkspaceFileOpenAppDefinition['launch'] = {} + + if (targets.bundleIds) { + detect.darwin = { type: 'macBundleId', bundleIds: targets.bundleIds } + launch.darwin = { type: 'macOpenA' } + } + if (targets.exeNames) { + detect.win32 = { type: 'winExecutable', exeNames: targets.exeNames } + launch.win32 = { type: 'exec' } + } + if (targets.binary || targets.desktopIds) { + detect.linux = { type: 'linuxApp', binary: targets.binary, desktopIds: targets.desktopIds } + // The detector narrows this to `desktopEntry` when only an entry was found. + launch.linux = { type: 'exec' } + } + + return { id, name, kind: 'editor', detect, launch } +} + +/** + * Only mainstream GUI editors, IDEs and terminals are listed. Browsers, note + * apps and generic viewers are excluded on purpose; "open with system default" + * still covers those. + */ +export const WORKSPACE_FILE_OPEN_APPS: readonly WorkspaceFileOpenAppDefinition[] = [ + editor('vscode', 'VS Code', { + bundleIds: ['com.microsoft.VSCode'], + exeNames: ['Code.exe'], + binary: 'code', + desktopIds: ['code.desktop', 'visual-studio-code.desktop', 'com.visualstudio.code.desktop'] + }), + editor('vscode-insiders', 'VS Code Insiders', { + bundleIds: ['com.microsoft.VSCodeInsiders'], + exeNames: ['Code - Insiders.exe'], + binary: 'code-insiders', + desktopIds: ['code-insiders.desktop'] + }), + editor('vscodium', 'VSCodium', { + bundleIds: ['com.vscodium', 'com.visualstudio.code.oss'], + exeNames: ['VSCodium.exe'], + binary: 'codium', + desktopIds: ['codium.desktop', 'vscodium.desktop', 'com.vscodium.codium.desktop'] + }), + editor('cursor', 'Cursor', { + bundleIds: ['com.todesktop.230313mzl4w4u92'], + exeNames: ['Cursor.exe'], + binary: 'cursor', + desktopIds: ['cursor.desktop'] + }), + editor('windsurf', 'Windsurf', { + bundleIds: ['com.exafunction.windsurf'], + exeNames: ['Windsurf.exe'], + binary: 'windsurf', + desktopIds: ['windsurf.desktop'] + }), + editor('zed', 'Zed', { + bundleIds: ['dev.zed.Zed'], + binary: 'zed', + desktopIds: ['dev.zed.Zed.desktop', 'zed.desktop'] + }), + editor('sublime-text', 'Sublime Text', { + bundleIds: ['com.sublimetext.4', 'com.sublimetext.3'], + exeNames: ['sublime_text.exe'], + binary: 'subl', + desktopIds: ['sublime_text.desktop', 'com.sublimetext.three.desktop'] + }), + editor('intellij', 'IntelliJ IDEA', { + bundleIds: ['com.jetbrains.intellij', 'com.jetbrains.intellij.ce'], + exeNames: ['idea64.exe'], + binary: 'idea', + desktopIds: [ + 'intellij-idea-ultimate.desktop', + 'intellij-idea-community.desktop', + 'jetbrains-idea.desktop', + 'jetbrains-idea-ce.desktop', + 'com.jetbrains.IntelliJ-IDEA-Ultimate.desktop', + 'com.jetbrains.IntelliJ-IDEA-Community.desktop' + ] + }), + editor('goland', 'GoLand', { + bundleIds: ['com.jetbrains.goland'], + exeNames: ['goland64.exe'], + binary: 'goland', + desktopIds: ['goland.desktop', 'jetbrains-goland.desktop', 'com.jetbrains.GoLand.desktop'] + }), + editor('webstorm', 'WebStorm', { + bundleIds: ['com.jetbrains.WebStorm'], + exeNames: ['webstorm64.exe'], + binary: 'webstorm', + desktopIds: ['webstorm.desktop', 'jetbrains-webstorm.desktop', 'com.jetbrains.WebStorm.desktop'] + }), + editor('pycharm', 'PyCharm', { + bundleIds: ['com.jetbrains.pycharm', 'com.jetbrains.pycharm.ce'], + exeNames: ['pycharm64.exe'], + binary: 'pycharm', + desktopIds: [ + 'pycharm-professional.desktop', + 'pycharm-community.desktop', + 'jetbrains-pycharm.desktop', + 'jetbrains-pycharm-ce.desktop', + 'com.jetbrains.PyCharm-Professional.desktop', + 'com.jetbrains.PyCharm-Community.desktop' + ] + }), + editor('rustrover', 'RustRover', { + bundleIds: ['com.jetbrains.rustrover'], + exeNames: ['rustrover64.exe'], + binary: 'rustrover', + desktopIds: [ + 'rustrover.desktop', + 'jetbrains-rustrover.desktop', + 'com.jetbrains.RustRover.desktop' + ] + }), + editor('clion', 'CLion', { + bundleIds: ['com.jetbrains.CLion'], + exeNames: ['clion64.exe'], + binary: 'clion', + desktopIds: ['clion.desktop', 'jetbrains-clion.desktop', 'com.jetbrains.CLion.desktop'] + }), + editor('rider', 'Rider', { + bundleIds: ['com.jetbrains.rider'], + exeNames: ['rider64.exe'], + binary: 'rider', + desktopIds: ['rider.desktop', 'jetbrains-rider.desktop', 'com.jetbrains.Rider.desktop'] + }), + editor('phpstorm', 'PhpStorm', { + bundleIds: ['com.jetbrains.PhpStorm'], + exeNames: ['phpstorm64.exe'], + binary: 'phpstorm', + desktopIds: ['phpstorm.desktop', 'jetbrains-phpstorm.desktop', 'com.jetbrains.PhpStorm.desktop'] + }), + editor('rubymine', 'RubyMine', { + bundleIds: ['com.jetbrains.rubymine'], + exeNames: ['rubymine64.exe'], + binary: 'rubymine', + desktopIds: ['rubymine.desktop', 'jetbrains-rubymine.desktop', 'com.jetbrains.RubyMine.desktop'] + }), + editor('fleet', 'Fleet', { + bundleIds: ['com.jetbrains.fleet'], + exeNames: ['Fleet.exe'], + desktopIds: ['fleet.desktop', 'jetbrains-fleet.desktop'] + }), + editor('android-studio', 'Android Studio', { + bundleIds: ['com.google.android.studio'], + exeNames: ['studio64.exe'], + binary: 'studio', + desktopIds: ['android-studio.desktop', 'com.google.AndroidStudio.desktop'] + }), + // Neovide is the GUI front end; bare nvim would open detached with no terminal. + editor('neovim', 'Neovim', { + bundleIds: ['io.neovim.neovide', 'com.neovim.neovim'], + binary: 'neovide', + desktopIds: ['neovide.desktop', 'nvim.desktop', 'io.neovim.nvim.desktop'] + }), + editor('emacs', 'Emacs', { + bundleIds: ['org.gnu.Emacs'], + exeNames: ['runemacs.exe'], + binary: 'emacs', + desktopIds: ['emacs.desktop'] + }), + // macOS only: Xcode ships nowhere else. + editor('xcode', 'Xcode', { bundleIds: ['com.apple.dt.Xcode'] }), + + { + id: 'apple-terminal', + name: 'Terminal', + kind: 'terminal', + detect: { darwin: { type: 'macBundleId', bundleIds: ['com.apple.Terminal'] } }, + launch: { darwin: { type: 'macOpenA' } } + }, + { + id: 'iterm2', + name: 'iTerm2', + kind: 'terminal', + detect: { darwin: { type: 'macBundleId', bundleIds: ['com.googlecode.iterm2'] } }, + launch: { darwin: { type: 'macOpenA' } } + }, + { + // Warp has no documented working-directory flag, so it is macOS only, where + // `open -a ` supplies the directory. + id: 'warp', + name: 'Warp', + kind: 'terminal', + detect: { darwin: { type: 'macBundleId', bundleIds: ['dev.warp.Warp-Stable'] } }, + launch: { darwin: { type: 'macOpenA' } } + }, + { + id: 'ghostty', + name: 'Ghostty', + kind: 'terminal', + detect: { + darwin: { type: 'macBundleId', bundleIds: ['com.mitchellh.ghostty'] }, + linux: { type: 'linuxApp', binary: 'ghostty' } + }, + launch: { + darwin: { type: 'macOpenA' }, + // Ghostty's CLI only accepts the `--flag=value` form. + linux: { type: 'exec', args: ['--working-directory={path}'] } + } + }, + { + id: 'wezterm', + name: 'WezTerm', + kind: 'terminal', + detect: { + darwin: { type: 'macBundleId', bundleIds: ['com.github.wez.wezterm'] }, + win32: { type: 'winExecutable', exeNames: ['wezterm-gui.exe'] }, + linux: { type: 'linuxApp', binary: 'wezterm' } + }, + launch: { + darwin: { type: 'macOpenA' }, + win32: { type: 'exec', args: ['start', '--cwd'] }, + linux: { type: 'exec', args: ['start', '--cwd'] } + } + }, + { + id: 'kitty', + name: 'kitty', + kind: 'terminal', + detect: { + darwin: { type: 'macBundleId', bundleIds: ['net.kovidgoyal.kitty'] }, + linux: { type: 'linuxApp', binary: 'kitty' } + }, + launch: { + darwin: { type: 'macOpenA' }, + // A positional argument would be read as the program to run. + linux: { type: 'exec', args: ['--directory'] } + } + }, + { + id: 'alacritty', + name: 'Alacritty', + kind: 'terminal', + detect: { + darwin: { type: 'macBundleId', bundleIds: ['org.alacritty'] }, + win32: { type: 'winExecutable', exeNames: ['alacritty.exe'] }, + linux: { type: 'linuxApp', binary: 'alacritty' } + }, + launch: { + darwin: { type: 'macOpenA' }, + win32: { type: 'exec', args: ['--working-directory'] }, + linux: { type: 'exec', args: ['--working-directory'] } + } + }, + { + id: 'hyper', + name: 'Hyper', + kind: 'terminal', + detect: { + darwin: { type: 'macBundleId', bundleIds: ['co.zeit.hyper'] }, + win32: { type: 'winExecutable', exeNames: ['Hyper.exe'] }, + linux: { type: 'linuxApp', binary: 'hyper' } + }, + // Hyper is the one terminal here that documents a positional directory + // (`hyper `), so it needs no working-directory flag. Every other + // terminal must declare one; see test/main/shared/fileOpenApps.test.ts. + launch: { + darwin: { type: 'macOpenA' }, + win32: { type: 'exec' }, + linux: { type: 'exec' } + } + }, + { + id: 'windows-terminal', + name: 'Windows Terminal', + kind: 'terminal', + detect: { win32: { type: 'winExecutable', exeNames: ['wt.exe'] } }, + // A positional argument is the command line wt runs, not the start directory. + launch: { win32: { type: 'exec', args: ['-d'] } } + }, + { + id: 'gnome-terminal', + name: 'GNOME Terminal', + kind: 'terminal', + detect: { linux: { type: 'linuxApp', binary: 'gnome-terminal' } }, + launch: { linux: { type: 'exec', args: ['--working-directory'] } } + }, + { + id: 'konsole', + name: 'Konsole', + kind: 'terminal', + detect: { linux: { type: 'linuxApp', binary: 'konsole' } }, + launch: { linux: { type: 'exec', args: ['--workdir'] } } + } +] + +/** Normalize `process.platform` to the three platforms the registry covers. */ +export function toFileOpenAppPlatform(platform: NodeJS.Platform): WorkspaceFileOpenAppPlatform { + if (platform === 'darwin' || platform === 'win32') { + return platform + } + return 'linux' +} + +/** Apps usable on the given platform: both detection and launch must exist. */ +export function appsForPlatform( + platform: WorkspaceFileOpenAppPlatform +): WorkspaceFileOpenAppDefinition[] { + return WORKSPACE_FILE_OPEN_APPS.filter( + (definition) => definition.detect[platform] && definition.launch[platform] + ) +} diff --git a/src/types/i18n.d.ts b/src/types/i18n.d.ts index f0ded50663..3a5cba9df3 100644 --- a/src/types/i18n.d.ts +++ b/src/types/i18n.d.ts @@ -808,8 +808,14 @@ declare module 'vue-i18n' { } contextMenu: { openFile: string + openWith: string + openInApp: string + openInTerminalApp: string + openFailed: string + preferredAppUnavailable: string revealInFolder: string insertPath: string + openWithSystemDefault: string } watchStatus: { degraded: string diff --git a/test/main/shared/fileOpenApps.test.ts b/test/main/shared/fileOpenApps.test.ts new file mode 100644 index 0000000000..091eee9769 --- /dev/null +++ b/test/main/shared/fileOpenApps.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + appsForPlatform, + buildLaunchArgs, + toFileOpenAppPlatform, + WORKSPACE_FILE_OPEN_APPS, + type WorkspaceFileOpenAppPlatform +} from '@shared/workspace/fileOpenApps' + +const PLATFORMS: WorkspaceFileOpenAppPlatform[] = ['darwin', 'win32', 'linux'] + +describe('fileOpenApps', () => { + it('substitutes or appends the target path for exec launches', () => { + expect(buildLaunchArgs(undefined, '/tmp/file.ts')).toEqual(['/tmp/file.ts']) + expect(buildLaunchArgs([], '/tmp/file.ts')).toEqual(['/tmp/file.ts']) + expect(buildLaunchArgs(['--cwd'], '/tmp/dir')).toEqual(['--cwd', '/tmp/dir']) + expect(buildLaunchArgs(['--working-directory={path}'], '/tmp/dir')).toEqual([ + '--working-directory=/tmp/dir' + ]) + }) + + it('offers an app only when both detect and launch exist', () => { + for (const definition of WORKSPACE_FILE_OPEN_APPS) { + const detectPlatforms = Object.keys(definition.detect).sort() + const launchPlatforms = Object.keys(definition.launch).sort() + expect(launchPlatforms).toEqual(detectPlatforms) + } + + for (const platform of PLATFORMS) { + const offered = appsForPlatform(platform) + expect(offered.length).toBeGreaterThan(0) + expect( + offered.every((definition) => definition.detect[platform] && definition.launch[platform]) + ).toBe(true) + } + }) + + it('requires a working-directory strategy on every terminal except Hyper', () => { + const terminals = WORKSPACE_FILE_OPEN_APPS.filter( + (definition) => definition.kind === 'terminal' + ) + + for (const definition of terminals) { + for (const launch of Object.values(definition.launch)) { + if (launch.type !== 'exec') { + continue + } + + if (definition.id === 'hyper') { + expect(launch.args).toBeUndefined() + continue + } + + expect(launch.args?.length).toBeGreaterThan(0) + } + } + }) + + it('maps unknown process platforms to linux', () => { + expect(toFileOpenAppPlatform('darwin')).toBe('darwin') + expect(toFileOpenAppPlatform('win32')).toBe('win32') + expect(toFileOpenAppPlatform('linux')).toBe('linux') + expect(toFileOpenAppPlatform('freebsd')).toBe('linux') + }) +}) diff --git a/test/main/workspace/openInApp/launchers.test.ts b/test/main/workspace/openInApp/launchers.test.ts new file mode 100644 index 0000000000..1cbbfa1f5b --- /dev/null +++ b/test/main/workspace/openInApp/launchers.test.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DetectedApp } from '@/workspace/openInApp/detectors' + +const { spawnMock, execFileMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + execFileMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock, + execFile: (...args: unknown[]) => execFileMock(...args) +})) + +import { launchApp } from '@/workspace/openInApp/launchers' + +class FakeChild extends EventEmitter { + unref = vi.fn() +} + +const vscodeLinux: DetectedApp = { + definition: { + id: 'vscode', + name: 'VS Code', + kind: 'editor', + detect: { linux: { type: 'linuxApp', binary: 'code' } }, + launch: { linux: { type: 'exec' } } + }, + launchTarget: '/usr/bin/code' +} + +const fleetDesktop: DetectedApp = { + definition: { + id: 'fleet', + name: 'Fleet', + kind: 'editor', + detect: { linux: { type: 'linuxApp', desktopIds: ['fleet.desktop'] } }, + launch: { linux: { type: 'exec' } } + }, + launchTarget: '/usr/share/applications/fleet.desktop', + launchOverride: { type: 'desktopEntry' } +} + +describe('launchApp', () => { + afterEach(() => { + spawnMock.mockReset() + execFileMock.mockReset() + }) + + it('rejects when the process cannot be spawned', async () => { + const child = new FakeChild() + spawnMock.mockReturnValue(child) + + const pending = launchApp(vscodeLinux, '/tmp/file.ts', 'linux') + child.emit('error', new Error('spawn failed')) + + await expect(pending).rejects.toThrow('spawn failed') + expect(child.unref).not.toHaveBeenCalled() + }) + + it('resolves when the process is spawned', async () => { + const child = new FakeChild() + spawnMock.mockReturnValue(child) + + const pending = launchApp(vscodeLinux, '/tmp/file.ts', 'linux') + child.emit('spawn') + + await expect(pending).resolves.toBeUndefined() + expect(child.unref).toHaveBeenCalled() + }) + + it('waits for gio launch to exit instead of detaching', async () => { + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, result?: unknown) => void + callback(null, { stdout: '', stderr: '' }) + }) + + await expect(launchApp(fleetDesktop, '/tmp/file.ts', 'linux')).resolves.toBeUndefined() + expect(execFileMock).toHaveBeenCalledWith( + 'gio', + ['launch', '/usr/share/applications/fleet.desktop', '/tmp/file.ts'], + expect.objectContaining({ timeout: 10_000 }), + expect.any(Function) + ) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('rejects when gio launch fails after spawn', async () => { + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, result?: unknown) => void + callback(new Error('gio: unable to find desktop file')) + }) + + await expect(launchApp(fleetDesktop, '/tmp/file.ts', 'linux')).rejects.toThrow( + 'gio: unable to find desktop file' + ) + expect(spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/workspace/openInApp/linuxBinaryIcon.test.ts b/test/main/workspace/openInApp/linuxBinaryIcon.test.ts new file mode 100644 index 0000000000..c8371f56a8 --- /dev/null +++ b/test/main/workspace/openInApp/linuxBinaryIcon.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { + execFileMock, + getFileIconMock, + findDesktopEntryMock, + readDesktopEntryMock, + readDesktopEntryIconMock +} = vi.hoisted(() => ({ + execFileMock: vi.fn(), + getFileIconMock: vi.fn(), + findDesktopEntryMock: vi.fn(), + readDesktopEntryMock: vi.fn(), + readDesktopEntryIconMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: (...args: unknown[]) => execFileMock(...args) +})) + +vi.mock('electron', () => ({ + app: { + getFileIcon: getFileIconMock + } +})) + +vi.mock('@/workspace/openInApp/linuxDesktopEntries', () => ({ + findDesktopEntry: findDesktopEntryMock, + readDesktopEntry: readDesktopEntryMock, + readDesktopEntryIcon: readDesktopEntryIconMock, + desktopEntryAcceptsFiles: vi.fn() +})) + +import { detectInstalledApps } from '@/workspace/openInApp/detectors' + +function resolveBinary(binary: string, path: string | null) { + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, result?: unknown) => void + const commandArgs = args[1] as string[] | undefined + const command = commandArgs?.[1] ?? '' + if (path && command.includes(`"${binary}"`)) { + callback(null, { stdout: `${path}\n`, stderr: '' }) + return + } + callback(new Error('not found')) + }) +} + +describe('Linux binary icon detection', () => { + afterEach(() => { + execFileMock.mockReset() + getFileIconMock.mockReset() + findDesktopEntryMock.mockReset() + readDesktopEntryMock.mockReset() + readDesktopEntryIconMock.mockReset() + }) + + it('prefers a desktop-entry icon over the binary', async () => { + resolveBinary('code', '/usr/bin/code') + findDesktopEntryMock.mockReturnValue('/usr/share/applications/code.desktop') + readDesktopEntryMock.mockReturnValue('[Desktop Entry]\nIcon=/usr/share/pixmaps/code.png') + readDesktopEntryIconMock.mockReturnValue('data:image/png;base64,desktop') + + const apps = await detectInstalledApps('linux') + const vscode = apps.find((entry) => entry.definition.id === 'vscode') + + expect(vscode?.iconDataUrl).toBe('data:image/png;base64,desktop') + expect(getFileIconMock).not.toHaveBeenCalled() + }) + + it('falls back to the binary icon when the desktop entry has none', async () => { + resolveBinary('code', '/usr/bin/code') + findDesktopEntryMock.mockReturnValue('/usr/share/applications/code.desktop') + readDesktopEntryMock.mockReturnValue('[Desktop Entry]\nIcon=code') + readDesktopEntryIconMock.mockReturnValue(undefined) + getFileIconMock.mockResolvedValue({ + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,binary' + }) + + const apps = await detectInstalledApps('linux') + const vscode = apps.find((entry) => entry.definition.id === 'vscode') + + expect(vscode?.iconDataUrl).toBe('data:image/png;base64,binary') + }) + + it('omits the icon when getFileIcon returns empty', async () => { + resolveBinary('code', '/usr/bin/code') + findDesktopEntryMock.mockReturnValue(null) + getFileIconMock.mockResolvedValue({ + isEmpty: () => true, + toDataURL: () => 'data:image/png;base64,unused' + }) + + const apps = await detectInstalledApps('linux') + const vscode = apps.find((entry) => entry.definition.id === 'vscode') + + expect(vscode?.iconDataUrl).toBeUndefined() + }) + + it('omits the icon when getFileIcon fails', async () => { + resolveBinary('code', '/usr/bin/code') + findDesktopEntryMock.mockReturnValue(null) + getFileIconMock.mockRejectedValue(new Error('no icon')) + + const apps = await detectInstalledApps('linux') + const vscode = apps.find((entry) => entry.definition.id === 'vscode') + + expect(vscode?.iconDataUrl).toBeUndefined() + }) +}) diff --git a/test/main/workspace/workspaceService.test.ts b/test/main/workspace/workspaceService.test.ts index fa8d327ce6..5525bbc092 100644 --- a/test/main/workspace/workspaceService.test.ts +++ b/test/main/workspace/workspaceService.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' +import { shell } from 'electron' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DEEPCHAT_EVENT_CHANNEL } from '../../../src/shared/contracts/channels' import { createDeepchatEventEnvelope } from '../../../src/shared/contracts/events' @@ -330,6 +331,16 @@ describe('WorkspaceService watchers', () => { expect(contentWatcher.close).toHaveBeenCalledTimes(1) expect(gitWatcher.close).toHaveBeenCalledTimes(1) }) + + it('rejects when opening an authorized path returns an error message', async () => { + const filePath = path.join(workspacePath, 'example.txt') + fs.writeFileSync(filePath, '') + await presenter.registerWorkspace(workspacePath) + vi.mocked(shell.openPath).mockResolvedValueOnce('failed to open') + + await expect(presenter.openFile(filePath)).rejects.toThrow('failed to open') + expect(shell.openPath).toHaveBeenCalledWith(filePath) + }) }) describe('WorkspaceService readFilePreview', () => { diff --git a/test/renderer/components/WorkspaceViewer.test.ts b/test/renderer/components/WorkspaceViewer.test.ts index b33a22a839..9100c6e996 100644 --- a/test/renderer/components/WorkspaceViewer.test.ts +++ b/test/renderer/components/WorkspaceViewer.test.ts @@ -1,8 +1,42 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' +const PREFERRED_OPEN_APP_STORAGE_KEY = 'workspace.openWith.preferredAppId' + +const textFilePreview = { + path: 'C:/repo/src/app.ts', + relativePath: 'src/app.ts', + name: 'app.ts', + mimeType: 'application/typescript', + kind: 'text', + content: 'export const app = 1', + language: 'ts', + metadata: { + fileName: 'app.ts', + fileSize: 18, + fileCreated: new Date('2024-01-01T00:00:00Z'), + fileModified: new Date('2024-01-02T00:00:00Z') + } +} as const + +const textFileSession = { + selectedArtifactContext: null, + selectedFilePath: 'C:/repo/src/app.ts', + selectedDiffPath: null, + viewMode: 'preview', + sections: { + files: true, + git: false, + artifacts: true + } +} as const + describe('WorkspaceViewer', () => { + afterEach(() => { + localStorage.clear() + }) + const setup = async (options?: { sessionState?: { selectedArtifactContext: { @@ -20,6 +54,8 @@ describe('WorkspaceViewer', () => { } } props?: Record + preferredAppId?: string | null + fileOpenApps?: Array<{ id: string; name: string; kind: 'editor' | 'terminal' }> }) => { vi.resetModules() @@ -47,6 +83,15 @@ describe('WorkspaceViewer', () => { } const openFileMock = vi.fn().mockResolvedValue(undefined) + const openFileWithAppMock = vi.fn().mockResolvedValue(undefined) + const listFileOpenAppsMock = vi.fn().mockResolvedValue(options?.fileOpenApps ?? []) + const notifyRenderer = vi.fn() + + if (options?.preferredAppId) { + localStorage.setItem(PREFERRED_OPEN_APP_STORAGE_KEY, options.preferredAppId) + } else { + localStorage.removeItem(PREFERRED_OPEN_APP_STORAGE_KEY) + } vi.doMock('vue-i18n', () => ({ useI18n: () => ({ @@ -65,9 +110,15 @@ describe('WorkspaceViewer', () => { }) })) + vi.doMock('@renderer-notifications/rendererNotificationPort', () => ({ + notifyRenderer + })) + vi.doMock('@api/WorkspaceClient', () => ({ createWorkspaceClient: () => ({ - openFile: openFileMock + openFile: openFileMock, + listFileOpenApps: listFileOpenAppsMock, + openFileWithApp: openFileWithAppMock }) })) @@ -133,12 +184,40 @@ describe('WorkspaceViewer', () => { name: 'Button', emits: ['click'], template: '' + }), + DropdownMenu: defineComponent({ + name: 'DropdownMenu', + template: '
' + }), + DropdownMenuTrigger: defineComponent({ + name: 'DropdownMenuTrigger', + template: '
' + }), + DropdownMenuContent: defineComponent({ + name: 'DropdownMenuContent', + template: '
' + }), + DropdownMenuSeparator: defineComponent({ + name: 'DropdownMenuSeparator', + template: '
' + }), + DropdownMenuItem: defineComponent({ + name: 'DropdownMenuItem', + emits: ['select'], + template: '' }) } } }) - return { wrapper, sidepanelStore, openFileMock } + return { + wrapper, + sidepanelStore, + openFileMock, + openFileWithAppMock, + listFileOpenAppsMock, + notifyRenderer + } } it('shows a maximize button and emits toggle-fullscreen', async () => { @@ -407,4 +486,170 @@ describe('WorkspaceViewer', () => { expect(wrapper.find('[data-testid="preview-pane"]').exists()).toBe(false) expect(wrapper.find('[data-testid="code-pane"]').exists()).toBe(false) }) + + it('keeps a stored preference when a partial probe omits it', async () => { + const { wrapper, notifyRenderer } = await setup({ + sessionState: textFileSession, + props: { + artifact: null, + filePreview: textFilePreview + }, + preferredAppId: 'vscode', + fileOpenApps: [{ id: 'cursor', name: 'Cursor', kind: 'editor' }] + }) + + await flushPromises() + + expect(localStorage.getItem(PREFERRED_OPEN_APP_STORAGE_KEY)).toBe('vscode') + expect(notifyRenderer).not.toHaveBeenCalled() + + wrapper.unmount() + }) + + it('opens with the system default without clearing a missing preference', async () => { + const { wrapper, openFileMock, notifyRenderer } = await setup({ + sessionState: textFileSession, + props: { + artifact: null, + filePreview: textFilePreview + }, + preferredAppId: 'vscode', + fileOpenApps: [{ id: 'cursor', name: 'Cursor', kind: 'editor' }] + }) + + await flushPromises() + + const openButton = wrapper + .findAll('button') + .find( + (button) => button.attributes('tooltip') === 'chat.workspace.files.contextMenu.openFile' + ) + expect(openButton).toBeTruthy() + await openButton!.trigger('click') + await flushPromises() + + expect(openFileMock).toHaveBeenCalledWith('C:/repo/src/app.ts') + expect(notifyRenderer).toHaveBeenCalledWith({ + kind: 'info', + code: 'chat.workspace.preferredAppUnavailable', + title: 'chat.workspace.files.contextMenu.preferredAppUnavailable' + }) + expect(localStorage.getItem(PREFERRED_OPEN_APP_STORAGE_KEY)).toBe('vscode') + + wrapper.unmount() + }) + + it('does not claim a system-default fallback when that open fails', async () => { + const { wrapper, openFileMock, notifyRenderer } = await setup({ + sessionState: textFileSession, + props: { + artifact: null, + filePreview: textFilePreview + }, + preferredAppId: 'vscode', + fileOpenApps: [{ id: 'cursor', name: 'Cursor', kind: 'editor' }] + }) + + openFileMock.mockRejectedValueOnce(new Error('open failed')) + await flushPromises() + + const openButton = wrapper + .findAll('button') + .find( + (button) => button.attributes('tooltip') === 'chat.workspace.files.contextMenu.openFile' + ) + expect(openButton).toBeTruthy() + await openButton!.trigger('click') + await flushPromises() + + expect(notifyRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'error', + code: 'chat.workspace.openFileFailed' + }) + ) + expect(notifyRenderer).not.toHaveBeenCalledWith( + expect.objectContaining({ + code: 'chat.workspace.preferredAppUnavailable' + }) + ) + + wrapper.unmount() + }) + + it('drops the legacy system-default sentinel without notifying', async () => { + const { wrapper, notifyRenderer } = await setup({ + sessionState: textFileSession, + props: { + artifact: null, + filePreview: textFilePreview + }, + preferredAppId: '#system-default', + fileOpenApps: [{ id: 'cursor', name: 'Cursor', kind: 'editor' }] + }) + + await flushPromises() + + expect(localStorage.getItem(PREFERRED_OPEN_APP_STORAGE_KEY)).toBeNull() + expect(notifyRenderer).not.toHaveBeenCalled() + + wrapper.unmount() + }) + + it('remembers a preferred app only after a successful launch', async () => { + const { wrapper, openFileWithAppMock } = await setup({ + sessionState: textFileSession, + props: { + artifact: null, + filePreview: textFilePreview + }, + fileOpenApps: [{ id: 'cursor', name: 'Cursor', kind: 'editor' }] + }) + + await flushPromises() + + const openInAppButton = wrapper + .findAll('button') + .find((button) => button.text().includes('chat.workspace.files.contextMenu.openInApp')) + expect(openInAppButton).toBeTruthy() + await openInAppButton!.trigger('click') + await flushPromises() + + expect(openFileWithAppMock).toHaveBeenCalledWith('C:/repo/src/app.ts', 'cursor') + expect(localStorage.getItem(PREFERRED_OPEN_APP_STORAGE_KEY)).toBe('cursor') + + wrapper.unmount() + }) + + it('does not remember a preferred app when launch fails', async () => { + const { wrapper, openFileWithAppMock, notifyRenderer } = await setup({ + sessionState: textFileSession, + props: { + artifact: null, + filePreview: textFilePreview + }, + preferredAppId: 'vscode', + fileOpenApps: [{ id: 'cursor', name: 'Cursor', kind: 'editor' }] + }) + + openFileWithAppMock.mockRejectedValueOnce(new Error('launch failed')) + await flushPromises() + + const openInAppButton = wrapper + .findAll('button') + .find((button) => button.text().includes('chat.workspace.files.contextMenu.openInApp')) + expect(openInAppButton).toBeTruthy() + await openInAppButton!.trigger('click') + await flushPromises() + + expect(notifyRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'error', + code: 'chat.workspace.openFileFailed' + }) + ) + expect(localStorage.getItem(PREFERRED_OPEN_APP_STORAGE_KEY)).toBe('vscode') + + wrapper.unmount() + }) })