Fix WSL UNC OpenCode binaries on Windows (#341)
## Summary - support Windows validation and launch of OpenCode binaries stored under WSL UNC paths like \\wsl.localhost\... - harden the existing manual directory browser so absolute, UNC, and WSL paths can be pasted and navigated reliably - harden WSL env/path propagation, UNC workspace handling, runtime shutdown, and add targeted tests Partially addresses #5. ## Testing - node --test --import tsx src/workspaces/__tests__/spawn.test.ts - npm run typecheck --workspace @neuralnomads/codenomad - npm run typecheck --workspace @codenomad/ui
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
import { probeBinaryVersion } from "../../workspaces/spawn"
|
||||||
import type { SettingsService } from "../../settings/service"
|
import type { SettingsService } from "../../settings/service"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||||
|
|||||||
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
|
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
|
||||||
|
|
||||||
|
describe("parseWslUncPath", () => {
|
||||||
|
it("parses WSL UNC paths into distro and linux path", () => {
|
||||||
|
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
|
||||||
|
distro: "Ubuntu",
|
||||||
|
linuxPath: "/home/dev/.opencode/bin/opencode",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports the legacy wsl$ UNC prefix", () => {
|
||||||
|
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
|
||||||
|
distro: "Ubuntu",
|
||||||
|
linuxPath: "/home/dev",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveWslWorkingDirectory", () => {
|
||||||
|
it("keeps WSL workspace folders in the same distro", () => {
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
|
||||||
|
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
|
||||||
|
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
|
||||||
|
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects WSL workspace folders from a different distro", () => {
|
||||||
|
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildWindowsSpawnSpec", () => {
|
||||||
|
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve", "--port", "0"],
|
||||||
|
{
|
||||||
|
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||||
|
env: {
|
||||||
|
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||||
|
CODENOMAD_INSTANCE_ID: "workspace-123",
|
||||||
|
OPENCODE_SERVER_PASSWORD: "secret",
|
||||||
|
},
|
||||||
|
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, [
|
||||||
|
"--distribution",
|
||||||
|
"Ubuntu",
|
||||||
|
"--cd",
|
||||||
|
"/home/dev/workspace",
|
||||||
|
"--exec",
|
||||||
|
"/home/dev/.opencode/bin/opencode",
|
||||||
|
"serve",
|
||||||
|
"--port",
|
||||||
|
"0",
|
||||||
|
])
|
||||||
|
assert.equal(spec.cwd, undefined)
|
||||||
|
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("upgrades existing WSLENV path entries to include /p", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||||
|
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
|
||||||
|
},
|
||||||
|
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("propagates inherited known path variables even when they are not explicitly requested", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve", "--port", "0"],
|
||||||
|
{
|
||||||
|
cwd: String.raw`C:\Users\dev\workspace`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, [
|
||||||
|
"--distribution",
|
||||||
|
"Ubuntu",
|
||||||
|
"--exec",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||||
|
"codenomad-wsl-launch",
|
||||||
|
String.raw`C:\Users\dev\workspace`,
|
||||||
|
"/home/dev/.opencode/bin/opencode",
|
||||||
|
"serve",
|
||||||
|
"--port",
|
||||||
|
"0",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses wslpath for UNC network workspace folders", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve"],
|
||||||
|
{
|
||||||
|
cwd: String.raw`\\server\share\workspace`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, [
|
||||||
|
"--distribution",
|
||||||
|
"Ubuntu",
|
||||||
|
"--exec",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||||
|
"codenomad-wsl-launch",
|
||||||
|
String.raw`\\server\share\workspace`,
|
||||||
|
"/home/dev/.opencode/bin/opencode",
|
||||||
|
"serve",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can wrap WSL launches to emit the Linux PID marker", () => {
|
||||||
|
const spec = buildWindowsSpawnSpec(
|
||||||
|
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||||
|
["serve"],
|
||||||
|
{
|
||||||
|
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||||
|
wslPidMarker: "__CODENOMAD_WSL_PID__:",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, [
|
||||||
|
"--distribution",
|
||||||
|
"Ubuntu",
|
||||||
|
"--exec",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
|
||||||
|
"codenomad-wsl-launch",
|
||||||
|
"/home/dev/workspace",
|
||||||
|
"/home/dev/.opencode/bin/opencode",
|
||||||
|
"serve",
|
||||||
|
])
|
||||||
|
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds the WSL kill command for tracked Linux PIDs", () => {
|
||||||
|
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
|
||||||
|
|
||||||
|
assert.equal(spec.command, "wsl.exe")
|
||||||
|
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,100 +4,10 @@ import path from "path"
|
|||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
|
||||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
|
||||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
|
||||||
|
|
||||||
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
|
||||||
|
|
||||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
return { command: binaryPath, args, options: {} as const }
|
|
||||||
}
|
|
||||||
|
|
||||||
const extension = path.extname(binaryPath).toLowerCase()
|
|
||||||
|
|
||||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
|
||||||
const comspec = process.env.ComSpec || "cmd.exe"
|
|
||||||
// cmd.exe requires the full command as a single string.
|
|
||||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
|
||||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: comspec,
|
|
||||||
args: ["/d", "/s", "/c", commandLine],
|
|
||||||
options: { windowsVerbatimArguments: true } as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
|
||||||
// powershell.exe ships with Windows. (pwsh may not.)
|
|
||||||
return {
|
|
||||||
command: "powershell.exe",
|
|
||||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
|
||||||
options: {} as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { command: binaryPath, args, options: {} as const }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function probeBinaryVersion(binaryPath: string): {
|
|
||||||
valid: boolean
|
|
||||||
version?: string
|
|
||||||
reported?: string
|
|
||||||
error?: string
|
|
||||||
} {
|
|
||||||
if (!binaryPath) {
|
|
||||||
return { valid: false, error: "Missing binary path" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(spec.command, spec.args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
windowsVerbatimArguments: Boolean(
|
|
||||||
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { valid: false, error: result.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const stderr = result.stderr?.trim()
|
|
||||||
const stdout = result.stdout?.trim()
|
|
||||||
const combined = stderr || stdout
|
|
||||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
|
||||||
return { valid: false, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdoutLines = String(result.stdout ?? "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
const stderrLines = String(result.stderr ?? "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
|
|
||||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
|
||||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
|
||||||
if (!reported) {
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionMatch = reported.match(VERSION_REGEX)
|
|
||||||
const version = versionMatch?.[1]
|
|
||||||
return { valid: true, version, reported }
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||||
|
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
|
||||||
|
|
||||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||||
const redacted: Record<string, string | undefined> = {}
|
const redacted: Record<string, string | undefined> = {}
|
||||||
@@ -130,6 +40,10 @@ export interface ProcessExitInfo {
|
|||||||
interface ManagedProcess {
|
interface ManagedProcess {
|
||||||
child: ChildProcess
|
child: ChildProcess
|
||||||
requestedStop: boolean
|
requestedStop: boolean
|
||||||
|
wsl?: {
|
||||||
|
distro: string
|
||||||
|
linuxPid: number | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkspaceRuntime {
|
export class WorkspaceRuntime {
|
||||||
@@ -167,7 +81,13 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
const propagatedEnvKeys = Object.keys(options.environment ?? {})
|
||||||
|
const spec = buildSpawnSpec(options.binaryPath, args, {
|
||||||
|
cwd: options.folder,
|
||||||
|
env,
|
||||||
|
propagateEnvKeys: propagatedEnvKeys,
|
||||||
|
wslPidMarker: WSL_PID_MARKER,
|
||||||
|
})
|
||||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
@@ -197,14 +117,18 @@ export class WorkspaceRuntime {
|
|||||||
)
|
)
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spec.command, spec.args, {
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: spec.cwd,
|
||||||
env,
|
env: spec.env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached,
|
detached,
|
||||||
...spec.options,
|
...spec.options,
|
||||||
})
|
})
|
||||||
|
|
||||||
const managed: ManagedProcess = { child, requestedStop: false }
|
const managed: ManagedProcess = {
|
||||||
|
child,
|
||||||
|
requestedStop: false,
|
||||||
|
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
|
||||||
|
}
|
||||||
this.processes.set(options.workspaceId, managed)
|
this.processes.set(options.workspaceId, managed)
|
||||||
|
|
||||||
let stdoutBuffer = ""
|
let stdoutBuffer = ""
|
||||||
@@ -284,6 +208,15 @@ export class WorkspaceRuntime {
|
|||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
if (!trimmed) continue
|
if (!trimmed) continue
|
||||||
|
|
||||||
|
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
|
||||||
|
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
|
||||||
|
if (Number.isFinite(linuxPid) && linuxPid > 0) {
|
||||||
|
managed.wsl.linuxPid = linuxPid
|
||||||
|
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
recentStdout.push(trimmed)
|
recentStdout.push(trimmed)
|
||||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||||
recentStdout.shift()
|
recentStdout.shift()
|
||||||
@@ -398,11 +331,44 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trySignalWslProcess = (signal: NodeJS.Signals) => {
|
||||||
|
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
|
||||||
|
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
|
||||||
|
const exitCode = result.status
|
||||||
|
if (exitCode === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||||
|
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||||
|
const combined = `${stdout}\n${stderr}`
|
||||||
|
if (combined.includes("no such process") || combined.includes("not found")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
|
||||||
|
"WSL kill failed",
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
// Best-effort: terminate the whole process tree rooted at pid.
|
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
|
||||||
// Use /F only for escalation.
|
if (!trySignalWslProcess(signal)) {
|
||||||
tryTaskkill(signal === "SIGKILL")
|
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
|
||||||
|
tryTaskkill(signal === "SIGKILL")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
307
packages/server/src/workspaces/spawn.ts
Normal file
307
packages/server/src/workspaces/spawn.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||||
|
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||||
|
|
||||||
|
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
||||||
|
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
|
||||||
|
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
|
||||||
|
|
||||||
|
export interface SpawnSpec {
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
options: {
|
||||||
|
windowsVerbatimArguments?: boolean
|
||||||
|
}
|
||||||
|
cwd?: string
|
||||||
|
env?: NodeJS.ProcessEnv
|
||||||
|
wsl?: {
|
||||||
|
distro: string
|
||||||
|
pidMarker?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildSpawnSpecOptions {
|
||||||
|
cwd?: string
|
||||||
|
env?: NodeJS.ProcessEnv
|
||||||
|
propagateEnvKeys?: string[]
|
||||||
|
wslPidMarker?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WslPath {
|
||||||
|
distro: string
|
||||||
|
linuxPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WslWorkingDirectory =
|
||||||
|
| { kind: "linux"; path: string }
|
||||||
|
| { kind: "windows"; path: string }
|
||||||
|
|
||||||
|
export function parseWslUncPath(input: string): WslPath | null {
|
||||||
|
const normalized = input.trim().replace(/\//g, "\\")
|
||||||
|
const match = normalized.match(WSL_UNC_PATH_REGEX)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const distro = match[1] ?? ""
|
||||||
|
const remainder = match[2] ?? ""
|
||||||
|
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
distro,
|
||||||
|
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
|
||||||
|
const wslFolder = parseWslUncPath(folder)
|
||||||
|
if (wslFolder) {
|
||||||
|
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowsFolder = normalizeWindowsPath(folder)
|
||||||
|
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||||
|
const wslPath = parseWslUncPath(binaryPath)
|
||||||
|
if (wslPath) {
|
||||||
|
return buildWslSpawnSpec(wslPath, args, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(binaryPath).toLowerCase()
|
||||||
|
|
||||||
|
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||||
|
const comspec = process.env.ComSpec || "cmd.exe"
|
||||||
|
// cmd.exe requires the full command as a single string.
|
||||||
|
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||||
|
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: comspec,
|
||||||
|
args: ["/d", "/s", "/c", commandLine],
|
||||||
|
options: { windowsVerbatimArguments: true },
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||||
|
// powershell.exe ships with Windows. (pwsh may not.)
|
||||||
|
return {
|
||||||
|
command: "powershell.exe",
|
||||||
|
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: binaryPath,
|
||||||
|
args,
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return {
|
||||||
|
command: binaryPath,
|
||||||
|
args,
|
||||||
|
options: {},
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildWindowsSpawnSpec(binaryPath, args, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
|
||||||
|
return {
|
||||||
|
command: "wsl.exe",
|
||||||
|
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
|
||||||
|
options: {},
|
||||||
|
wsl: { distro },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probeBinaryVersion(binaryPath: string): {
|
||||||
|
valid: boolean
|
||||||
|
version?: string
|
||||||
|
reported?: string
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
if (!binaryPath) {
|
||||||
|
return { valid: false, error: "Missing binary path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||||
|
const result = spawnSync(spec.command, spec.args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
cwd: spec.cwd,
|
||||||
|
env: spec.env,
|
||||||
|
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { valid: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim()
|
||||||
|
const stdout = result.stdout?.trim()
|
||||||
|
const combined = stderr || stdout
|
||||||
|
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||||
|
return { valid: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdoutLines = String(result.stdout ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
const stderrLines = String(result.stderr ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
|
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||||
|
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||||
|
if (!reported) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionMatch = reported.match(VERSION_REGEX)
|
||||||
|
const version = versionMatch?.[1]
|
||||||
|
return { valid: true, version, reported }
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
|
||||||
|
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
|
||||||
|
if (options.cwd && !workingDirectory) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wslArgs = ["--distribution", wslPath.distro]
|
||||||
|
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
|
||||||
|
|
||||||
|
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
|
||||||
|
wslArgs.push("--cd", workingDirectory.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldWrapWithShell) {
|
||||||
|
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
|
||||||
|
wslArgs.push(
|
||||||
|
"--exec",
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
launchScript,
|
||||||
|
"codenomad-wsl-launch",
|
||||||
|
)
|
||||||
|
if (workingDirectory) {
|
||||||
|
wslArgs.push(workingDirectory.path)
|
||||||
|
}
|
||||||
|
wslArgs.push(
|
||||||
|
wslPath.linuxPath,
|
||||||
|
...args,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
wslArgs.push("--exec", wslPath.linuxPath, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: "wsl.exe",
|
||||||
|
args: wslArgs,
|
||||||
|
options: {},
|
||||||
|
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
|
||||||
|
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
|
||||||
|
const steps: string[] = []
|
||||||
|
|
||||||
|
if (pidMarker) {
|
||||||
|
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workingDirectory?.kind === "linux") {
|
||||||
|
steps.push('cd "$1"')
|
||||||
|
steps.push("shift")
|
||||||
|
} else if (workingDirectory?.kind === "windows") {
|
||||||
|
steps.push('cd "$(wslpath -au "$1")"')
|
||||||
|
steps.push("shift")
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push('exec "$@"')
|
||||||
|
return steps.join(" && ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWindowsPath(input: string): string | null {
|
||||||
|
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
|
||||||
|
if (!env) {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysToPropagate = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
|
||||||
|
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
if (keysToPropagate.length === 0) {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = { ...env }
|
||||||
|
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
|
||||||
|
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
|
||||||
|
|
||||||
|
for (const key of keysToPropagate) {
|
||||||
|
const existingEntry = byName.get(key)
|
||||||
|
if (existingEntry) {
|
||||||
|
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.WSLENV = Array.from(byName.values()).join(":")
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
|
||||||
|
if (!requiresPathTranslation) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
const [name, rawFlags = ""] = entry.split("/")
|
||||||
|
if (rawFlags.includes("p")) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
|
||||||
|
}
|
||||||
@@ -58,6 +58,16 @@ function resolveAbsolutePath(root: string, relativePath: string) {
|
|||||||
return `${trimmedRoot}${normalized}`
|
return `${trimmedRoot}${normalized}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) {
|
||||||
|
if (!metadata || metadata.pathKind === "drives") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if (metadata.pathKind === "relative") {
|
||||||
|
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||||
|
}
|
||||||
|
return metadata.displayPath
|
||||||
|
}
|
||||||
|
|
||||||
type FolderRow =
|
type FolderRow =
|
||||||
| { type: "up"; path: string }
|
| { type: "up"; path: string }
|
||||||
| { type: "folder"; entry: FileSystemEntry }
|
| { type: "folder"; entry: FileSystemEntry }
|
||||||
@@ -67,6 +77,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [pathInput, setPathInput] = createSignal("")
|
||||||
|
const [pathInputDirty, setPathInputDirty] = createSignal(false)
|
||||||
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||||
@@ -75,12 +87,16 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
|
|
||||||
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||||
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
|
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||||
|
let latestNavigationId = 0
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
|
setRootPath("")
|
||||||
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
|
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
|
||||||
setLoadingPaths(new Set<string>())
|
setLoadingPaths(new Set<string>())
|
||||||
setCurrentPathKey(null)
|
setCurrentPathKey(null)
|
||||||
setCurrentMetadata(null)
|
setCurrentMetadata(null)
|
||||||
|
setPathInput("")
|
||||||
|
setPathInputDirty(false)
|
||||||
metadataCache.clear()
|
metadataCache.clear()
|
||||||
inFlightRequests.clear()
|
inFlightRequests.clear()
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
async function initialize() {
|
async function initialize() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const metadata = await loadDirectory()
|
await navigateTo()
|
||||||
applyMetadata(metadata)
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
|
||||||
setError(message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -197,13 +209,22 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function navigateTo(path?: string) {
|
async function navigateTo(path?: string) {
|
||||||
|
const navigationId = ++latestNavigationId
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const metadata = await loadDirectory(path)
|
const metadata = await loadDirectory(path)
|
||||||
|
if (navigationId !== latestNavigationId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
applyMetadata(metadata)
|
applyMetadata(metadata)
|
||||||
|
return metadata
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (navigationId !== latestNavigationId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||||
setError(message)
|
setError(message)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,31 +246,58 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleNavigateTo(path: string) {
|
function handleNavigateTo(path: string) {
|
||||||
|
setPathInputDirty(false)
|
||||||
void navigateTo(path)
|
void navigateTo(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNavigateUp() {
|
function handleNavigateUp() {
|
||||||
const parent = currentMetadata()?.parentPath
|
const parent = currentMetadata()?.parentPath
|
||||||
if (parent) {
|
if (parent) {
|
||||||
|
setPathInputDirty(false)
|
||||||
void navigateTo(parent)
|
void navigateTo(parent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentAbsolutePath = createMemo(() => {
|
const currentAbsolutePath = createMemo(() => {
|
||||||
const metadata = currentMetadata()
|
return getAbsolutePathFromMetadata(currentMetadata())
|
||||||
if (!metadata) {
|
})
|
||||||
return ""
|
|
||||||
|
createEffect(() => {
|
||||||
|
const absolutePath = currentAbsolutePath()
|
||||||
|
if (!pathInputDirty()) {
|
||||||
|
setPathInput(absolutePath)
|
||||||
}
|
}
|
||||||
if (metadata.pathKind === "drives") {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if (metadata.pathKind === "relative") {
|
|
||||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
|
||||||
}
|
|
||||||
return metadata.displayPath
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
||||||
|
const canSubmitPath = createMemo(() => pathInput().trim().length > 0)
|
||||||
|
|
||||||
|
async function handlePathSubmit() {
|
||||||
|
const target = pathInput().trim()
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const metadata = await navigateTo(target)
|
||||||
|
if (!metadata) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPathInputDirty(false)
|
||||||
|
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectCurrent() {
|
||||||
|
const target = pathInput().trim()
|
||||||
|
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||||
|
if (!metadata) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPathInputDirty(false)
|
||||||
|
const absolute = getAbsolutePathFromMetadata(metadata)
|
||||||
|
if (absolute) {
|
||||||
|
setPathInput(absolute)
|
||||||
|
props.onSelect(absolute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleEntrySelect(entry: FileSystemEntry) {
|
function handleEntrySelect(entry: FileSystemEntry) {
|
||||||
const absolutePath = entry.absolutePath
|
const absolutePath = entry.absolutePath
|
||||||
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
|
|
||||||
async function handleCreateFolder() {
|
async function handleCreateFolder() {
|
||||||
if (creatingFolder()) return
|
if (creatingFolder()) return
|
||||||
const metadata = currentMetadata()
|
const target = pathInput().trim()
|
||||||
|
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||||
if (!metadata || metadata.pathKind === "drives") {
|
if (!metadata || metadata.pathKind === "drives") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setPathInputDirty(false)
|
||||||
|
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||||
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<div class="directory-browser-current">
|
<div class="directory-browser-current">
|
||||||
<div class="directory-browser-current-meta">
|
<div class="directory-browser-current-meta">
|
||||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pathInput()}
|
||||||
|
onInput={(event) => {
|
||||||
|
setPathInput(event.currentTarget.value)
|
||||||
|
setPathInputDirty(true)
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
void handlePathSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
spellcheck={false}
|
||||||
|
class="selector-input directory-browser-current-path"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="directory-browser-current-actions">
|
<div class="directory-browser-current-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||||
disabled={!canSelectCurrent() || creatingFolder()}
|
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
|
||||||
onClick={() => {
|
onClick={() => void handleSelectCurrent()}
|
||||||
const absolute = currentAbsolutePath()
|
|
||||||
if (absolute) {
|
|
||||||
props.onSelect(absolute)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("directoryBrowser.selectCurrent")}
|
{t("directoryBrowser.selectCurrent")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user