Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/client/open-with.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
113 changes: 112 additions & 1 deletion src/open-external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string>()
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] }
Expand Down