Compare commits
5 Commits
no-more-no
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a10d12e0 | ||
|
|
68551f6731 | ||
|
|
662a6b94b0 | ||
|
|
77df40169a | ||
|
|
3b411e2e73 |
12
.github/workflows/manual-npm-publish.yml
vendored
12
.github/workflows/manual-npm-publish.yml
vendored
@@ -47,7 +47,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 22
|
NODE_VERSION: 22
|
||||||
PUBLISH_NPM_VERSION: 11.5.1
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -60,15 +59,8 @@ jobs:
|
|||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
- name: Prepare pinned npm CLI
|
- name: Ensure npm >=11.5.1
|
||||||
shell: bash
|
run: npm install -g npm@latest
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
tool_dir="$RUNNER_TEMP/publish-npm"
|
|
||||||
mkdir -p "$tool_dir"
|
|
||||||
npm install --prefix "$tool_dir" "npm@${PUBLISH_NPM_VERSION}" --no-audit --no-fund
|
|
||||||
echo "$tool_dir/node_modules/npm/bin" >> "$GITHUB_PATH"
|
|
||||||
"$tool_dir/node_modules/npm/bin/npm-cli.js" --version
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ function createWindow() {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
spellcheck: !isMac,
|
spellcheck: !isMac,
|
||||||
|
additionalArguments: ["--codenomad-window-context=local"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -438,6 +439,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
spellcheck: !isMac,
|
spellcheck: !isMac,
|
||||||
|
additionalArguments: ["--codenomad-window-context=remote"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
function resolveWindowContext() {
|
||||||
|
const prefix = "--codenomad-window-context="
|
||||||
|
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
|
||||||
|
const context = arg ? arg.slice(prefix.length) : "local"
|
||||||
|
return context === "remote" ? "remote" : "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeHost(windowContext) {
|
||||||
|
return "electron"
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowContext = resolveWindowContext()
|
||||||
|
|
||||||
|
const localElectronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||||
@@ -26,4 +39,15 @@ const electronAPI = {
|
|||||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
const remoteElectronAPI = {
|
||||||
|
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
|
||||||
|
setWakeLock: localElectronAPI.setWakeLock,
|
||||||
|
showNotification: localElectronAPI.showNotification,
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld(
|
||||||
|
"electronAPI",
|
||||||
|
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
|
||||||
|
)
|
||||||
|
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
|
||||||
|
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))
|
||||||
|
|||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
|||||||
const ZOOM_STEP: f64 = 0.1;
|
const ZOOM_STEP: f64 = 0.1;
|
||||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||||
|
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
|
||||||
|
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||||
@@ -300,6 +302,7 @@ async fn open_remote_window_impl(
|
|||||||
let initial_url = window_url.clone();
|
let initial_url = window_url.clone();
|
||||||
|
|
||||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
||||||
|
.initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
|
||||||
.title(title)
|
.title(title)
|
||||||
.inner_size(1400.0, 900.0)
|
.inner_size(1400.0, 900.0)
|
||||||
.min_inner_size(800.0, 600.0)
|
.min_inner_size(800.0, 600.0)
|
||||||
@@ -542,6 +545,9 @@ fn main() {
|
|||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
set_windows_app_user_model_id();
|
||||||
build_menu(&app.handle())?;
|
build_menu(&app.handle())?;
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
|
||||||
|
}
|
||||||
if let Some(shortcut) = fullscreen_shortcut() {
|
if let Some(shortcut) = fullscreen_shortcut() {
|
||||||
let shortcut_manager = app.handle().global_shortcut();
|
let shortcut_manager = app.handle().global_shortcut();
|
||||||
let _ = shortcut_manager.register(shortcut.clone());
|
let _ = shortcut_manager.register(shortcut.clone());
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { getLogger } from "./lib/logger"
|
|||||||
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
||||||
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
|
||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
@@ -137,7 +137,7 @@ const App: Component = () => {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const shouldShow =
|
const shouldShow =
|
||||||
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
!isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||||
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
// Listen for Tauri menu events
|
// Listen for Tauri menu events
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||||
if (tauriBridge?.event) {
|
if (tauriBridge?.event) {
|
||||||
let unlistenMenu: (() => void) | null = null
|
let unlistenMenu: (() => void) | null = null
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S
|
|||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||||
import VersionPill from "./version-pill"
|
import VersionPill from "./version-pill"
|
||||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
@@ -16,7 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
import { openExternalUrl } from "../lib/external-url"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { runtimeEnv } from "../lib/runtime-env"
|
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
|
||||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
@@ -58,7 +58,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||||
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||||
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
type LanguageOption = { value: Locale; label: string }
|
type LanguageOption = { value: Locale; label: string }
|
||||||
@@ -78,6 +77,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
const serverList = () => remoteServers()
|
const serverList = () => remoteServers()
|
||||||
const isLoading = () => Boolean(props.isLoading)
|
const isLoading = () => Boolean(props.isLoading)
|
||||||
|
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
|
||||||
|
|
||||||
function getActiveListLength() {
|
function getActiveListLength() {
|
||||||
return activeTab() === "local" ? folders().length : serverList().length
|
return activeTab() === "local" ? folders().length : serverList().length
|
||||||
@@ -124,17 +124,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
const normalizedKey = e.key.toLowerCase()
|
const normalizedKey = e.key.toLowerCase()
|
||||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||||
const blockedKeys = [
|
const blockedKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", "Enter"]
|
||||||
"ArrowDown",
|
|
||||||
"ArrowUp",
|
|
||||||
"PageDown",
|
|
||||||
"PageUp",
|
|
||||||
"Home",
|
|
||||||
"End",
|
|
||||||
"Enter",
|
|
||||||
"Backspace",
|
|
||||||
"Delete",
|
|
||||||
]
|
|
||||||
|
|
||||||
if (isLoading()) {
|
if (isLoading()) {
|
||||||
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
|
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
|
||||||
@@ -192,21 +182,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleEnterKey()
|
handleEnterKey()
|
||||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
|
||||||
e.preventDefault()
|
|
||||||
if (listLength > 0 && focusMode() === "recent") {
|
|
||||||
if (activeTab() === "local") {
|
|
||||||
const folder = folders()[selectedIndex()]
|
|
||||||
if (folder) {
|
|
||||||
handleRemove(folder.path)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const server = serverList()[selectedIndex()]
|
|
||||||
if (server) {
|
|
||||||
removeRemoteServerProfile(server.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +206,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
activeTab()
|
activeTab()
|
||||||
|
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
|
||||||
|
setActiveTab("local")
|
||||||
|
return
|
||||||
|
}
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
})
|
})
|
||||||
@@ -305,11 +284,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openServerDialog() {
|
function openServerDialog() {
|
||||||
|
if (!canUseRemoteServerWindows()) return
|
||||||
resetServerDialog()
|
resetServerDialog()
|
||||||
setIsServerDialogOpen(true)
|
setIsServerDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||||
|
if (openWindow && !canUseRemoteServerWindows()) {
|
||||||
|
throw new Error("Remote server windows can only be opened from a local desktop window")
|
||||||
|
}
|
||||||
|
|
||||||
const trimmedName = input.name.trim()
|
const trimmedName = input.name.trim()
|
||||||
const trimmedUrl = input.baseUrl.trim()
|
const trimmedUrl = input.baseUrl.trim()
|
||||||
if (!trimmedName || !trimmedUrl) {
|
if (!trimmedName || !trimmedUrl) {
|
||||||
@@ -334,7 +318,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
if (openWindow) {
|
if (openWindow) {
|
||||||
const remoteProxySession =
|
const remoteProxySession =
|
||||||
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
||||||
? await serverApi.createRemoteProxySession({
|
? await serverApi.createRemoteProxySession({
|
||||||
baseUrl: profile.baseUrl,
|
baseUrl: profile.baseUrl,
|
||||||
skipTlsVerify: profile.skipTlsVerify,
|
skipTlsVerify: profile.skipTlsVerify,
|
||||||
@@ -379,6 +363,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleConnectSavedServer(id: string) {
|
async function handleConnectSavedServer(id: string) {
|
||||||
|
if (!canUseRemoteServerWindows()) return
|
||||||
const target = remoteServers().find((entry) => entry.id === id)
|
const target = remoteServers().find((entry) => entry.id === id)
|
||||||
if (!target || connectingServerId()) return
|
if (!target || connectingServerId()) return
|
||||||
setConnectingServerId(id)
|
setConnectingServerId(id)
|
||||||
@@ -397,7 +382,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
if (nativeDialogsAvailable) {
|
if (supportsNativeDialogsInCurrentWindow()) {
|
||||||
const fallbackPath = folders()[0]?.path
|
const fallbackPath = folders()[0]?.path
|
||||||
const selected = await openNativeFolderDialog({
|
const selected = await openNativeFolderDialog({
|
||||||
title: t("folderSelection.dialog.title"),
|
title: t("folderSelection.dialog.title"),
|
||||||
@@ -554,15 +539,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Settings class="w-4 h-4" />
|
<Settings class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Show when={canUseRemoteServerWindows()}>
|
||||||
type="button"
|
<button
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
type="button"
|
||||||
onClick={() => openSettings("remote")}
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
onClick={() => openSettings("remote")}
|
||||||
title={t("instanceTabs.remote.title")}
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
>
|
title={t("instanceTabs.remote.title")}
|
||||||
<MonitorUp class="w-4 h-4" />
|
>
|
||||||
</button>
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<Show when={props.onClose}>
|
<Show when={props.onClose}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -636,7 +623,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||||
<div class="panel flex flex-col flex-1 min-h-0">
|
<div class="panel flex flex-col flex-1 min-h-0">
|
||||||
<div class="panel-header !gap-0 !p-0">
|
<div class="panel-header !gap-0 !p-0">
|
||||||
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
<div class={`grid ${canUseRemoteServerWindows() ? "grid-cols-2" : "grid-cols-1"} gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="border-r border-base px-4 py-3 text-left transition-colors"
|
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||||
@@ -671,35 +658,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Show when={canUseRemoteServerWindows()}>
|
||||||
type="button"
|
<button
|
||||||
class="px-4 py-3 text-left transition-colors"
|
type="button"
|
||||||
classList={{
|
class="px-4 py-3 text-left transition-colors"
|
||||||
"text-primary": activeTab() === "servers",
|
classList={{
|
||||||
"text-muted hover:text-secondary": activeTab() !== "servers",
|
"text-primary": activeTab() === "servers",
|
||||||
}}
|
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||||
style={{
|
|
||||||
"background-color": "var(--surface-secondary)",
|
|
||||||
}}
|
|
||||||
onClick={() => setActiveTab("servers")}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="panel-title text-base"
|
|
||||||
style={{
|
|
||||||
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
{t("folderSelection.tabs.servers")}
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="panel-subtitle mt-1"
|
|
||||||
style={{
|
style={{
|
||||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
"background-color": "var(--surface-secondary)",
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setActiveTab("servers")}
|
||||||
>
|
>
|
||||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
<div
|
||||||
</p>
|
class="panel-title text-base"
|
||||||
</button>
|
style={{
|
||||||
|
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("folderSelection.tabs.servers")}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="panel-subtitle mt-1"
|
||||||
|
style={{
|
||||||
|
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -707,23 +696,25 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
when={activeTab() === "local"}
|
when={activeTab() === "local"}
|
||||||
fallback={
|
fallback={
|
||||||
<Show
|
<Show
|
||||||
when={remoteServers().length > 0}
|
when={canUseRemoteServerWindows() && remoteServers().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="panel-empty-state flex-1">
|
<Show when={canUseRemoteServerWindows()}>
|
||||||
<div class="panel-empty-state-icon">
|
<div class="panel-empty-state flex-1">
|
||||||
<Globe class="w-12 h-12 mx-auto" />
|
<div class="panel-empty-state-icon">
|
||||||
|
<Globe class="w-12 h-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||||
|
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||||
|
onClick={openServerDialog}
|
||||||
|
>
|
||||||
|
<Globe class="w-4 h-4" />
|
||||||
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
</Show>
|
||||||
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
|
||||||
onClick={openServerDialog}
|
|
||||||
>
|
|
||||||
<Globe class="w-4 h-4" />
|
|
||||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -891,15 +882,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<Show when={canUseRemoteServerWindows()}>
|
||||||
onClick={openServerDialog}
|
<button
|
||||||
class="button-primary w-full flex items-center justify-center text-sm"
|
onClick={openServerDialog}
|
||||||
>
|
class="button-primary w-full flex items-center justify-center text-sm"
|
||||||
<div class="flex items-center gap-2">
|
>
|
||||||
<Globe class="w-4 h-4" />
|
<div class="flex items-center gap-2">
|
||||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
<Globe class="w-4 h-4" />
|
||||||
</div>
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
</button>
|
</div>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* OpenCode settings section */}
|
||||||
@@ -935,10 +928,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>{t("folderSelection.hints.select")}</span>
|
<span>{t("folderSelection.hints.select")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<kbd class="kbd">Del</kbd>
|
|
||||||
<span>{t("folderSelection.hints.remove")}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
|||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
|
import { canOpenRemoteWindows } from "../lib/runtime-env"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { openSettings } from "../stores/settings-screen"
|
import { openSettings } from "../stores/settings-screen"
|
||||||
import type { AppTabRecord } from "../stores/app-tabs"
|
import type { AppTabRecord } from "../stores/app-tabs"
|
||||||
@@ -99,14 +100,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<Show when={canOpenRemoteWindows()}>
|
||||||
class="new-tab-button tab-remote-button"
|
<button
|
||||||
onClick={() => openSettings("remote")}
|
class="new-tab-button tab-remote-button"
|
||||||
title={t("instanceTabs.remote.title")}
|
onClick={() => openSettings("remote")}
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
title={t("instanceTabs.remote.title")}
|
||||||
>
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
<MonitorUp class="w-4 h-4" />
|
>
|
||||||
</button>
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -171,9 +171,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void handleEnterKey()
|
void handleEnterKey()
|
||||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
|
||||||
e.preventDefault()
|
|
||||||
void handleDeleteKey()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,29 +184,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteKey() {
|
|
||||||
const sessions = parentSessions()
|
|
||||||
const index = selectedIndex()
|
|
||||||
|
|
||||||
if (index >= sessions.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleSessionDelete(sessions[index].id)
|
|
||||||
|
|
||||||
const updatedSessions = parentSessions()
|
|
||||||
if (updatedSessions.length === 0) {
|
|
||||||
setFocusMode("new-session")
|
|
||||||
setSelectedIndex(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
|
||||||
setSelectedIndex(nextIndex)
|
|
||||||
setFocusMode("sessions")
|
|
||||||
scrollToIndex(nextIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
|
||||||
@@ -562,10 +536,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>{t("instanceWelcome.hints.resume")}</span>
|
<span>{t("instanceWelcome.hints.resume")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<kbd class="kbd">Del</kbd>
|
|
||||||
<span>{t("instanceWelcome.hints.delete")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
|
|||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
@@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
|
||||||
|
|
||||||
const binaries = () => opencodeBinaries()
|
const binaries = () => opencodeBinaries()
|
||||||
|
|
||||||
@@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
async function handleBrowseBinary() {
|
async function handleBrowseBinary() {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
if (nativeDialogsAvailable) {
|
if (supportsNativeDialogsInCurrentWindow()) {
|
||||||
const selected = await openNativeFileDialog({
|
const selected = await openNativeFileDialog({
|
||||||
title: t("opencodeBinarySelector.dialog.title"),
|
title: t("opencodeBinarySelector.dialog.title"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,25 +15,33 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
|||||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||||
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||||
|
import { canOpenRemoteWindows } from "../lib/runtime-env"
|
||||||
|
|
||||||
export const SettingsScreen: Component = () => {
|
export const SettingsScreen: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const sections = createMemo(() => [
|
const sections = createMemo(() => {
|
||||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
const items = [
|
||||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||||
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||||
])
|
]
|
||||||
|
|
||||||
|
if (canOpenRemoteWindows()) {
|
||||||
|
items.splice(2, 0, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") })
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
switch (activeSettingsSection()) {
|
switch (activeSettingsSection()) {
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return <NotificationsSettingsSection />
|
return <NotificationsSettingsSection />
|
||||||
case "remote":
|
case "remote":
|
||||||
return <RemoteAccessSettingsSection />
|
return canOpenRemoteWindows() ? <RemoteAccessSettingsSection /> : <AppearanceSettingsSection />
|
||||||
case "speech":
|
case "speech":
|
||||||
return <SpeechSettingsSection />
|
return <SpeechSettingsSection />
|
||||||
case "sidecars":
|
case "sidecars":
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface LspDiagnostic {
|
|||||||
range?: LspRange
|
range?: LspRange
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DiagnosticsMap = Record<string, LspDiagnostic[] | undefined>
|
||||||
|
|
||||||
export interface DiagnosticEntry {
|
export interface DiagnosticEntry {
|
||||||
id: string
|
id: string
|
||||||
severity: number
|
severity: number
|
||||||
@@ -30,7 +32,7 @@ export interface DiagnosticEntry {
|
|||||||
column: number
|
column: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDiagnosticPath(path: string) {
|
export function normalizeDiagnosticPath(path: string) {
|
||||||
return path.replace(/\\/g, "/")
|
return path.replace(/\\/g, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,49 +55,71 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr
|
|||||||
|
|
||||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||||
const input = (state.input || {}) as Record<string, unknown>
|
const input = (state.input || {}) as Record<string, unknown>
|
||||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined
|
||||||
if (!diagnosticsMap) return []
|
if (!diagnosticsMap) return []
|
||||||
|
|
||||||
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find(
|
return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path])
|
||||||
(value) => typeof value === "string" && value.length > 0,
|
}
|
||||||
) as string | undefined
|
|
||||||
|
|
||||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): string | undefined {
|
||||||
if (!normalizedPreferred) return []
|
if (Object.keys(diagnostics).length === 0) return undefined
|
||||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
|
||||||
if (candidateEntries.length === 0) return []
|
|
||||||
|
|
||||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
const normalizedPreferred = preferredPaths
|
||||||
const normalized = normalizeDiagnosticPath(path)
|
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||||
return normalized === normalizedPreferred
|
.map((value) => normalizeDiagnosticPath(value))
|
||||||
})
|
|
||||||
|
|
||||||
if (prioritizedEntries.length === 0) return []
|
if (normalizedPreferred.length === 0) return undefined
|
||||||
|
|
||||||
|
for (const preferred of normalizedPreferred) {
|
||||||
|
if (diagnostics[preferred]) return preferred
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(diagnostics)
|
||||||
|
|
||||||
|
for (const preferred of normalizedPreferred) {
|
||||||
|
const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred)
|
||||||
|
if (direct) return direct
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preferred of normalizedPreferred) {
|
||||||
|
const suffixMatch = keys.find((key) => {
|
||||||
|
const normalized = normalizeDiagnosticPath(key)
|
||||||
|
return normalized === preferred || normalized.endsWith("/" + preferred)
|
||||||
|
})
|
||||||
|
if (suffixMatch) return suffixMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): DiagnosticEntry[] {
|
||||||
|
const key = resolveDiagnosticsKey(diagnostics, preferredPaths)
|
||||||
|
if (!key) return []
|
||||||
|
|
||||||
|
const list = diagnostics[key]
|
||||||
|
if (!Array.isArray(list) || list.length === 0) return []
|
||||||
|
|
||||||
const entries: DiagnosticEntry[] = []
|
const entries: DiagnosticEntry[] = []
|
||||||
for (const [pathKey, list] of prioritizedEntries) {
|
const normalizedPath = normalizeDiagnosticPath(key)
|
||||||
if (!Array.isArray(list)) continue
|
for (let index = 0; index < list.length; index++) {
|
||||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
const diagnostic = list[index]
|
||||||
for (let index = 0; index < list.length; index++) {
|
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||||
const diagnostic = list[index]
|
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
const severityMeta = getSeverityMeta(tone)
|
||||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||||
const severityMeta = getSeverityMeta(tone)
|
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
entries.push({
|
||||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||||
entries.push({
|
severity: severityMeta.rank,
|
||||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
tone,
|
||||||
severity: severityMeta.rank,
|
label: severityMeta.label,
|
||||||
tone,
|
icon: severityMeta.icon,
|
||||||
label: severityMeta.label,
|
message: diagnostic.message,
|
||||||
icon: severityMeta.icon,
|
filePath: normalizedPath,
|
||||||
message: diagnostic.message,
|
displayPath: getRelativePath(normalizedPath),
|
||||||
filePath: normalizedPath,
|
line,
|
||||||
displayPath: getRelativePath(normalizedPath),
|
column,
|
||||||
line,
|
})
|
||||||
column,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries.sort((a, b) => a.severity - b.severity)
|
return entries.sort((a, b) => a.severity - b.severity)
|
||||||
|
|||||||
@@ -1,107 +1,14 @@
|
|||||||
import { For, Show, createMemo } from "solid-js"
|
import { For, Show, createMemo } from "solid-js"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||||
import type { DiagnosticEntry } from "../diagnostics"
|
import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics"
|
||||||
|
|
||||||
type LspRangePosition = {
|
|
||||||
line?: number
|
|
||||||
character?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type LspRange = {
|
|
||||||
start?: LspRangePosition
|
|
||||||
}
|
|
||||||
|
|
||||||
type LspDiagnostic = {
|
|
||||||
message?: string
|
|
||||||
severity?: number
|
|
||||||
range?: LspRange
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApplyPatchFile = {
|
type ApplyPatchFile = {
|
||||||
filePath?: string
|
filePath?: string
|
||||||
relativePath?: string
|
relativePath?: string
|
||||||
type?: string
|
type?: string
|
||||||
diff?: string
|
diff?: string
|
||||||
}
|
patch?: string
|
||||||
|
|
||||||
function normalizePath(value: string): string {
|
|
||||||
return value.replace(/\\/g, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
|
||||||
if (severity === 1) return "error"
|
|
||||||
if (severity === 2) return "warning"
|
|
||||||
return "info"
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
|
|
||||||
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
|
|
||||||
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
|
|
||||||
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDiagnosticsKey(
|
|
||||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
|
||||||
file: ApplyPatchFile,
|
|
||||||
): string | undefined {
|
|
||||||
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
|
|
||||||
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
|
|
||||||
if (absolute && diagnostics[absolute]) return absolute
|
|
||||||
if (relative && diagnostics[relative]) return relative
|
|
||||||
|
|
||||||
if (absolute) {
|
|
||||||
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
|
|
||||||
if (direct) return direct
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relative) {
|
|
||||||
const suffixMatch = Object.keys(diagnostics).find((key) => {
|
|
||||||
const normalized = normalizePath(key)
|
|
||||||
return normalized === relative || normalized.endsWith("/" + relative)
|
|
||||||
})
|
|
||||||
if (suffixMatch) return suffixMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDiagnostics(
|
|
||||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
|
||||||
file: ApplyPatchFile,
|
|
||||||
t: (key: string, params?: Record<string, unknown>) => string,
|
|
||||||
): DiagnosticEntry[] {
|
|
||||||
const key = resolveDiagnosticsKey(diagnostics, file)
|
|
||||||
if (!key) return []
|
|
||||||
const list = diagnostics[key]
|
|
||||||
if (!Array.isArray(list) || list.length === 0) return []
|
|
||||||
|
|
||||||
const normalizedKey = normalizePath(key)
|
|
||||||
const entries: DiagnosticEntry[] = []
|
|
||||||
for (let index = 0; index < list.length; index++) {
|
|
||||||
const diagnostic = list[index]
|
|
||||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
|
||||||
|
|
||||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
|
||||||
const severityMeta = getSeverityMeta(tone, t)
|
|
||||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
|
||||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
id: `${normalizedKey}-${index}-${diagnostic.message}`,
|
|
||||||
severity: severityMeta.rank,
|
|
||||||
tone,
|
|
||||||
label: severityMeta.label,
|
|
||||||
icon: severityMeta.icon,
|
|
||||||
message: diagnostic.message,
|
|
||||||
filePath: normalizedKey,
|
|
||||||
displayPath: getRelativePath(normalizedKey),
|
|
||||||
line,
|
|
||||||
column,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries.sort((a, b) => a.severity - b.severity)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
|
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
|
||||||
@@ -164,7 +71,7 @@ export const applyPatchRenderer: ToolRenderer = {
|
|||||||
})
|
})
|
||||||
const diagnosticsMap = createMemo(() => {
|
const diagnosticsMap = createMemo(() => {
|
||||||
const value = (payload.metadata as any).diagnostics
|
const value = (payload.metadata as any).diagnostics
|
||||||
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
|
return value && typeof value === "object" ? (value as DiagnosticsMap) : {}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (files().length === 0) {
|
if (files().length === 0) {
|
||||||
@@ -178,9 +85,9 @@ export const applyPatchRenderer: ToolRenderer = {
|
|||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file, index) => {
|
{(file, index) => {
|
||||||
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
|
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
|
||||||
const diffText = typeof file.diff === "string" ? file.diff : ""
|
const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
|
||||||
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
||||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
|
const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-apply-patch-file">
|
<div class="tool-call-apply-patch-file">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
normalizeDroppedDirectoryPaths,
|
normalizeDroppedDirectoryPaths,
|
||||||
supportsDesktopFolderDrop,
|
supportsDesktopFolderDrop,
|
||||||
} from "../native/desktop-file-drop"
|
} from "../native/desktop-file-drop"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { isTauriHost } from "../runtime-env"
|
||||||
|
|
||||||
interface UseFolderDropOptions {
|
interface UseFolderDropOptions {
|
||||||
enabled: Accessor<boolean>
|
enabled: Accessor<boolean>
|
||||||
@@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
|||||||
|
|
||||||
const bind: FolderDropBindings = {
|
const bind: FolderDropBindings = {
|
||||||
onDragEnter(event) {
|
onDragEnter(event) {
|
||||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
|||||||
setIsActive(true)
|
setIsActive(true)
|
||||||
},
|
},
|
||||||
onDragOver(event) {
|
onDragOver(event) {
|
||||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
|||||||
setIsActive(true)
|
setIsActive(true)
|
||||||
},
|
},
|
||||||
onDragLeave(event) {
|
onDragLeave(event) {
|
||||||
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
|
if (!isSupported || isTauriHost() || !containsFileDrop(event)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
reset()
|
reset()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
export async function restartCli(): Promise<boolean> {
|
export async function restartCli(): Promise<boolean> {
|
||||||
|
if (!canRestartCli()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
|
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
|
||||||
if (api?.restartCli) {
|
if (api?.restartCli) {
|
||||||
await api.restartCli()
|
await api.restartCli()
|
||||||
@@ -15,7 +19,7 @@ export async function restartCli(): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
||||||
await invoke("cli_restart")
|
await invoke("cli_restart")
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { listen } from "@tauri-apps/api/event"
|
import { listen } from "@tauri-apps/api/event"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ function getFilePath(file: File): string | null {
|
|||||||
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
||||||
return file.path
|
return file.path
|
||||||
}
|
}
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
||||||
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
||||||
return electronPath
|
return electronPath
|
||||||
@@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function supportsDesktopFolderDrop(): boolean {
|
export function supportsDesktopFolderDrop(): boolean {
|
||||||
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
|
return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function containsFileDrop(event: DragEvent): boolean {
|
export function containsFileDrop(event: DragEvent): boolean {
|
||||||
@@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<s
|
|||||||
if (uniquePaths.length === 0) {
|
if (uniquePaths.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
return resolveElectronDirectoryPaths(uniquePaths)
|
return resolveElectronDirectoryPaths(uniquePaths)
|
||||||
}
|
}
|
||||||
return uniquePaths
|
return uniquePaths
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
||||||
if (runtimeEnv.host !== "tauri") {
|
if (!isTauriHost()) {
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
||||||
if (runtimeEnv.host !== "tauri") {
|
if (!isTauriHost()) {
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { runtimeEnv } from "../runtime-env"
|
import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env"
|
||||||
import type { NativeDialogOptions } from "./types"
|
import type { NativeDialogOptions } from "./types"
|
||||||
import { openElectronNativeDialog } from "./electron/functions"
|
import { openElectronNativeDialog } from "./electron/functions"
|
||||||
import { openTauriNativeDialog } from "./tauri/functions"
|
import { openTauriNativeDialog } from "./tauri/functions"
|
||||||
@@ -6,20 +6,23 @@ import { openTauriNativeDialog } from "./tauri/functions"
|
|||||||
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
|
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
|
||||||
|
|
||||||
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
|
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
|
||||||
switch (runtimeEnv.host) {
|
if (isElectronHost()) {
|
||||||
case "electron":
|
return openElectronNativeDialog
|
||||||
return openElectronNativeDialog
|
|
||||||
case "tauri":
|
|
||||||
return openTauriNativeDialog
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
if (isTauriHost()) {
|
||||||
|
return openTauriNativeDialog
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsNativeDialogs(): boolean {
|
export function supportsNativeDialogs(): boolean {
|
||||||
return resolveNativeHandler() !== null
|
return resolveNativeHandler() !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function supportsNativeDialogsInCurrentWindow(): boolean {
|
||||||
|
return canUseNativeDialogs()
|
||||||
|
}
|
||||||
|
|
||||||
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||||
const handler = resolveNativeHandler()
|
const handler = resolveNativeHandler()
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"
|
|||||||
import type { RemoteServerProfile } from "../../../../server/src/api-types"
|
import type { RemoteServerProfile } from "../../../../server/src/api-types"
|
||||||
import { showConfirmDialog } from "../../stores/alerts"
|
import { showConfirmDialog } from "../../stores/alerts"
|
||||||
import { tGlobal } from "../i18n"
|
import { tGlobal } from "../i18n"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
|
||||||
|
|
||||||
export interface RemoteWindowOpenPayload {
|
export interface RemoteWindowOpenPayload {
|
||||||
id: string
|
id: string
|
||||||
@@ -18,6 +18,10 @@ export async function openRemoteServerWindow(
|
|||||||
entryUrl?: string,
|
entryUrl?: string,
|
||||||
proxySessionId?: string,
|
proxySessionId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (!canOpenRemoteWindows()) {
|
||||||
|
throw new Error("Remote server windows can only be opened from a local desktop window")
|
||||||
|
}
|
||||||
|
|
||||||
const payload: RemoteWindowOpenPayload = {
|
const payload: RemoteWindowOpenPayload = {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
@@ -27,7 +31,7 @@ export async function openRemoteServerWindow(
|
|||||||
skipTlsVerify: profile.skipTlsVerify,
|
skipTlsVerify: profile.skipTlsVerify,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||||
if (typeof api?.openRemoteWindow === "function") {
|
if (typeof api?.openRemoteWindow === "function") {
|
||||||
await api.openRemoteWindow(payload)
|
await api.openRemoteWindow(payload)
|
||||||
@@ -35,7 +39,7 @@ export async function openRemoteServerWindow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
const requiresLocalCertificate =
|
const requiresLocalCertificate =
|
||||||
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")
|
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { isElectronHost, isTauriHost } from "../runtime-env"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
@@ -56,11 +56,11 @@ async function setWebWakeLock(enabled: boolean): Promise<boolean> {
|
|||||||
|
|
||||||
function hasAnyWakeLockSupport(): boolean {
|
function hasAnyWakeLockSupport(): boolean {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return false
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const api = (window as any).electronAPI
|
const api = (window as any).electronAPI
|
||||||
if (api?.setWakeLock) return true
|
if (api?.setWakeLock) return true
|
||||||
}
|
}
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
return typeof window.__TAURI__?.core?.invoke === "function"
|
return typeof window.__TAURI__?.core?.invoke === "function"
|
||||||
}
|
}
|
||||||
return Boolean((navigator as any)?.wakeLock?.request)
|
return Boolean((navigator as any)?.wakeLock?.request)
|
||||||
@@ -106,13 +106,13 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
|||||||
async function applyWakeLock(enabled: boolean): Promise<boolean> {
|
async function applyWakeLock(enabled: boolean): Promise<boolean> {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return false
|
||||||
|
|
||||||
if (runtimeEnv.host === "electron") {
|
if (isElectronHost()) {
|
||||||
const ok = await setElectronWakeLock(enabled)
|
const ok = await setElectronWakeLock(enabled)
|
||||||
if (ok || !enabled) return ok
|
if (ok || !enabled) return ok
|
||||||
// fallback to web API if electron preload didn't expose it
|
// fallback to web API if electron preload didn't expose it
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (isTauriHost()) {
|
||||||
const ok = await setTauriWakeLock(enabled)
|
const ok = await setTauriWakeLock(enabled)
|
||||||
if (ok || !enabled) return ok
|
if (ok || !enabled) return ok
|
||||||
// fallback to web API if tauri command isn't available
|
// fallback to web API if tauri command isn't available
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { getLogger } from "./logger"
|
|||||||
|
|
||||||
export type HostRuntime = "electron" | "tauri" | "web"
|
export type HostRuntime = "electron" | "tauri" | "web"
|
||||||
export type PlatformKind = "desktop" | "mobile"
|
export type PlatformKind = "desktop" | "mobile"
|
||||||
|
export type WindowContextKind = "local" | "remote"
|
||||||
|
|
||||||
export interface RuntimeEnvironment {
|
export interface RuntimeEnvironment {
|
||||||
host: HostRuntime
|
host: HostRuntime
|
||||||
platform: PlatformKind
|
platform: PlatformKind
|
||||||
|
windowContext: WindowContextKind
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -14,6 +16,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
__CODENOMAD_WINDOW_CONTEXT__?: WindowContextKind
|
||||||
electronAPI?: unknown
|
electronAPI?: unknown
|
||||||
__TAURI__?: {
|
__TAURI__?: {
|
||||||
core?: TauriCoreModule
|
core?: TauriCoreModule
|
||||||
@@ -21,11 +24,41 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectWindowContext(): WindowContextKind {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "remote") {
|
||||||
|
return "remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "local") {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = window as Window & { electronAPI?: unknown }
|
||||||
|
if (typeof win.electronAPI !== "undefined" || typeof win.__TAURI__ !== "undefined") {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "remote"
|
||||||
|
}
|
||||||
|
|
||||||
function detectHost(): HostRuntime {
|
function detectHost(): HostRuntime {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return "web"
|
return "web"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const explicitHost = window.__CODENOMAD_RUNTIME_HOST__
|
||||||
|
if (explicitHost) {
|
||||||
|
return explicitHost
|
||||||
|
}
|
||||||
|
|
||||||
const win = window as Window & { electronAPI?: unknown }
|
const win = window as Window & { electronAPI?: unknown }
|
||||||
if (typeof win.electronAPI !== "undefined") {
|
if (typeof win.electronAPI !== "undefined") {
|
||||||
return "electron"
|
return "electron"
|
||||||
@@ -71,16 +104,24 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
|
|||||||
cachedEnv = {
|
cachedEnv = {
|
||||||
host: detectHost(),
|
host: detectHost(),
|
||||||
platform: detectPlatform(),
|
platform: detectPlatform(),
|
||||||
|
windowContext: detectWindowContext(),
|
||||||
}
|
}
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`)
|
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform} context=${cachedEnv.windowContext}`)
|
||||||
}
|
}
|
||||||
return cachedEnv
|
return cachedEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtimeEnv = detectRuntimeEnvironment()
|
export const runtimeEnv = detectRuntimeEnvironment()
|
||||||
|
|
||||||
export const isElectronHost = () => runtimeEnv.host === "electron"
|
export const isElectronHost = () => detectHost() === "electron"
|
||||||
export const isTauriHost = () => runtimeEnv.host === "tauri"
|
export const isTauriHost = () => detectHost() === "tauri"
|
||||||
export const isWebHost = () => runtimeEnv.host === "web"
|
export const isWebHost = () => detectHost() === "web"
|
||||||
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"
|
export const isDesktopHost = () => isElectronHost() || isTauriHost()
|
||||||
|
export const isMobilePlatform = () => detectPlatform() === "mobile"
|
||||||
|
export const isLocalWindow = () => detectWindowContext() === "local"
|
||||||
|
export const isRemoteWindow = () => detectWindowContext() === "remote"
|
||||||
|
export const canUseNativeDialogs = () => isDesktopHost() && isLocalWindow()
|
||||||
|
export const canOpenRemoteWindows = () => isDesktopHost() && isLocalWindow()
|
||||||
|
export const canRestartCli = () => isDesktopHost() && isLocalWindow()
|
||||||
|
export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
} from "../../stores/preferences"
|
} from "../../stores/preferences"
|
||||||
import type { Command } from "../commands"
|
import type { Command } from "../commands"
|
||||||
import { tGlobal } from "../i18n"
|
import { tGlobal } from "../i18n"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { isWebHost } from "../runtime-env"
|
||||||
|
|
||||||
export type BehaviorSettingKind = "toggle" | "enum"
|
export type BehaviorSettingKind = "toggle" | "enum"
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
|||||||
next,
|
next,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
disabled: () => runtimeEnv.host === "web",
|
disabled: () => isWebHost(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "toggle",
|
kind: "toggle",
|
||||||
@@ -337,13 +337,13 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[]
|
|||||||
),
|
),
|
||||||
description: () =>
|
description: () =>
|
||||||
tGlobal(
|
tGlobal(
|
||||||
runtimeEnv.host === "web"
|
isWebHost()
|
||||||
? "commands.keyboardShortcutHints.description.disabledWeb"
|
? "commands.keyboardShortcutHints.description.disabledWeb"
|
||||||
: "commands.keyboardShortcutHints.description",
|
: "commands.keyboardShortcutHints.description",
|
||||||
),
|
),
|
||||||
category: "System",
|
category: "System",
|
||||||
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
||||||
disabled: () => runtimeEnv.host === "web",
|
disabled: () => isWebHost(),
|
||||||
action: actions.toggleKeyboardShortcutHints,
|
action: actions.toggleKeyboardShortcutHints,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
12
packages/ui/src/types/global.d.ts
vendored
12
packages/ui/src/types/global.d.ts
vendored
@@ -63,10 +63,12 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
__CODENOMAD_API_BASE__?: string
|
__CODENOMAD_API_BASE__?: string
|
||||||
__CODENOMAD_EVENTS_URL__?: string
|
__CODENOMAD_EVENTS_URL__?: string
|
||||||
electronAPI?: ElectronAPI
|
__CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web"
|
||||||
__TAURI__?: TauriBridge
|
__CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote"
|
||||||
codenomadLogger?: LoggerControls
|
electronAPI?: ElectronAPI
|
||||||
|
__TAURI__?: TauriBridge
|
||||||
|
codenomadLogger?: LoggerControls
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user