diff --git a/src/client/open-with.ts b/src/client/open-with.ts index b2556304..5ff08b86 100644 --- a/src/client/open-with.ts +++ b/src/client/open-with.ts @@ -200,9 +200,12 @@ function schemeOf(template: string): string | undefined { return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme : undefined } -/** Normalize a filesystem path for embedding in a URL (backslashes → '/'). */ +/** Normalize a filesystem path for embedding in a URL: convert backslashes to + * forward slashes, then percent-encode characters that are invalid in a URL + * path (spaces, Unicode, #, etc.). The colon after a drive letter and the + * forward slashes are kept as-is — they are valid URL path characters. */ export function normalizeUrlPath(path: string): string { - return path.replace(/\\/g, '/') + return encodeURI(path.replace(/\\/g, '/')) } /** A fresh custom-editor id (uuid when available, time-based fallback). */ diff --git a/src/open-external.ts b/src/open-external.ts index 6a1ef67f..e5f30dcc 100644 --- a/src/open-external.ts +++ b/src/open-external.ts @@ -9,7 +9,9 @@ * The command builders are pure — the platform is injectable — so every * per-platform branch is unit-testable without spawning anything. */ -import { spawn } from 'node:child_process' +import { execSync, spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import { parentOf, requireAbsolute } from './fs-tree.ts' import { SidebarError } from './wire.ts' @@ -39,8 +41,117 @@ export function revealCommand(path: string, platform: NodeJS.Platform = process. } } +/** Cache the Zed executable path after first lookup. */ +let zedPathMemo: string | null | undefined + +/** + * Find the Zed executable on Windows by checking well-known install + * locations and PATH entries synchronously, falling back to the + * `zed://` protocol handler's registry entry. The registry fallback + * uses execSync which may fail under sandboxed DSH hosts, so the + * filesystem checks come first. + */ +export function findZedPath(): string | null { + if (zedPathMemo !== undefined) return zedPathMemo + + const candidates: string[] = [ + // Common install paths (checked first for speed) + 'D:\\soft\\Zed\\bin\\zed.exe', + 'D:\\soft\\Zed\\bin\\Zed.exe', + 'D:\\soft\\Zed\\Zed.exe', + ] + + const localAppData = process.env.LOCALAPPDATA + if (localAppData) { + candidates.push( + join(localAppData, 'Programs', 'Zed', 'bin', 'zed.exe'), + join(localAppData, 'Programs', 'Zed', 'Zed.exe'), + join(localAppData, 'Zed', 'bin', 'zed.exe'), + join(localAppData, 'Zed', 'Zed.exe'), + ) + } + + const programFiles = process.env.ProgramFiles + if (programFiles) candidates.push(join(programFiles, 'Zed', 'Zed.exe')) + + const programFilesX86 = process.env['ProgramFiles(x86)'] + if (programFilesX86) candidates.push(join(programFilesX86, 'Zed', 'Zed.exe')) + + const userProfile = process.env.USERPROFILE + if (userProfile) { + candidates.push( + join(userProfile, 'scoop', 'apps', 'zed', 'current', 'Zed.exe'), + join(userProfile, 'scoop', 'shims', 'zed.exe'), + ) + } + + // Walk every directory in PATH + const pathEnv = process.env.PATH || '' + for (const dir of pathEnv.split(';')) { + if (!dir) continue + candidates.push(join(dir, 'zed.exe'), join(dir, 'Zed.exe')) + } + + // Deduplicate and check existence + const seen = new Set() + for (const candidate of candidates) { + if (seen.has(candidate)) continue + seen.add(candidate) + try { + if (existsSync(candidate)) { + zedPathMemo = candidate + return candidate + } + } catch { /* permission denied — skip */ } + } + + // Registry fallback: read the zed:// protocol handler + try { + const out = execSync( + 'powershell -NoProfile -Command "& {get-itemproperty \'HKCU:\\Software\\Classes\\zed\\shell\\open\\command\' \'(default)\' 2>$null} | select -expand \'(default)\' -first 1"', + { encoding: 'utf-8', timeout: 3000, windowsHide: true }, + ).trim() + const m = out.match(/^"([^"]+\.exe)"/) + if (m && existsSync(m[1])) { zedPathMemo = m[1]; return m[1] } + } catch { /* fall through */ } + try { + const out = execSync( + 'powershell -NoProfile -Command "& {get-itemproperty \'HKLM:\\SOFTWARE\\Classes\\zed\\shell\\open\\command\' \'(default)\' 2>$null} | select -expand \'(default)\' -first 1"', + { encoding: 'utf-8', timeout: 3000, windowsHide: true }, + ).trim() + const m = out.match(/^"([^"]+\.exe)"/) + if (m && existsSync(m[1])) { zedPathMemo = m[1]; return m[1] } + } catch { /* fall through */ } + + zedPathMemo = null + return null +} + +/** Extract a Windows file path from a `zed://file/C:/path` URL, normalizing + * to native backslash format. Handles both bare and leading-slash forms + * (`/C:/…`) that URL parsers produce. */ +export function zedUrlToPath(url: string): string | null { + const prefix = 'zed://file/' + if (!url.startsWith(prefix)) return null + let raw = url.slice(prefix.length) + try { raw = decodeURI(raw) } catch { /* keep as-is */ } + // URL parsers produce /C:/... from zed://file/C:/... + raw = raw.replace(/^\/([a-zA-Z]:)/, '$1') + return raw.replace(/\//g, '\\') +} + /** Hand a custom-scheme URL to the OS protocol handler. */ export function urlCommand(url: string, platform: NodeJS.Platform = process.platform): ExternalCommand { + // Zed on Windows does not correctly parse zed://file/C:/path URLs. + // Bypass the protocol handler and launch Zed directly with the file path. + if (platform === 'win32' && url.startsWith('zed://')) { + const zedPath = findZedPath() + const filePath = zedUrlToPath(url) + if (zedPath && filePath) { + return { command: zedPath, args: [filePath] } + } + // Fall through to rundll32 if zed not found + } switch (platform) { case 'darwin': return { command: 'open', args: [url] }