Compare commits

..

1 Commits

Author SHA1 Message Date
Shantur Rathore
43db9cdb50 Release v0.14.0 - Sidecars, Remote access from app, more speedy (#325)
## Thanks for contributions
- PR #262 “fix(server): show sane remote URLs for 0.0.0.0 binds” by
@VooDisss
- PR #270 “Implement shared compact split and unified tool-call diff
layout” by @VooDisss
- PR #272 “Add log level configuration support” by @bluelovers
- PR #274 “perf(ui): fix O(n²) reactive subscriptions in timeline
effects (HUGE SPEED IMPROVEMENT)” by @pascalandr
- PR #285 “fix(ui): preserve prompt text when dismissing mention picker”
by @app/codenomadbot
- PR #291 “perf(ui): virtualize message timeline rendering, #274
follow-up ( BIG SPEED IMPROVEMENT )” by @pascalandr
- PR #309 “fix(ui): stop forced auto-follow during streaming” by
@VooDisss
- PR #320 “fix(tauri): own Windows CLI subtree with a job object” by
@pascalandr

## Highlights
- **Remote access is much easier to use**: Connect to remote CodeNomad
servers directly from the app and have full app experience remotely.
- **Sidecars**: Open locally hosted services directly in CodeNomad.
OpenVSCode Server, Terminal ( using ttyd ) and many more all available
remotely and on the move.
- **Background task notifications**: LLMs can now run background task
asynchronously and be notified when the task is done. No need to wait or
periodically checking the status.
- **Long conversations are much smoother**: Large sessions and busy
timelines render much faster, and chat scrolling behaves better while
responses stream in.
- **Clearer diffs and output views**: Tool-call diffs and session
changes now render more consistently with improved split/unified layouts
and support for unified diff patch formats.

## What’s Improved
- **Big timeline performance gains**: The message timeline got two major
performance passes, including virtualization, so large sessions stay
responsive much longer.
- **More useful context information**: Token stats are shown more
clearly, and context window reporting is simpler to understand.
- **Smoother voice and speech flows**: Speech input stays chained
correctly, dictated text inserts more naturally into prompts, and voice
mode state stays synced across listeners.
- **More dependable desktop behavior**: Desktop auth isolation is safer
per app, startup is more stable, and Windows process cleanup is more
reliable.

## Fixes
- **Less frustrating auto-scroll behavior**: Streaming reasoning, tool
output, and oversized assistant replies no longer pull you to the bottom
as aggressively, and you can toggle hold behavior for long replies.
- **More reliable timeline previews**: Message preview tooltips now
escape clipping so previews remain usable.
- **Safer prompt editing**: Dismissing the mention picker no longer
wipes your draft text.
- **Better remote session stability**: Remote keyboard handling,
reconnect flows, TLS exception scoping, and remote URL display are more
predictable.
- **Better small-screen and desktop polish**: The settings modal behaves
better on phones, speech input stays pinned correctly, and Windows
desktop builds handle CLI subprocess ownership more safely.

## Docs
- **Refreshed project docs**: README and supporting presentation content
were updated to better reflect the current product.

### Contributors
- @pascalandr
- @VooDisss
- @bluelovers

**Full Changelog**:
https://github.com/NeuralNomadsAI/CodeNomad/compare/v0.13.3...v0.14.0
2026-04-16 09:25:09 +01:00
115 changed files with 1490 additions and 11626 deletions

View File

@@ -53,7 +53,7 @@ on:
# least-privilege (e.g. dev CI uses read-only; releases grant write).
env:
NODE_VERSION: 22
NODE_VERSION: 20
jobs:
build-macos:
@@ -212,7 +212,7 @@ jobs:
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
@@ -313,7 +313,7 @@ jobs:
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
@@ -324,9 +324,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: |
packages/electron-app/release/*.zip
packages/electron-app/release/*.AppImage
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
@@ -372,7 +370,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-x64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
@@ -456,7 +454,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-arm64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
@@ -542,7 +540,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-win32-x64-msvc@2.10.1 --no-save --no-audit --no-fund --workspaces=false
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
@@ -614,7 +612,6 @@ jobs:
sudo apt-get install -y \
build-essential \
pkg-config \
xdg-utils \
libgtk-3-dev \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
@@ -643,7 +640,6 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
# Tauri CLI 2.10.1 regresses Linux AppImage bundling in CI; keep Linux on the last known-good CLI.
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
@@ -743,7 +739,6 @@ jobs:
sudo apt-get install -y \
build-essential \
pkg-config \
xdg-utils \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libgtk-3-dev:arm64 \

View File

@@ -46,8 +46,7 @@ jobs:
publish:
runs-on: ubuntu-latest
env:
NODE_VERSION: 22
PUBLISH_NPM_VERSION: 11.5.1
NODE_VERSION: 20
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -60,24 +59,17 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Prepare pinned npm CLI
shell: bash
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 "PINNED_NPM_CLI=$tool_dir/node_modules/npm/bin/npm-cli.js" >> "$GITHUB_ENV"
node "$tool_dir/node_modules/npm/bin/npm-cli.js" --version
- name: Ensure npm >=11.5.1
run: npm install -g npm@latest
- name: Install dependencies
run: node "$PINNED_NPM_CLI" ci --workspaces
run: npm ci --workspaces
- name: Ensure rollup native binary
run: node "$PINNED_NPM_CLI" install @rollup/rollup-linux-x64-gnu --no-save
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package (includes UI bundling)
run: node "$PINNED_NPM_CLI" run build --workspace packages/server
run: npm run build --workspace packages/server
- name: Set publish metadata
shell: bash
@@ -91,7 +83,7 @@ jobs:
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
- name: Bump package version for publish
run: node "$PINNED_NPM_CLI" version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Set server package name for publish
shell: bash
@@ -115,4 +107,4 @@ jobs:
else
echo "Using NPM_TOKEN authentication"
fi
node "$PINNED_NPM_CLI" publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance

View File

@@ -14,7 +14,7 @@ permissions:
contents: read
env:
NODE_VERSION: 22
NODE_VERSION: 20
jobs:
release-ui:

View File

@@ -39,7 +39,7 @@ permissions:
contents: write
env:
NODE_VERSION: 22
NODE_VERSION: 20
jobs:
prepare-release:

View File

@@ -18,7 +18,6 @@ CodeNomad transforms OpenCode from a terminal tool into a **premium desktop work
- **🎙️ Voice Input & Speech**
- **🌳 Git Worktrees**
- **💬 Rich Message Experience**
- **🧩 SideCars**
- **⌨️ Command Palette**
- **📁 File System Browser**
- **🔐 Authentication & Security**
@@ -62,60 +61,6 @@ npx @neuralnomads/codenomad-dev --launch
---
## SideCars
SideCars let you open local web tools inside CodeNomad as tabs.
<details>
<summary><strong>Configuration</strong></summary>
- **Name**: Display name used in CodeNomad
- **Port**: Local HTTP or HTTPS service running on `127.0.0.1:<port>`
- **Base path**: Mounted under `/sidecars/:id`
- **Prefix mode**:
- **Preserve prefix** forwards the full `/sidecars/:id/...` path upstream
- **Strip prefix** removes `/sidecars/:id` before forwarding the request upstream
</details>
<details>
<summary><strong>VSCode (OpenVSCode Server)</strong></summary>
Run with Docker:
```bash
docker run -it --init -p 8000:3000 -v "${HOME}:${HOME}:cached" -e HOME=${HOME} gitpod/openvscode-server --server-base-path /sidecars/vscode
```
Add SideCar as:
- **Name**: `VSCode`
- **Port**: `http://127.0.0.1:8000`
- **Base path**: `/sidecars/vscode`
- **Prefix mode**: `Preserve prefix`
</details>
<details>
<summary><strong>Terminal (ttyd)</strong></summary>
Run with:
```bash
ttyd --writable zsh
```
Add SideCar as:
- **Name**: `Terminal`
- **Port**: `http://127.0.0.1:7681`
- **Base path**: `/sidecars/terminal`
- **Prefix mode**: `Strip prefix`
</details>
---
## Requirements
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`

View File

@@ -1,17 +0,0 @@
# Wake Lock Behavior
## Product Rule
CodeNomad only requests a wake lock for qualifying active work that is already running and can continue without continuous foreground interaction. The goal is to prevent idle system sleep where the platform supports that behavior without intentionally keeping the display awake.
Wake lock must not be held when work is idle, paused, completed, cancelled, failed, or waiting for new user input or permission before it can continue.
## Platform Behavior
- **Electron:** request system-sleep-only behavior with `prevent-app-suspension`.
- **Tauri:** request the native keep-awake mode with `display: false`, `idle: true`, and `sleep: false`.
- **Web:** do not fall back to `navigator.wakeLock.request("screen")`; if a true system-sleep-only primitive is unavailable, CodeNomad degrades to no wake lock.
## Release Expectations
Wake lock should be released promptly when qualifying active work ends or when the app cleans up the active session lifecycle.

View File

@@ -1,79 +0,0 @@
---
id: SCR-2026-04-21-001
title: Wake lock should allow screen lock while preventing system sleep
status: draft
---
# Summary
Refine wake-lock behavior so the product protects long-running active work from device/system sleep without intentionally keeping the display awake. The desired product experience is: users may lock the screen or let the display sleep, and in-platform work should continue whenever the platform can support that behavior.
# Problem
Current wake-lock behavior on desktop is oriented around display wake, which prevents normal screen lock or display sleep behavior on macOS and does not match the requested product outcome. The Product Owner wants wake lock to protect only against system/device sleep during active work, not against display sleep or screen lock. Scope includes Electron, Tauri, and web, with documented best-effort degradation where platform APIs cannot provide a system-sleep-only capability.
# Requested Outcome
- Allow the screen/display to sleep or lock normally while qualifying work is in progress.
- Prevent only system/device sleep during qualifying active work on platforms that support a system-sleep-only hold.
- Keep platform behavior aligned to a single product rule: never intentionally keep the display awake as a fallback for this feature.
- Apply the behavior across Electron, Tauri, and web using best-effort platform support with explicit limitation handling.
# Product Scope
## Active Work Definition
For this change, **active work** means a user-initiated or product-initiated in-app operation that:
- has started execution,
- is represented by the product as still in progress,
- is expected to continue without continuous foreground interaction, and
- would lose reliability or stop early if the device enters normal system sleep.
Active work does **not** include:
- the app merely being open or focused,
- idle viewing or reading states,
- paused, completed, failed, or cancelled work,
- states waiting indefinitely for new user input before further execution, or
- generic background presence without a currently running task.
## Product Behavior Rule
- When active work starts, the product may request a wake lock only if the platform can do so **without intentionally blocking screen lock or display sleep**.
- When active work ends, pauses, fails, is cancelled, or no longer needs protection, the product must release the wake lock promptly.
- The product intent is consistent across platforms, but implementation is **best-effort by platform capability**, not strict-identical by mechanism.
## Fallback Policy
- If a platform can provide **system-sleep-only** protection, the product should use it.
- If a platform can only provide a **display/screen wake** lock that keeps the screen awake, the product must **not** use that mode as a fallback for this feature.
- In unsupported or partially supported environments, the product should fall back to **no wake lock** rather than preserving the old display-wake behavior.
- Unsupported behavior must be treated as a documented platform limitation, not as a product failure.
## Platform Expectations
- **Electron:** In scope to use a system-sleep-only mode if available.
- **Tauri:** In scope to use a system-sleep-only mode if available through the chosen Tauri/native path.
- **Web:** Default expectation is unsupported or partially supported for this exact behavior unless a browser/runtime exposes a true system-sleep-only primitive. A screen wake lock that keeps the display awake is not an acceptable substitute.
## Non-Goals
- Keeping the display continuously awake during long-running work.
- Preserving current display-wake behavior on platforms where that is the only available wake-lock mode.
- Inventing platform-specific user settings to choose between display wake and system-sleep-only behavior as part of this SCR.
# Acceptance Criteria
- AC-1: The specification defines **active work** in user-observable product terms, including the states that do and do not qualify for wake-lock protection.
- AC-2: The specification defines a single cross-platform product rule: qualifying active work should protect against system sleep where possible, while screen lock and display sleep remain allowed.
- AC-3: The specification defines the fallback policy for unsupported platforms: if system-sleep-only protection is unavailable, the product must not substitute display/screen wake behavior and must instead degrade to no wake lock.
- AC-4: Platform expectations are documented for Electron, Tauri, and web, including the explicit expectation that web is best-effort and may remain unsupported for this exact behavior.
- AC-5: The specification defines wake-lock release expectations so protection ends promptly when qualifying active work is no longer running.
- AC-6: Any implementation derived from this SCR must document user-visible limitations for unsupported platforms in the appropriate product-facing documentation if final technical validation confirms those limitations.
# Implementation Notes For Follow-On Technical Assessment
- Electron and Tauri feasibility still requires technical validation of the exact API mode, lifecycle reliability, and background-execution behavior.
- Web feasibility still requires confirmation of browser/runtime support, permission constraints, visibility restrictions, and whether any supported runtime offers a true system-sleep-only primitive.
- If technical validation shows a desktop platform cannot provide system-sleep-only behavior safely, implementation should follow the fallback policy above rather than retaining display-wake behavior.

1445
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,13 +30,5 @@
},
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "4.52.5",
"@rollup/rollup-darwin-x64": "4.52.5",
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
"@rollup/rollup-linux-x64-gnu": "4.52.5",
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
"@rollup/rollup-win32-x64-msvc": "4.52.5"
}
}

View File

@@ -92,7 +92,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { enabled: true }
}
try {
wakeLockId = powerSaveBlocker.start("prevent-app-suspension")
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
} catch {
wakeLockId = null
return { enabled: false }

View File

@@ -118,8 +118,6 @@ function loadLoadingScreen(window: BrowserWindow) {
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
return loader
}
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
@@ -279,7 +277,6 @@ function createWindow() {
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=local"],
},
})
@@ -294,7 +291,7 @@ function createWindow() {
showingLoadingScreen = true
currentCliUrl = null
clearWindowAllowedOrigin(window)
const loadingReady = loadLoadingScreen(window)
loadLoadingScreen(window)
if (process.env.NODE_ENV === "development") {
window.webContents.openDevTools({ mode: "detach" })
@@ -313,7 +310,11 @@ function createWindow() {
showingLoadingScreen = false
})
return loadingReady
if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
}
function showLoadingScreen(force = false) {
@@ -439,7 +440,6 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=remote"],
},
})
@@ -620,8 +620,7 @@ app.whenReady().then(() => {
// ignore
}
const loadingReady = createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
startCli()
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
@@ -638,11 +637,8 @@ app.whenReady().then(() => {
}
}
void loadingReady.finally(() => {
setTimeout(() => {
void startCli()
}, 0)
})
createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
if (isInsecureOriginAllowed(url)) {

View File

@@ -38,7 +38,7 @@ interface StartOptions {
interface CliEntryResolution {
entry: string
runner: "node" | "tsx" | "standalone"
runner: "node" | "tsx"
runnerPath?: string
}
@@ -148,15 +148,15 @@ export class CliProcessManager extends EventEmitter {
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
const cliEntry = this.resolveCliEntry(options)
let child: ManagedChild
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
if (this.shouldUsePackagedShellSupervisor(options)) {
const runtimePath = this.resolveShellNodeCommand()
const entryPath = this.resolveBundledProdEntry()
const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const shellTarget = cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
const supervisorPayload = JSON.stringify({
command: shellCommand.command,
args: shellCommand.args,
@@ -164,33 +164,28 @@ export class CliProcessManager extends EventEmitter {
})
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
)
console.info(`[cli] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
env: shellEnv,
stdio: "pipe",
serviceName: "CodeNomad CLI Supervisor",
})
this.childLaunchMode = "utility"
} else {
const cliEntry = this.resolveCliEntry(options)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
if (cliEntry.runner !== "standalone") {
env.ELECTRON_RUN_AS_NODE = "1"
}
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(
`${cliEntry.runner === "standalone" ? "" : "ELECTRON_RUN_AS_NODE=1 "}exec ${
cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
}`,
)
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
@@ -568,10 +563,6 @@ export class CliProcessManager extends EventEmitter {
}
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
if (cliEntry.runner === "standalone") {
return this.buildExecutableCommand(cliEntry.entry, args)
}
const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath))
@@ -586,10 +577,6 @@ export class CliProcessManager extends EventEmitter {
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "standalone") {
return { command: cliEntry.entry, args }
}
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
@@ -606,8 +593,9 @@ export class CliProcessManager extends EventEmitter {
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
}
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
}
private resolveTsx(): string | null {
@@ -647,25 +635,20 @@ export class CliProcessManager extends EventEmitter {
return entry
}
private resolveStandaloneProdEntry(): string {
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
const candidates = [
path.join(process.resourcesPath, "server", "dist", executableName),
path.join(mainDirname, "../resources/server/dist", executableName),
path.resolve(process.cwd(), "..", "server", "dist", executableName),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
}
} catch {
// fall through to error below
}
throw new Error(`Unable to locate standalone CodeNomad server executable (${executableName}). Run npm run build:standalone --workspace @neuralnomads/codenomad.`)
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin"
}
private resolveCliSupervisorPath(): string {
@@ -683,6 +666,26 @@ export class CliProcessManager extends EventEmitter {
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
}
private resolveShellNodeCommand(): string {
const configured = process.env.NODE_BINARY?.trim()
return configured && configured.length > 0 ? configured : "node"
}
private resolveBundledProdEntry(): string {
const candidates = [
path.join(process.resourcesPath, "server", "dist", "bin.js"),
path.join(mainDirname, "../resources/server/dist/bin.js"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
}
private describeUtilityProcessError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message

View File

@@ -1,19 +1,6 @@
const { contextBridge, ipcRenderer, webUtils } = require("electron")
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 = {
const electronAPI = {
onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
@@ -39,15 +26,4 @@ const localElectronAPI = {
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
}
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))
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -62,7 +62,7 @@
"vite-plugin-solid": "^2.10.0"
},
"build": {
"appId": "ai.neuralnomads.codenomad.client",
"appId": "ai.opencode.client",
"productName": "CodeNomad",
"directories": {
"output": "release",
@@ -147,13 +147,6 @@
"x64",
"arm64"
]
},
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { spawn } from "child_process"
import { existsSync, readFileSync } from "fs"
import { existsSync } from "fs"
import path, { join } from "path"
import { fileURLToPath } from "url"
@@ -14,46 +14,6 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
const nodeModulesPath = join(appDir, "node_modules")
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
function getPlatformEsbuildPackage() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@esbuild/linux-x64",
"linux-arm64": "@esbuild/linux-arm64",
"darwin-arm64": "@esbuild/darwin-arm64",
"darwin-x64": "@esbuild/darwin-x64",
"win32-arm64": "@esbuild/win32-arm64",
"win32-x64": "@esbuild/win32-x64",
}
return platformPackages[platformKey] ?? null
}
async function ensureEsbuildPlatformBinary() {
const pkgName = getPlatformEsbuildPackage()
if (!pkgName) {
return
}
const platformPackagePath = join(workspaceNodeModulesPath, ...pkgName.split("/"))
if (existsSync(platformPackagePath)) {
return
}
let esbuildVersion = ""
try {
esbuildVersion = JSON.parse(readFileSync(join(workspaceNodeModulesPath, "esbuild", "package.json"), "utf-8")).version ?? ""
} catch {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
console.log("📦 Step 0/3: Restoring esbuild platform binary...\n")
await run(npmCmd, ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
}
const platforms = {
mac: {
args: ["--mac", "--x64", "--arm64"],
@@ -145,8 +105,6 @@ async function build(platform) {
console.log(`\n🔨 Building for: ${config.description}\n`)
try {
await ensureEsbuildPlatformBinary()
console.log("📦 Step 1/3: Building CLI dependency...\n")
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
cwd: workspaceRoot,

View File

@@ -16,7 +16,6 @@ const npmNodeExecPath = process.env.npm_node_execpath
const serverSources = ["dist", "public", "node_modules", "package.json"]
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
const standaloneMarker = join(serverRoot, "dist", process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server")
function log(message) {
console.log(`[prepare-resources] ${message}`)
@@ -30,34 +29,6 @@ function ensureServerBuild() {
}
}
function ensureStandaloneServerBuild() {
log("building standalone server executable")
const result = spawnSync(
"npm",
["run", "build:standalone", "--workspace", "@neuralnomads/codenomad"],
{
cwd: workspaceRoot,
stdio: "inherit",
env: {
...process.env,
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
},
shell: process.platform === "win32",
},
)
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`standalone server build exited with code ${result.status ?? 1}`)
}
if (!fs.existsSync(standaloneMarker)) {
throw new Error(`Standalone server executable missing after build: ${standaloneMarker}`)
}
}
function ensureServerDependencies() {
if (fs.existsSync(serverDepsMarker)) {
return
@@ -94,51 +65,6 @@ function ensureServerDependencies() {
}
}
function ensureEsbuildPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@esbuild/linux-x64",
"linux-arm64": "@esbuild/linux-arm64",
"darwin-arm64": "@esbuild/darwin-arm64",
"darwin-x64": "@esbuild/darwin-x64",
"win32-arm64": "@esbuild/win32-arm64",
"win32-x64": "@esbuild/win32-x64",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = join(workspaceRoot, "node_modules", ...pkgName.split("/"))
if (fs.existsSync(platformPackagePath)) {
return
}
let esbuildVersion = ""
try {
esbuildVersion = JSON.parse(fs.readFileSync(join(workspaceRoot, "node_modules", "esbuild", "package.json"), "utf-8")).version ?? ""
} catch {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
log("installing esbuild platform binary (optional dep workaround)")
const result = spawnSync("npm", ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
cwd: workspaceRoot,
stdio: "inherit",
shell: process.platform === "win32",
})
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`esbuild platform install exited with code ${result.status ?? 1}`)
}
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
@@ -195,9 +121,7 @@ function stripNodeModuleBins() {
async function main() {
ensureServerBuild()
ensureStandaloneServerBuild()
ensureServerDependencies()
ensureEsbuildPlatformBinary()
copyServerArtifacts()
stripNodeModuleBins()
}

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.14.19"
"@opencode-ai/plugin": "1.3.7"
}
}
}

View File

@@ -18,7 +18,6 @@
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
"build:standalone": "node ./scripts/build-standalone.mjs",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
@@ -26,16 +25,16 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/reply-from": "^12.6.2",
"@fastify/static": "^9.1.1",
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"commander": "^12.1.0",
"fastify": "^5.8.5",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^8.1.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
@@ -43,7 +42,6 @@
"devDependencies": {
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0",
"bun": "^1.3.13",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",

View File

@@ -1,99 +0,0 @@
#!/usr/bin/env node
import fs from "fs"
import path from "path"
import { spawnSync } from "child_process"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const distDir = path.join(cliRoot, "dist")
const publicDir = path.join(cliRoot, "public")
const authPagesSourceDir = path.join(distDir, "server", "routes", "auth-pages")
const authPagesTargetDir = path.join(distDir, "auth-pages")
const explicitTarget = process.env.CODENOMAD_STANDALONE_TARGET?.trim()
const outputName = (explicitTarget?.includes("windows") || process.platform === "win32") ? "codenomad-server.exe" : "codenomad-server"
const outputPath = path.join(distDir, outputName)
const packageJsonPath = path.join(cliRoot, "package.json")
function resolveBunCommand() {
const executableName = process.platform === "win32" ? "bun.exe" : "bun"
const localBinName = process.platform === "win32" ? "bun.cmd" : "bun"
const candidates = [
path.join(cliRoot, "node_modules", ".bin", localBinName),
path.join(cliRoot, "..", "..", "node_modules", ".bin", localBinName),
path.join(cliRoot, "node_modules", "bun", "bin", executableName),
path.join(cliRoot, "..", "..", "node_modules", "bun", "bin", executableName),
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate
}
}
return "bun"
}
function fail(message) {
console.error(`[build-standalone] ${message}`)
process.exit(1)
}
function ensureArtifacts() {
const requiredPaths = [distDir, publicDir, authPagesSourceDir, packageJsonPath]
const missing = requiredPaths.filter((filePath) => !fs.existsSync(filePath))
if (missing.length > 0) {
fail(`Missing required build artifacts: ${missing.join(", ")}. Run npm run build first.`)
}
const bunResult = spawnSync(resolveBunCommand(), ["-v"], { cwd: cliRoot, encoding: "utf-8", shell: process.platform === "win32" })
if (bunResult.status !== 0) {
fail("Bun is required to build the standalone server executable. Install dependencies so the local Bun binary is available.")
}
}
function syncStandaloneAuthPages() {
fs.rmSync(authPagesTargetDir, { recursive: true, force: true })
fs.mkdirSync(path.dirname(authPagesTargetDir), { recursive: true })
fs.cpSync(authPagesSourceDir, authPagesTargetDir, { recursive: true })
}
function buildStandaloneExecutable() {
fs.rmSync(outputPath, { force: true })
const bunCommand = resolveBunCommand()
const args = ["build", "--compile"]
if (explicitTarget) {
args.push(`--target=${explicitTarget}`)
}
args.push(path.join(cliRoot, "src", "index.ts"), "--outfile", outputPath)
const result = spawnSync(bunCommand, args, {
cwd: cliRoot,
stdio: "inherit",
shell: process.platform === "win32",
})
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`bun build --compile exited with code ${result.status ?? 1}`)
}
}
function main() {
ensureArtifacts()
syncStandaloneAuthPages()
buildStandaloneExecutable()
console.log(`[build-standalone] built ${outputPath}`)
}
try {
main()
} catch (error) {
console.error("[build-standalone] failed:", error)
process.exit(1)
}

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { spawnSync } from "child_process"
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "fs"
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
@@ -14,67 +14,6 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
function stripNodeModuleBins(rootDir) {
const root = path.join(rootDir, "node_modules")
if (!existsSync(root)) {
return 0
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
return removed
}
function stripOptionalNativeAddons(rootDir) {
const nodeModulesRoot = path.join(rootDir, "node_modules")
if (!existsSync(nodeModulesRoot)) {
return 0
}
const removablePaths = [
path.join(nodeModulesRoot, "@msgpackr-extract"),
path.join(nodeModulesRoot, "msgpackr-extract"),
]
let removed = 0
for (const targetPath of removablePaths) {
if (!existsSync(targetPath)) {
continue
}
rmSync(targetPath, { recursive: true, force: true })
removed += 1
}
return removed
}
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
@@ -119,14 +58,4 @@ rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
const removedBins = stripNodeModuleBins(targetDir)
if (removedBins > 0) {
console.log(`[copy-opencode-config] Removed ${removedBins} node_modules/.bin directories`)
}
const removedNativeAddons = stripOptionalNativeAddons(targetDir)
if (removedNativeAddons > 0) {
console.log(`[copy-opencode-config] Removed ${removedNativeAddons} optional native addon package paths`)
}
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -81,55 +81,6 @@ export interface WorktreeMap {
parentSessionWorktreeSlug: Record<string, string>
}
export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"
export interface WorktreeGitStatusEntry {
path: string
originalPath?: string | null
stagedStatus: GitChangeKind | null
stagedAdditions: number
stagedDeletions: number
unstagedStatus: GitChangeKind | null
unstagedAdditions: number
unstagedDeletions: number
}
export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[]
export type WorktreeGitDiffScope = "staged" | "unstaged"
export interface WorktreeGitPathsRequest {
paths: string[]
}
export interface WorktreeGitMutationResponse {
ok: true
}
export interface WorktreeGitCommitRequest {
message: string
}
export interface WorktreeGitCommitResponse {
ok: true
commitSha?: string
}
export interface WorktreeGitDiffResponse {
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
before: string
after: string
isBinary?: boolean
}
export interface WorktreeGitDiffRequest {
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
}
export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry {
@@ -337,16 +288,6 @@ export interface RemoteServerProbeResponse {
errorCode?: string
}
export interface RemoteProxySessionCreateRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteProxySessionCreateResponse {
sessionId: string
windowUrl: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"

View File

@@ -21,7 +21,6 @@ import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { RemoteProxySessionManager } from "./server/remote-proxy"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
@@ -29,14 +28,13 @@ import { SideCarManager } from "./sidecars/manager"
import { ClientConnectionManager } from "./clients/connection-manager"
import { PluginChannelManager } from "./plugins/channel"
import { VoiceModeManager } from "./plugins/voice-mode"
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths"
const require = createRequire(import.meta.url)
const packageJson = { version: readServerPackageVersion(import.meta.url) }
const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
interface CliOptions {
host: string
@@ -377,15 +375,14 @@ async function main() {
})
: null
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
const remoteProxySessionManager = new RemoteProxySessionManager({
authManager,
logger: logger.child({ component: "remote-proxy" }),
httpsOptions: tlsResolution?.httpsOptions,
})
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
@@ -425,7 +422,6 @@ async function main() {
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
@@ -451,7 +447,6 @@ async function main() {
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,

View File

@@ -1,11 +1,22 @@
import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
import { resolveOpencodeTemplateDir } from "./runtime-paths"
const log = createLogger({ component: "opencode-config" })
const templateDir = resolveOpencodeTemplateDir(import.meta.url)
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const prodTemplateDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
path.resolve(__dirname, "opencode-config"),
].filter((dir): dir is string => Boolean(dir))
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {

View File

@@ -1,79 +0,0 @@
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
function safeModuleDir(importMetaUrl: string): string | null {
try {
return path.dirname(fileURLToPath(importMetaUrl))
} catch {
return null
}
}
function firstExistingPath(candidates: Array<string | null | undefined>, predicate: (value: string) => boolean): string | null {
for (const candidate of candidates) {
if (!candidate) continue
if (predicate(candidate)) {
return candidate
}
}
return null
}
export function getPackagedDistDir(): string {
return path.dirname(process.execPath)
}
export function resolveServerPackageRoot(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const configuredRoot = process.env.CODENOMAD_SERVER_ROOT?.trim()
const candidates = [
configuredRoot ? path.resolve(configuredRoot) : null,
moduleDir ? path.resolve(moduleDir, "..") : null,
path.resolve(getPackagedDistDir(), ".."),
]
return (
firstExistingPath(candidates, (value) => fs.existsSync(path.join(value, "package.json"))) ??
candidates.find((value): value is string => Boolean(value)) ??
process.cwd()
)
}
export function resolveServerPublicDir(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const candidates = [moduleDir ? path.resolve(moduleDir, "../public") : null, path.join(resolveServerPackageRoot(importMetaUrl), "public")]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
}
export function resolveAuthTemplatePath(importMetaUrl: string, fileName: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const distDir = getPackagedDistDir()
const candidates = [
moduleDir ? path.join(moduleDir, "auth-pages", fileName) : null,
path.join(distDir, "auth-pages", fileName),
path.join(distDir, "server", "routes", "auth-pages", fileName),
]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[0]!
}
export function resolveOpencodeTemplateDir(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const candidates = [
moduleDir ? path.resolve(moduleDir, "../../opencode-config") : null,
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : null,
moduleDir ? path.resolve(moduleDir, "opencode-config") : null,
path.join(getPackagedDistDir(), "opencode-config"),
]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
}
export function readServerPackageVersion(importMetaUrl: string): string {
const packageJsonPath = path.join(resolveServerPackageRoot(importMetaUrl), "package.json")
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { version?: unknown }
return typeof parsed.version === "string" && parsed.version.trim().length > 0 ? parsed.version : "0.0.0"
}

View File

@@ -1,248 +0,0 @@
import assert from "node:assert/strict"
import { after, afterEach, describe, it } from "node:test"
import fs from "node:fs"
import http, { type IncomingMessage, type ServerResponse } from "node:http"
import os from "node:os"
import path from "node:path"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../../auth/manager"
import type { Logger } from "../../logger"
import { RemoteProxySessionManager } from "../remote-proxy"
import { resolveHttpsOptions } from "../tls"
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
const sharedTls = resolveHttpsOptions({
enabled: true,
configDir: sharedTempDir,
host: "127.0.0.1",
logger: createStubLogger(),
})
if (!sharedTls) {
throw new Error("Failed to generate HTTPS options for remote proxy tests")
}
const sharedHttpsOptions = sharedTls.httpsOptions
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
const managers = new Set<RemoteProxySessionManager>()
afterEach(async () => {
for (const manager of managers) {
await disposeManager(manager)
}
managers.clear()
})
after(() => {
fs.rmSync(sharedTempDir, { recursive: true, force: true })
httpsDispatcher.close().catch(() => {})
})
describe("RemoteProxySessionManager", () => {
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
assert.equal(blocked.status, 403)
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session2.token }),
})
assert.equal(wrongTokenResponse.status, 401)
assert.equal(await activateSession(session1), true)
assert.equal(await activateSession(session2), true)
}, (req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.url ?? "")
})
})
it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
assert.equal(apiResponse.status, 200)
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" })
assert.equal(redirectResponse.status, 302)
assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`)
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/redirect") {
res.writeHead(302, { location: "/base/after?ok=1" })
res.end()
return
}
res.writeHead(200, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
assert.equal(loginResponse.status, 200)
const setCookie = getSetCookie(loginResponse)[0]
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
assert.doesNotMatch(setCookie, /domain=/i)
const cookieHeader = setCookie.split(";", 1)[0]
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
headers: { cookie: cookieHeader },
})
assert.equal(await whoamiResponse.text(), "session=abc123")
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/login") {
res.writeHead(200, {
"content-type": "text/plain",
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
})
res.end("ok")
return
}
if (requestUrl === "/base/whoami") {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.headers.cookie ?? "")
return
}
res.writeHead(404, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("supports explicit deletion and idle cleanup of sessions", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
assert.equal(await manager.deleteSession(session.sessionId), true)
assert.equal(await manager.deleteSession(session.sessionId), false)
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
await internalCleanup.call(manager)
assert.equal(internalSessions.has(session3.sessionId), false)
assert.equal(await manager.deleteSession(session3.sessionId), false)
}, (_req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end("ok")
})
})
})
function createSessionManager() {
const manager = new RemoteProxySessionManager({
authManager: {
isLoopbackRequest: () => true,
} as unknown as AuthManager,
logger: createStubLogger(),
httpsOptions: sharedHttpsOptions,
})
managers.add(manager)
return manager
}
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
const created = await manager.createSession(baseUrl, false)
const windowUrl = new URL(created.windowUrl)
return {
sessionId: created.sessionId,
windowUrl,
proxyOrigin: windowUrl.origin,
token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")),
}
}
async function activateSession(session: { proxyOrigin: string; token: string }) {
const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session.token }),
})
if (!response.ok) {
return false
}
const body = (await response.json()) as { ok?: boolean }
return body.ok === true
}
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(values) && values.length > 0) {
return values
}
const fallback = response.headers.get("set-cookie")
return fallback ? [fallback] : []
}
async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
return fetch(url, { dispatcher: httpsDispatcher, ...init })
}
async function disposeManager(manager: RemoteProxySessionManager) {
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).keys())
for (const sessionId of sessions) {
await manager.deleteSession(sessionId)
}
clearInterval((manager as any).cleanupTimer as NodeJS.Timeout)
}
async function withUpstreamServer(
callback: (baseUrl: string) => Promise<void>,
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
) {
const server = http.createServer(handler)
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
try {
const address = server.address()
if (!address || typeof address === "string") {
throw new Error("Failed to resolve upstream server address")
}
await callback(`http://127.0.0.1:${address.port}`)
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
}
function createStubLogger(): Logger {
const logger = {
info() {},
warn() {},
error() {},
child() {
return logger
},
}
return logger as unknown as Logger
}

View File

@@ -5,14 +5,11 @@ import replyFrom from "@fastify/reply-from"
import fs from "fs"
import { connect as connectTcp, type Socket } from "net"
import path from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { connect as connectTls, type TLSSocket } from "tls"
import { fetch } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
import type { SettingsService } from "../settings/service"
import { FileSystemBrowser } from "../filesystem/browser"
@@ -28,7 +25,6 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
@@ -41,7 +37,6 @@ import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
interface HttpServerDeps {
bindHost: string
@@ -62,7 +57,6 @@ interface HttpServerDeps {
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -204,12 +198,7 @@ export function createHttpServer(deps: HttpServerDeps) {
publicPagePaths.add("/auth/token")
}
const isLoopbackRemoteProxyDelete =
request.method === "DELETE" &&
pathname.startsWith("/api/remote-proxy/sessions/") &&
deps.authManager.isLoopbackRequest(request)
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
done()
return
}
@@ -284,7 +273,6 @@ export function createHttpServer(deps: HttpServerDeps) {
workspaceManager: deps.workspaceManager,
})
registerRemoteServerRoutes(app, { logger: apiLogger })
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
@@ -628,57 +616,57 @@ async function proxyWorkspaceRequest(args: {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
}
const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
return reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
if (logger.isLevelEnabled("trace")) {
logger.trace(
{
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: redactProxyHeadersForLogs(headers),
},
"Proxy -> OpenCode request",
)
}
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
const isNonASCII = /[^\x00-\x7F]/.test(directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
const init: any = {
method: request.method,
headers,
redirect: "manual",
}
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toProxyRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
if (logger.isLevelEnabled("trace")) {
const outgoing: Record<string, unknown> = {}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
outgoing[key] = value
}
try {
const response = await fetch(targetUrl, init)
reply.code(response.status)
applyInstanceProxyResponseHeaders(reply, response)
// Redact sensitive headers.
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
logger.trace(
{
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: outgoing,
},
"Proxy -> OpenCode request",
)
}
reply.hijack()
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
} catch (error) {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!reply.sent) {
reply.code(502).send({ error: "Workspace instance proxy failed" })
}
}
return headers
},
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
}
},
})
}
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
@@ -772,6 +760,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger: Logger
}): Promise<string | null> {
const { worktreeSlug } = params
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
if (match) {
return match.directory
}
// If the slug is new (e.g., created moments ago), refresh once.
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
}
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
@@ -869,90 +903,12 @@ function isApiRequest(rawUrl: string | null | undefined) {
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
const lower = key.toLowerCase()
if (!value || lower === "host" || isHopByHopHeader(lower)) continue
if (!value || key.toLowerCase() === "host") continue
result[key] = Array.isArray(value) ? value.join(",") : value
}
return result
}
function toProxyRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (typeof (body as { pipe?: unknown }).pipe === "function") {
return body
}
if (typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
return body
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
function buildWorkspaceInstanceProxyHeaders(
headers: FastifyRequest["headers"],
instanceAuthHeader: string | undefined,
directory: string,
): Record<string, string> {
const next = buildProxyHeaders(headers)
if (instanceAuthHeader) {
next.authorization = instanceAuthHeader
}
const isNonASCII = /[^\x00-\x7F]/.test(directory)
next["x-opencode-directory"] = isNonASCII ? encodeURIComponent(directory) : directory
return next
}
function redactProxyHeadersForLogs(headers: Record<string, string>): Record<string, string> {
const outgoing = { ...headers }
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
return outgoing
}
function applyInstanceProxyResponseHeaders(reply: FastifyReply, response: any) {
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "content-length" || lower === "content-encoding") {
return
}
reply.header(key, value)
})
}
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
const next: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
next[key] = Array.isArray(value) ? value.map(String) : String(value)
}
return next
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}
async function proxySideCarRequest(args: {
request: FastifyRequest
reply: FastifyReply

View File

@@ -1,566 +0,0 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import { randomBytes, randomUUID } from "crypto"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../auth/manager"
import type { Logger } from "../logger"
const LOOPBACK_HOST = "127.0.0.1"
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
const SESSION_IDLE_TTL_MS = 30 * 60_000
interface RemoteProxySession {
id: string
bootstrapToken: string
targetBaseUrl: URL
skipTlsVerify: boolean
localBaseUrl: URL
entryUrl: URL
bootstrapUrl: string
activated: boolean
cookiePrefix: string
app: FastifyInstance
dispatcher?: Agent
createdAt: number
lastAccessAt: number
}
export interface RemoteProxySessionManagerOptions {
authManager: AuthManager
logger: Logger
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
}
export interface RemoteProxySessionCreateResult {
sessionId: string
windowUrl: string
}
export class RemoteProxySessionManager {
private readonly sessions = new Map<string, RemoteProxySession>()
private readonly cleanupTimer: NodeJS.Timeout
constructor(private readonly options: RemoteProxySessionManagerOptions) {
this.cleanupTimer = setInterval(() => {
void this.cleanupExpiredSessions()
}, 60_000)
this.cleanupTimer.unref()
}
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
if (!this.options.httpsOptions) {
throw new Error("Local HTTPS is required for remote proxy sessions")
}
const targetBaseUrl = normalizeBaseUrl(baseUrl)
const sessionId = randomUUID()
const bootstrapToken = randomBytes(32).toString("base64url")
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
const app = Fastify({ logger: false, https: this.options.httpsOptions })
let session: RemoteProxySession | null = null
app.removeAllContentTypeParsers()
// Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body))
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(buildBootstrapPageHtml())
})
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
const body = parseTokenBody(request.body)
if (body.token !== session.bootstrapToken) {
reply.code(401).send({ error: "Invalid token" })
return
}
session.activated = true
session.lastAccessAt = Date.now()
reply.send({ ok: true })
})
app.all("/*", async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
app.setNotFoundHandler(async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
const address = new URL(addressInfo)
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
const returnTo = buildReturnToTarget(entryUrl)
session = {
id: sessionId,
bootstrapToken,
targetBaseUrl,
skipTlsVerify,
localBaseUrl,
entryUrl,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
activated: false,
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
app,
dispatcher,
createdAt: Date.now(),
lastAccessAt: Date.now(),
}
this.sessions.set(sessionId, session)
this.options.logger.info(
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
"Created remote proxy session",
)
return { sessionId, windowUrl: session.bootstrapUrl }
}
async deleteSession(sessionId: string): Promise<boolean> {
return this.disposeSession(sessionId)
}
private async cleanupExpiredSessions() {
const now = Date.now()
for (const session of Array.from(this.sessions.values())) {
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
continue
}
await this.disposeSession(session.id)
}
}
private async disposeSession(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (!session) {
return false
}
this.sessions.delete(sessionId)
session.dispatcher?.close().catch(() => {})
await session.app.close().catch(() => {})
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
return true
}
}
function normalizeBaseUrl(input: string): URL {
const parsed = new URL(input.trim())
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Server URL must use http:// or https://")
}
parsed.hash = ""
parsed.search = ""
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
return parsed
}
function buildReturnToTarget(entryUrl: URL): string {
const query = entryUrl.search ? entryUrl.search : ""
return `${entryUrl.pathname || "/"}${query}`
}
function buildBootstrapPageHtml(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
h1 { font-size: 18px; margin: 0 0 12px; }
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Connecting...</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error"></div>
</div>
<script>
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
const params = new URLSearchParams(location.search)
const returnTo = sanitizeReturnTo(params.get("returnTo"))
const errorEl = document.getElementById("error")
function sanitizeReturnTo(value) {
if (!value || typeof value !== "string") return "/"
if (!value.startsWith("/")) return "/"
if (value.startsWith("//")) return "/"
return value
}
function showError(message) {
errorEl.textContent = message
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || "Token exchange failed (" + res.status + ")")
return
}
window.location.replace(returnTo)
} catch (error) {
showError(error && error.message ? error.message : String(error))
}
}
run()
</script>
</body>
</html>`
}
function parseTokenBody(body: unknown): { token: string } {
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
const token = typeof value?.token === "string" ? value.token.trim() : ""
if (!token) {
throw new Error("Missing bootstrap token")
}
return { token }
}
function normalizeJsonBody(body: unknown): unknown {
if (Buffer.isBuffer(body)) {
return JSON.parse(body.toString("utf-8"))
}
if (typeof body === "string") {
return JSON.parse(body)
}
return body
}
function toRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
async function proxyRequest(args: {
request: FastifyRequest
reply: FastifyReply
session: RemoteProxySession
logger: Logger
}) {
const { request, reply, session, logger } = args
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
const headers = filterRequestHeaders(request.headers, session)
const init: any = {
method: request.method,
headers,
dispatcher: session.dispatcher,
redirect: "manual",
}
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
try {
const response = await fetch(upstreamUrl, init as any)
reply.code(response.status)
applyResponseHeaders(reply, response, session)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
reply.hijack()
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
} catch (error) {
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
if (!reply.sent) {
reply.code(502).send({ error: "Remote proxy request failed" })
}
}
}
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
const parsed = new URL(rawUrl, "https://localhost")
const url = new URL(baseUrl.toString())
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
url.search = stripInternalQuery(parsed.search)
url.hash = ""
return url.toString()
}
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
const basePath = normalizedBasePath(baseUrl)
if (basePath === "/") {
return requestPath
}
if (requestPath === "/") {
return basePath
}
if (pathHasBasePrefix(basePath, requestPath)) {
return requestPath
}
return `${basePath}${requestPath}`
}
function normalizedBasePath(baseUrl: URL): string {
return baseUrl.pathname || "/"
}
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
}
function stripInternalQuery(search: string): string {
if (!search || search === "?") {
return ""
}
return search
}
function filterRequestHeaders(
headers: FastifyRequest["headers"],
session: RemoteProxySession,
): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value) continue
const lower = key.toLowerCase()
if (
isHopByHopHeader(lower) ||
lower === "host" ||
lower === "content-length" ||
lower === "accept-encoding"
) {
continue
}
if (lower === "origin") {
next[key] = session.targetBaseUrl.origin
continue
}
if (lower === "referer") {
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
if (rewritten) {
next[key] = rewritten
}
continue
}
if (lower === "cookie") {
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
if (rewritten) {
next[key] = rewritten
}
continue
}
next[key] = Array.isArray(value) ? value.join(",") : value
}
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
if (!next.origin) {
next.origin = session.targetBaseUrl.origin
}
return next
}
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
if (!referer) {
return null
}
try {
const parsed = new URL(referer)
const rewritten = new URL(targetBaseUrl.toString())
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return null
}
}
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(setCookie)) {
for (const cookie of setCookie) {
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
}
}
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (
isHopByHopHeader(lower) ||
lower === "set-cookie" ||
lower === "content-length" ||
lower === "content-encoding"
) {
return
}
if (lower === "location") {
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
return
}
reply.header(key, value)
})
}
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
const next: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
next[key] = Array.isArray(value) ? value.map(String) : String(value)
}
return next
}
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
const parts = cookie.split(";").map((part) => part.trim())
const first = parts.shift() ?? ""
const separator = first.indexOf("=")
if (separator <= 0) {
return cookie
}
const name = first.slice(0, separator).trim()
const value = first.slice(separator + 1)
const rewritten = [`${cookiePrefix}${name}=${value}`]
for (const part of parts) {
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
continue
}
rewritten.push(part)
}
return rewritten.join("; ")
}
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
const next: string[] = []
for (const rawPart of cookieHeader.split(";")) {
const part = rawPart.trim()
if (!part) continue
const separator = part.indexOf("=")
if (separator <= 0) continue
const name = part.slice(0, separator).trim()
const value = part.slice(separator + 1)
if (!name.startsWith(cookiePrefix)) {
continue
}
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
}
return next.join("; ")
}
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
try {
const parsed = new URL(location, targetBaseUrl)
if (parsed.origin !== targetBaseUrl.origin) {
return location
}
const rewritten = new URL(localBaseUrl.toString())
rewritten.pathname = parsed.pathname
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return location
}
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}

View File

@@ -3,7 +3,6 @@ import fs from "fs"
import { z } from "zod"
import type { AuthManager } from "../../auth/manager"
import { isLoopbackAddress } from "../../auth/http-auth"
import { resolveAuthTemplatePath } from "../../runtime-paths"
interface RouteDeps {
authManager: AuthManager
@@ -22,21 +21,21 @@ const PasswordSchema = z.object({
password: z.string().min(8),
})
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
let cachedLoginTemplate: string | null = null
let cachedTokenTemplate: string | null = null
function readTemplate(filePath: string, cache: string | null): string {
function readTemplate(url: URL, cache: string | null): string {
if (cache) return cache
const content = fs.readFileSync(filePath, "utf-8")
const content = fs.readFileSync(url, "utf-8")
return content
}
function getLoginHtml(defaultUsername: string): string {
if (!cachedLoginTemplate) {
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
}
const escapedUsername = escapeHtml(defaultUsername)
@@ -45,7 +44,7 @@ function getLoginHtml(defaultUsername: string): string {
function getTokenHtml(): string {
if (!cachedTokenTemplate) {
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
}
return cachedTokenTemplate

View File

@@ -1,54 +0,0 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { RemoteProxySessionCreateResponse } from "../../api-types"
import { isLoopbackAddress } from "../../auth/http-auth"
import type { Logger } from "../../logger"
import type { RemoteProxySessionManager } from "../remote-proxy"
interface RouteDeps {
logger: Logger
sessionManager: RemoteProxySessionManager
}
const CreateSessionSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
const SessionParamsSchema = z.object({
id: z.string().uuid(),
})
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
try {
const body = CreateSessionSchema.parse(request.body ?? {})
return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
} catch (error) {
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
}
})
app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => {
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404)
return { error: "Not found" }
}
try {
const params = SessionParamsSchema.parse(request.params ?? {})
const deleted = await deps.sessionManager.deleteSession(params.id)
if (!deleted) {
reply.code(404)
return { error: "Remote proxy session not found" }
}
return { ok: true }
} catch (error) {
deps.logger.warn({ err: error }, "Failed to delete remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" }
}
})
}

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/spawn"
import { probeBinaryVersion } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"

View File

@@ -1,10 +1,6 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status"
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations"
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
interface RouteDeps {
workspaceManager: WorkspaceManager
@@ -27,20 +23,6 @@ const WorkspaceFileContentBodySchema = z.object({
contents: z.string(),
})
const WorktreeGitDiffQuerySchema = z.object({
path: z.string().trim().min(1, "Path is required"),
originalPath: z.string().trim().optional(),
scope: z.enum(["staged", "unstaged"]),
})
const WorktreeGitPathsBodySchema = z.object({
paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
})
const WorktreeGitCommitBodySchema = z.object({
message: z.string().trim().min(1, "Commit message is required"),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
@@ -136,138 +118,10 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string; slug: string }
}>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
try {
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log })
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string; slug: string }
Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" }
}>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
try {
const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
return await getWorktreeGitDiff({
workspaceFolder: directory,
path: query.path,
originalPath: query.originalPath,
scope: query.scope,
})
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { paths: string[] }
}>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
try {
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
return { ok: true as const }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { paths: string[] }
}>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
try {
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
return { ok: true as const }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { message: string }
}>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
try {
const body = WorktreeGitCommitBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message })
return { ok: true as const, ...result }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
}
async function resolveGitWorktreeDirectory(
workspaceManager: WorkspaceManager,
workspaceId: string,
worktreeSlug: string,
logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void },
reply: FastifyReply,
): Promise<string | null> {
const workspace = workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404)
reply.send({ error: "Workspace not found" })
return null
}
const gitAvailable = await isGitAvailable(workspace.path)
if (!gitAvailable) {
reply.code(503)
reply.send({ error: "Git is not installed or not available in PATH" })
return null
}
const { isGitRepo } = await resolveRepoRoot(workspace.path, logger)
if (!isGitRepo) {
reply.code(400)
reply.send({ error: "Workspace is not a Git repository" })
return null
}
const directory = await resolveWorktreeDirectory({
workspaceId: workspace.id,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404)
reply.send({ error: "Worktree not found" })
return null
}
return directory
}
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (isGitMutationError(error)) {
reply.code(error.statusCode)
return { error: error.message }
}
if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404)
return { error: "Workspace not found" }

View File

@@ -1,193 +0,0 @@
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"])
})
})

View File

@@ -1,121 +0,0 @@
import { spawn } from "child_process"
import path from "path"
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
class GitMutationError extends Error {
statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.name = "GitMutationError"
this.statusCode = statusCode
}
}
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (code === 0) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
export function normalizeGitWorktreeRelativePath(input: string): string {
const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "")
if (!normalized) {
throw new GitMutationError("Path is required", 400)
}
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400)
}
if (normalized === "." || normalized === "..") {
throw new GitMutationError(`Invalid path: ${input}`, 400)
}
if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400)
}
return normalized
}
function normalizeGitMutationPaths(paths: string[]): string[] {
const deduped = new Set<string>()
for (const rawPath of paths) {
deduped.add(normalizeGitWorktreeRelativePath(rawPath))
}
const normalized = Array.from(deduped)
if (normalized.length === 0) {
throw new GitMutationError("At least one path is required", 400)
}
return normalized
}
async function ensureGitCommandSucceeded(resultPromise: Promise<GitResult>, fallbackMessage: string): Promise<string> {
const result = await resultPromise
if (!result.ok) {
const message = result.stderr?.trim() || result.error.message || fallbackMessage
throw new GitMutationError(message, 409)
}
return result.stdout
}
export function isGitMutationError(error: unknown): error is GitMutationError {
return error instanceof GitMutationError
}
export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
const paths = normalizeGitMutationPaths(params.paths)
await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files")
}
export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
const paths = normalizeGitMutationPaths(params.paths)
const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder)
if (headResult.ok) {
await ensureGitCommandSucceeded(
runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder),
"Failed to unstage files",
)
return
}
await ensureGitCommandSucceeded(
runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder),
"Failed to unstage files",
)
}
export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> {
const message = params.message.trim()
if (!message) {
throw new GitMutationError("Commit message is required", 400)
}
await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit")
const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder)
if (!shaResult.ok) {
return {}
}
const commitSha = shaResult.stdout.trim()
return commitSha ? { commitSha } : {}
}

View File

@@ -1,385 +0,0 @@
import { spawn } from "child_process"
import { readFile } from "fs/promises"
import path from "path"
import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types"
import type { LogLike } from "./git-worktrees"
import { normalizeGitWorktreeRelativePath } from "./git-mutations"
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
type GitSuccessResult = Extract<GitResult, { ok: true }>
async function readFileAsDiffText(filePath: string): Promise<string> {
return readFile(filePath, "utf-8")
}
async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingOk = false): Promise<string> {
const result = await resultPromise
if (!result.ok) {
return decodeGitShowResult(result, missingOk)
}
return result.stdout
}
function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (acceptedExitCodes.includes(code ?? 0)) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
function ensureEntry(map: Map<string, WorktreeGitStatusEntry>, path: string): WorktreeGitStatusEntry {
const existing = map.get(path)
if (existing) return existing
const next: WorktreeGitStatusEntry = {
path,
originalPath: null,
stagedStatus: null,
stagedAdditions: 0,
stagedDeletions: 0,
unstagedStatus: null,
unstagedAdditions: 0,
unstagedDeletions: 0,
}
map.set(path, next)
return next
}
function normalizeGitStatusPath(value: string): string {
return value.trim().replace(/\\+/g, "/")
}
function parseGitChangeKind(code: string): GitChangeKind | null {
const normalized = code.trim().toUpperCase()
if (!normalized) return null
if (normalized === "A") return "added"
if (normalized === "M") return "modified"
if (normalized === "D") return "deleted"
if (normalized.startsWith("R")) return "renamed"
if (normalized.startsWith("C")) return "copied"
if (normalized === "U") return "unmerged"
return null
}
function applyNameStatusOutput(
map: Map<string, WorktreeGitStatusEntry>,
output: string,
target: "stagedStatus" | "unstagedStatus",
) {
const tokens = output.split("\0")
let index = 0
while (index < tokens.length) {
const record = tokens[index++] ?? ""
if (!record) continue
const parts = record.split("\t")
const statusCode = parseGitChangeKind(parts[0] ?? "")
if (!statusCode) continue
const inlinePath = parts.slice(1).join("\t")
const firstPath = inlinePath || tokens[index++] || ""
const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : ""
const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath
const normalizedPath = normalizeGitStatusPath(path)
if (!normalizedPath) continue
const entry = ensureEntry(map, normalizedPath)
entry[target] = statusCode
if (statusCode === "renamed" || statusCode === "copied") {
const originalPath = normalizeGitStatusPath(firstPath)
entry.originalPath = originalPath || entry.originalPath || null
}
}
}
function applyUntrackedOutput(map: Map<string, WorktreeGitStatusEntry>, output: string) {
for (const rawLine of output.split(/\r?\n/)) {
const path = normalizeGitStatusPath(rawLine)
if (!path) continue
ensureEntry(map, path).unstagedStatus = "untracked"
}
}
function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } {
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) continue
const parts = rawLine.split("\t")
const isBinary = parts[0] === "-" || parts[1] === "-"
return {
additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0,
deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0,
isBinary,
found: true,
}
}
return { additions: 0, deletions: 0, isBinary: false, found: false }
}
async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> {
const absolutePath = path.join(workspaceFolder, relativePath)
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1])
if (!result.ok) {
throw result.error
}
const parsed = parseSingleNumstat(result.stdout)
return { additions: parsed.additions, deletions: parsed.deletions }
}
async function applyUntrackedFileStats(map: Map<string, WorktreeGitStatusEntry>, workspaceFolder: string) {
const pending = Array.from(map.values())
.filter((entry) => entry.unstagedStatus === "untracked")
.map(async (entry) => {
try {
const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path)
entry.unstagedAdditions = stats.additions
entry.unstagedDeletions = stats.deletions
} catch {
entry.unstagedAdditions = 0
entry.unstagedDeletions = 0
}
})
await Promise.all(pending)
}
function applyNumstatOutput(
map: Map<string, WorktreeGitStatusEntry>,
output: string,
target: "staged" | "unstaged",
) {
const tokens = output.split("\0")
let index = 0
while (index < tokens.length) {
const record = tokens[index++] ?? ""
if (!record) continue
const parts = record.split("\t")
if (parts.length < 3) continue
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10)
const inlinePath = parts.slice(2).join("\t")
const isRenameLike = inlinePath === ""
const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null
const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath)
if (!normalizedPath) continue
const entry = ensureEntry(map, normalizedPath)
if (originalPath) {
entry.originalPath = originalPath
}
if (target === "staged") {
entry.stagedAdditions = Number.isFinite(additions) ? additions : 0
entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0
} else {
entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0
entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0
}
}
}
export async function getWorktreeGitStatus(params: {
workspaceFolder: string
logger?: LogLike
}): Promise<WorktreeGitStatusEntry[]> {
const { workspaceFolder, logger } = params
const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([
runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder),
runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder),
])
for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) {
if (!result.ok) {
logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree")
throw result.error
}
}
const stagedOutput = (stagedResult as GitSuccessResult).stdout
const unstagedOutput = (unstagedResult as GitSuccessResult).stdout
const untrackedOutput = (untrackedResult as GitSuccessResult).stdout
const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout
const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout
const entries = new Map<string, WorktreeGitStatusEntry>()
applyNameStatusOutput(entries, stagedOutput, "stagedStatus")
applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus")
applyUntrackedOutput(entries, untrackedOutput)
applyNumstatOutput(entries, stagedNumstatOutput, "staged")
applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged")
await applyUntrackedFileStats(entries, workspaceFolder)
return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path))
}
function decodeGitShowResult(result: GitResult, missingOk = false): string {
if (result.ok) return result.stdout
const message = result.stderr?.trim() || result.error.message || ""
if (
missingOk &&
(message.includes("exists on disk, but not in") ||
message.includes("Path '") ||
message.includes("does not exist") ||
message.includes("unknown revision or path not in the working tree"))
) {
return ""
}
throw result.error
}
async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise<GitResult> {
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder)
}
async function getTrackedDiffMetadata(params: {
workspaceFolder: string
scope: WorktreeGitDiffScope
normalizedPath: string
normalizedOriginalPath: string | null
}): Promise<{ isBinary: boolean; found: boolean }> {
const args = ["diff", "--numstat"]
if (params.scope === "staged") {
args.push("--cached")
}
args.push("--find-renames", "--find-copies", "--")
args.push(params.normalizedPath)
if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) {
args.push(params.normalizedOriginalPath)
}
const result = await runGit(args, params.workspaceFolder)
if (!result.ok) {
throw result.error
}
const parsed = parseSingleNumstat(result.stdout)
return { isBinary: parsed.isBinary, found: parsed.found }
}
async function getUntrackedDiffMetadata(params: {
workspaceFolder: string
normalizedPath: string
}): Promise<{ isBinary: boolean }> {
const absolutePath = path.join(params.workspaceFolder, params.normalizedPath)
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1])
if (!result.ok) {
throw result.error
}
return { isBinary: parseSingleNumstat(result.stdout).isBinary }
}
async function resolveUnstagedBeforePath(params: {
workspaceFolder: string
normalizedPath: string
normalizedOriginalPath: string | null
}): Promise<GitResult> {
const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath)
if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) {
return currentPathResult
}
return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath)
}
export async function getWorktreeGitDiff(params: {
workspaceFolder: string
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
}): Promise<WorktreeGitDiffResponse> {
const normalizedPath = normalizeGitWorktreeRelativePath(params.path)
const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null
const trackedMetadata = await getTrackedDiffMetadata({
workspaceFolder: params.workspaceFolder,
scope: params.scope,
normalizedPath,
normalizedOriginalPath,
})
const diffMetadata =
params.scope === "unstaged" && !trackedMetadata.found
? await getUntrackedDiffMetadata({
workspaceFolder: params.workspaceFolder,
normalizedPath,
})
: trackedMetadata
if (diffMetadata.isBinary) {
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: "",
after: "",
isBinary: true,
}
}
if (params.scope === "staged") {
const [beforeResult, afterResult] = await Promise.all([
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
])
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: beforeResult,
after: afterResult,
isBinary: false,
}
}
const indexResult = await resolveUnstagedBeforePath({
workspaceFolder: params.workspaceFolder,
normalizedPath,
normalizedOriginalPath,
})
const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true)
let after = beforeResult
const fsPath = path.join(params.workspaceFolder, normalizedPath)
try {
after = await readFileAsDiffText(fsPath)
} catch {
after = ""
}
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: beforeResult,
after,
isBinary: false,
}
}

View File

@@ -10,10 +10,6 @@ export interface LogLike {
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
function isGitUnavailableResult(result: GitResult): boolean {
return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"
}
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
@@ -42,9 +38,6 @@ function runGit(args: string[], cwd: string): Promise<GitResult> {
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
if (isGitUnavailableResult(result)) {
throw new Error("Git is not installed or not available in PATH")
}
if (!result.ok) {
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
return { repoRoot: folder, isGitRepo: false }
@@ -56,11 +49,6 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
return { repoRoot, isGitRepo: true }
}
export async function isGitAvailable(folder: string): Promise<boolean> {
const result = await runGit(["--version"], folder)
return result.ok || !isGitUnavailableResult(result)
}
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
const lines = output.split(/\r?\n/)
@@ -102,22 +90,15 @@ export async function listWorktrees(params: {
logger?: LogLike
}): Promise<WorktreeDescriptor[]> {
const { repoRoot, workspaceFolder, logger } = params
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
if (!result.ok) {
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
return [rootDescriptor]
}
const records = parseWorktreePorcelain(result.stdout)
const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot))
const rootDescriptor: WorktreeDescriptor = {
slug: "root",
directory: repoRoot,
kind: "root",
branch: rootRecord?.branch,
}
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
const seen = new Set<string>(["root"])

View File

@@ -21,70 +21,6 @@ import {
const STARTUP_STABILITY_DELAY_MS = 1500
function defaultShellPath(): string {
const configured = process.env.SHELL?.trim()
if (configured) {
return configured
}
return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"
}
function shellEscape(input: string): string {
if (!input) return "''"
return `'${input.replace(/'/g, `'\\''`)}'`
}
function wrapCommandForShell(command: string, shellPath: string): string {
const shellName = path.basename(shellPath).toLowerCase()
if (shellName.includes("bash")) {
return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`
}
if (shellName.includes("zsh")) {
return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`
}
return command
}
function buildShellArgs(shellPath: string, command: string): string[] {
const shellName = path.basename(shellPath).toLowerCase()
if (shellName.includes("zsh")) {
return ["-l", "-i", "-c", command]
}
return ["-l", "-c", command]
}
function resolveBinaryPathFromUserShell(identifier: string): string | null {
if (process.platform === "win32") {
return null
}
const shellPath = defaultShellPath()
const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath)
const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
encoding: "utf8",
env: {
...process.env,
npm_config_prefix: undefined,
NPM_CONFIG_PREFIX: undefined,
},
})
if (result.status !== 0) {
return null
}
const resolved = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
return resolved ?? null
}
interface WorkspaceManagerOptions {
rootDir: string
settings: SettingsService
@@ -330,12 +266,6 @@ export class WorkspaceManager {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
const shellResolved = resolveBinaryPathFromUserShell(identifier)
if (shellResolved) {
this.options.logger.debug({ identifier, resolved: shellResolved }, "Resolved binary path from user shell")
return shellResolved
}
return identifier
}

View File

@@ -4,10 +4,100 @@ import path from "path"
import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
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 WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {}
@@ -40,10 +130,6 @@ export interface ProcessExitInfo {
interface ManagedProcess {
child: ChildProcess
requestedStop: boolean
wsl?: {
distro: string
linuxPid: number | null
}
}
export class WorkspaceRuntime {
@@ -81,13 +167,7 @@ export class WorkspaceRuntime {
}
return new Promise((resolve, reject) => {
const propagatedEnvKeys = Object.keys(options.environment ?? {})
const spec = buildSpawnSpec(options.binaryPath, args, {
cwd: options.folder,
env,
propagateEnvKeys: propagatedEnvKeys,
wslPidMarker: WSL_PID_MARKER,
})
const spec = buildSpawnSpec(options.binaryPath, args)
const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info(
{
@@ -117,18 +197,14 @@ export class WorkspaceRuntime {
)
const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: spec.cwd,
env: spec.env,
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
detached,
...spec.options,
})
const managed: ManagedProcess = {
child,
requestedStop: false,
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
}
const managed: ManagedProcess = { child, requestedStop: false }
this.processes.set(options.workspaceId, managed)
let stdoutBuffer = ""
@@ -208,15 +284,6 @@ export class WorkspaceRuntime {
const trimmed = line.trim()
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)
if (recentStdout.length > MAX_OUTPUT_LINES) {
recentStdout.shift()
@@ -331,44 +398,11 @@ 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) => {
if (process.platform === "win32") {
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
if (!trySignalWslProcess(signal)) {
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
}
// Best-effort: terminate the whole process tree rooted at pid.
// Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
return
}

View File

@@ -1,307 +0,0 @@
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`
}

View File

@@ -1,99 +0,0 @@
import { realpath } from "fs/promises"
import type { LogLike } from "./git-worktrees"
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function normalizeDirectoryPath(directory: string): Promise<string> {
const trimmed = (directory ?? "").trim()
if (!trimmed) return ""
try {
return await realpath(trimmed)
} catch {
return trimmed
}
}
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: await Promise.all(
worktrees.map(async (wt) => ({
slug: wt.slug,
directory: wt.directory,
normalizedDirectory: await normalizeDirectoryPath(wt.directory),
})),
),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
export async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger?: LogLike
}): Promise<string | null> {
const cached = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug)
if (match) {
return match.directory
}
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null
}
export async function resolveWorktreeSlugForDirectory(params: {
workspaceId: string
workspacePath: string
directory: string
logger?: LogLike
}): Promise<string | null> {
const target = await normalizeDirectoryPath(params.directory ?? "")
if (!target) return null
const cached = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target)
if (match) {
return match.slug
}
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
}

View File

@@ -213,28 +213,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -430,8 +408,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -468,12 +444,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -486,28 +456,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codenomad-tauri"
version = "0.14.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"dirs 5.0.1",
"keepawake",
"libc",
"once_cell",
"parking_lot",
"regex",
"reqwest 0.12.28",
"rustls",
"serde",
"serde_json",
"serde_yaml",
@@ -517,8 +476,8 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-opener",
"thiserror 1.0.69",
"url",
"webkit2gtk",
"which",
"windows-sys 0.59.0",
]
@@ -1010,15 +969,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1189,12 +1139,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1435,10 +1379,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1448,11 +1390,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1634,25 +1574,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1778,7 +1699,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1790,23 +1710,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2096,16 +1999,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -2264,12 +2157,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -3108,61 +2995,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3380,50 +3212,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3454,7 +3242,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"wasm-streams",
"web-sys",
]
@@ -3482,20 +3270,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3537,44 +3311,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3795,18 +3531,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
@@ -4068,12 +3792,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4225,7 +3943,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.2",
"reqwest",
"serde",
"serde_json",
"serde_repr",
@@ -4649,21 +4367,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
@@ -4678,16 +4381,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4998,12 +4691,6 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -5215,19 +4902,6 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -5263,16 +4937,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@@ -5329,15 +4993,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5631,15 +5286,6 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -6281,12 +5927,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -14,6 +14,6 @@
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.1"
"@tauri-apps/cli": "^2.9.4"
}
}

View File

@@ -21,7 +21,6 @@ const serverDevInstallCommand =
const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
const serverStandaloneBuildCommand = "npm run build:standalone --workspace @neuralnomads/codenomad"
const envWithRootBin = {
...process.env,
@@ -38,12 +37,6 @@ const braceExpansionPath = path.join(
"package.json",
)
const serverBuildDependencyPaths = [
path.join(serverRoot, "node_modules", "typescript", "package.json"),
path.join(serverRoot, "node_modules", "@types", "node-forge", "package.json"),
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
]
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
async function ensureMonacoAssets() {
@@ -78,15 +71,6 @@ function ensureServerBuild() {
}
}
function ensureStandaloneServerBuild() {
console.log("[prebuild] building standalone server executable...")
execSync(serverStandaloneBuildCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
@@ -114,7 +98,7 @@ function syncServerUiBundle() {
}
function ensureServerDevDependencies() {
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
if (fs.existsSync(braceExpansionPath)) {
return
}
@@ -127,19 +111,15 @@ function ensureServerDevDependencies() {
}
function ensureServerDependencies() {
console.log("[prebuild] pruning server to production dependencies...")
execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
if (!fs.existsSync(braceExpansionPath)) {
console.log("[prebuild] restoring missing server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
}
function ensureUiDevDependencies() {
@@ -162,7 +142,6 @@ function ensureRollupPlatformBinary() {
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64",
"win32-arm64": "@rollup/rollup-win32-arm64-msvc",
"win32-x64": "@rollup/rollup-win32-x64-msvc",
}
@@ -192,47 +171,6 @@ function ensureRollupPlatformBinary() {
})
}
function ensureEsbuildPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@esbuild/linux-x64",
"linux-arm64": "@esbuild/linux-arm64",
"darwin-arm64": "@esbuild/darwin-arm64",
"darwin-x64": "@esbuild/darwin-x64",
"win32-arm64": "@esbuild/win32-arm64",
"win32-x64": "@esbuild/win32-x64",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = path.join(workspaceRoot, "node_modules", ...pkgName.split("/"))
if (fs.existsSync(platformPackagePath)) {
return
}
let esbuildVersion = ""
try {
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "esbuild", "package.json")).version
} catch {
try {
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "vite", "node_modules", "esbuild", "package.json")).version
} catch {
// leave version empty; fallback install will use latest compatible
}
}
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
console.log("[prebuild] installing esbuild platform binary (optional dep workaround)...")
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
cwd: workspaceRoot,
stdio: "inherit",
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
@@ -311,10 +249,8 @@ function copyUiLoadingAssets() {
ensureUiDevDependencies()
await ensureMonacoAssets()
ensureRollupPlatformBinary()
ensureEsbuildPlatformBinary()
ensureServerBuild()
ensureStandaloneServerBuild()
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
syncServerUiBundle()
copyServerArtifacts()

View File

@@ -5,18 +5,17 @@ edition = "2021"
license = "MIT"
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }
tauri-build = { version = "2.5.2", features = [] }
[dependencies]
tauri = { version = "2.10.1", features = [ "devtools"] }
tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
base64 = "0.22"
rustls = { version = "0.23", features = ["ring"] }
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
@@ -29,7 +28,4 @@ url = "2"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0.2"
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -1,9 +0,0 @@
[Desktop Entry]
Categories=
Exec=codenomad-tauri
StartupWMClass=codenomad-tauri
Icon=codenomad-tauri
Name=CodeNomad
NoDisplay=true
Terminal=false
Type=Application

View File

@@ -1,449 +0,0 @@
use base64::Engine;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
const TLS_DIR_NAME: &str = "tls";
const CA_CERT_FILE: &str = "ca-cert.pem";
const SERVER_CERT_FILE: &str = "server-cert.pem";
const SERVER_KEY_FILE: &str = "server-key.pem";
const TRUSTED_MARKER: &str = "server-ca.trusted";
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy,
/// plus the CA certificate DER used for trust-store installation.
pub struct LocalCert {
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_der: Vec<u8>,
}
struct TlsAssetPaths {
cert_path: PathBuf,
key_path: PathBuf,
trust_path: PathBuf,
append_ca_to_cert: bool,
}
/// Loads the TLS assets already managed by `packages/server`.
pub fn ensure_local_cert() -> Result<LocalCert, String> {
let assets = resolve_tls_asset_paths()?;
let mut cert_pem = read_pem_file(&assets.cert_path)?;
let key_pem = read_pem_file(&assets.key_path)?;
let trust_pem = read_pem_file(&assets.trust_path)?;
if assets.append_ca_to_cert {
cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim());
}
let ca_cert_der = pem_to_der(&trust_pem)?;
Ok(LocalCert {
cert_pem,
key_pem,
ca_cert_der,
})
}
fn read_pem_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
}
fn server_tls_dir() -> Result<PathBuf, String> {
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
}
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
let tls_key_path = env::var("CLI_TLS_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_cert_path = env::var("CLI_TLS_CERT")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_ca_path = env::var("CLI_TLS_CA")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
match (tls_key_path, tls_cert_path) {
(Some(key_path), Some(cert_path)) => {
let append_ca_to_cert = tls_ca_path.is_some();
let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone());
Ok(TlsAssetPaths {
cert_path,
key_path,
trust_path,
append_ca_to_cert,
})
}
(Some(_), None) | (None, Some(_)) => Err(
"CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files"
.to_string(),
),
(None, None) => {
let tls_dir = server_tls_dir()?;
Ok(TlsAssetPaths {
cert_path: tls_dir.join(SERVER_CERT_FILE),
key_path: tls_dir.join(SERVER_KEY_FILE),
trust_path: tls_dir.join(CA_CERT_FILE),
append_ca_to_cert: true,
})
}
}
}
fn resolve_server_config_base_dir() -> Result<PathBuf, String> {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
let expanded = resolve_path_like_server(&raw)?;
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") {
return expanded
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display()));
}
Ok(expanded)
}
fn resolve_path_like_server(path: &str) -> Result<PathBuf, String> {
if path.starts_with("~/") {
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?;
return Ok(home.join(path.trim_start_matches("~/")));
}
let path = PathBuf::from(path);
if path.is_absolute() {
return Ok(path);
}
let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?;
Ok(cwd.join(path))
}
fn trusted_marker_path() -> Result<PathBuf, String> {
let base = dirs::data_local_dir()
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
#[cfg(windows)]
{
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER));
}
#[cfg(not(windows))]
{
Ok(base.join("codenomad").join(TRUSTED_MARKER))
}
}
fn trusted_marker_value(cert_der: &[u8]) -> String {
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn trusted_marker_file_suffix(cert_der: &[u8]) -> String {
trusted_marker_value(cert_der).chars().take(16).collect()
}
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
trusted_marker_path()
.ok()
.and_then(|path| fs::read_to_string(path).ok())
.map(|value| value.trim() == trusted_marker_value(cert_der))
.unwrap_or(false)
}
fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
let path = trusted_marker_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?;
}
fs::write(path, trusted_marker_value(cert_der))
.map_err(|e| format!("Failed to write trust marker: {e}"))
}
#[cfg(windows)]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!windows_cert_is_trusted(cert_der)?)
}
#[cfg(windows)]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use windows_sys::Win32::Security::Cryptography::{
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
};
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
let result = CertAddEncodedCertificateToStore(
store,
encoding,
cert_der.as_ptr(),
cert_der.len() as u32,
CERT_STORE_ADD_REPLACE_EXISTING,
std::ptr::null_mut(),
);
CertCloseStore(store, 0);
if result == 0 {
return Err(
"Failed to add certificate to trust store. The user may have declined the security dialog."
.into(),
);
}
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!(has_matching_trusted_marker(cert_der) && macos_cert_is_trusted(cert_der)?))
}
#[cfg(target_os = "macos")]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use std::process::Command;
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let mut command = Command::new("/usr/bin/security");
command.args(["add-trusted-cert", "-r", "trustRoot", "-k"]);
command.arg(&keychain_path);
let output = command.arg(&temp_path).output().map_err(|e| {
format!(
"Failed to launch macOS security tool to trust the local CA certificate: {e}"
)
})?;
let _ = fs::remove_file(&temp_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to add the local CodeNomad CA certificate to the macOS trust settings: {detail}"
));
}
if !macos_cert_is_trusted(cert_der)? {
return Err(format!(
"Added the local CodeNomad CA certificate to {} but could not verify that macOS trusts it",
keychain_path.display()
));
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(windows)]
fn windows_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use windows_sys::Win32::Security::Cryptography::{
CertCloseStore, CertEnumCertificatesInStore, CertOpenSystemStoreW,
};
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let mut context = CertEnumCertificatesInStore(store, std::ptr::null());
while !context.is_null() {
let encoded = std::slice::from_raw_parts(
(*context).pbCertEncoded,
(*context).cbCertEncoded as usize,
);
if encoded == cert_der {
CertCloseStore(store, 0);
return Ok(true);
}
context = CertEnumCertificatesInStore(store, context);
}
CertCloseStore(store, 0);
Ok(false)
}
}
#[cfg(target_os = "macos")]
fn resolve_macos_user_keychain() -> Result<PathBuf, String> {
let output = std::process::Command::new("/usr/bin/security")
.args(["default-keychain", "-d", "user"])
.output()
.map_err(|e| format!("Failed to resolve macOS default user keychain: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim().trim_matches('"');
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory for macOS keychain lookup".to_string())?;
Ok(home.join("Library/Keychains/login.keychain-db"))
}
#[cfg(target_os = "macos")]
fn macos_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use std::process::Command;
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-verify-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let fingerprint = macos_cert_sha256(&temp_path)?;
let find_output = Command::new("/usr/bin/security")
.args(["find-certificate", "-a", "-Z", "-c", "CodeNomad Local CA"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to query macOS keychain certificates: {e}"))?;
if !find_output.status.success() {
let _ = fs::remove_file(&temp_path);
let stderr = String::from_utf8_lossy(&find_output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", find_output.status)
} else {
stderr
};
return Err(format!(
"Failed to inspect the macOS keychain for the local CodeNomad CA certificate: {detail}"
));
}
let stdout = String::from_utf8_lossy(&find_output.stdout);
if !stdout.to_ascii_uppercase().contains(&fingerprint) {
let _ = fs::remove_file(&temp_path);
return Ok(false);
}
let verify_output = Command::new("/usr/bin/security")
.args(["verify-cert", "-q", "-L", "-l", "-p", "basic", "-c"])
.arg(&temp_path)
.args(["-k"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to verify macOS trust for the local CodeNomad CA certificate: {e}"))?;
let _ = fs::remove_file(&temp_path);
Ok(verify_output.status.success())
}
#[cfg(target_os = "macos")]
fn macos_cert_sha256(cert_path: &Path) -> Result<String, String> {
let output = std::process::Command::new("/usr/bin/shasum")
.args(["-a", "256"])
.arg(cert_path)
.output()
.map_err(|e| format!("Failed to compute SHA-256 for {}: {e}", cert_path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("shasum exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to compute SHA-256 for {}: {detail}",
cert_path.display()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let hash = stdout
.split_whitespace()
.next()
.ok_or_else(|| format!("Failed to parse SHA-256 output for {}", cert_path.display()))?;
Ok(hash.to_ascii_uppercase())
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn needs_trust_in_store(_cert_der: &[u8]) -> Result<bool, String> {
Ok(false)
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
Ok(())
}
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
let mut body = String::new();
let mut in_block = false;
for line in pem.lines() {
if line.starts_with("-----BEGIN CERTIFICATE-----") {
in_block = true;
continue;
}
if line.starts_with("-----END CERTIFICATE-----") {
break;
}
if in_block {
body.push_str(line.trim());
}
}
if body.is_empty() {
return Err("No certificate found in PEM file".to_string());
}
base64::engine::general_purpose::STANDARD
.decode(body)
.map_err(|e| format!("Failed to decode certificate PEM: {e}"))
}

View File

@@ -38,7 +38,6 @@ use windows_sys::Win32::System::JobObjects::{
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:";
#[cfg(windows)]
#[derive(Debug)]
@@ -136,10 +135,6 @@ fn workspace_root() -> Option<PathBuf> {
})
}
fn launch_cwd() -> Option<PathBuf> {
std::env::current_dir().ok()
}
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
@@ -628,54 +623,42 @@ impl CliProcessManager {
log_line("development mode: will prefer tsx + source if present");
}
let cwd = launch_cwd();
let cwd = workspace_root();
if let Some(ref c) = cwd {
log_line(&format!("using cwd={}", c.display()));
}
let use_user_shell = supports_user_shell();
if resolution.runner == Runner::Tsx
&& !use_user_shell
&& which::which(&resolution.node_binary).is_err()
{
return Err(anyhow::anyhow!(
"Node binary '{}' not found. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
resolution.node_binary
));
}
let command_info = if use_user_shell {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else {
log_line(if resolution.runner == Runner::Standalone {
"spawning directly with standalone executable"
} else {
"spawning directly with node"
});
log_line("spawning directly with node");
ShellCommandType::Direct(DirectCommand {
program: if resolution.runner == Runner::Standalone {
resolution.entry.clone()
} else {
resolution.node_binary.clone()
},
program: resolution.node_binary.clone(),
args: resolution.runner_args(&args),
})
};
if !use_user_shell {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed."
));
}
}
let child = match &command_info {
ShellCommandType::UserShell(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
let mut c = Command::new(&cmd.shell);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.env_remove("npm_config_prefix")
.env_remove("NPM_CONFIG_PREFIX")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if resolution.runner != Runner::Standalone {
c.env("ELECTRON_RUN_AS_NODE", "1");
}
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
@@ -688,11 +671,9 @@ impl CliProcessManager {
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
let mut c = Command::new(&cmd.program);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if resolution.runner != Runner::Standalone {
c.env("ELECTRON_RUN_AS_NODE", "1");
}
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
@@ -939,17 +920,6 @@ impl CliProcessManager {
continue;
}
if let Some(node_binary) = line.strip_prefix(MISSING_NODE_PREFIX) {
let mut locked = status.lock();
if locked.error.is_none() {
locked.error = Some(format!(
"Node binary '{}' not found in the desktop shell environment. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
node_binary.trim()
));
}
continue;
}
if let Some(url) = local_url_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
@@ -1066,7 +1036,7 @@ struct CliEntry {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runner {
Standalone,
Node,
Tsx,
}
@@ -1087,17 +1057,17 @@ impl CliEntry {
}
}
if let Some(entry) = resolve_standalone_entry(app) {
if let Some(entry) = resolve_dist_entry(app) {
return Ok(Self {
entry,
runner: Runner::Standalone,
runner: Runner::Node,
runner_path: None,
node_binary: String::new(),
node_binary,
});
}
Err(anyhow::anyhow!(
"Unable to locate the packaged CodeNomad standalone server. Please rebuild the desktop bundle."
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
))
}
@@ -1113,8 +1083,7 @@ impl CliEntry {
];
if dev {
// Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so
// remote proxy sessions can still spin up secure local windows.
// Dev: plain HTTP + Vite dev server proxy.
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
@@ -1131,7 +1100,7 @@ impl CliEntry {
.unwrap_or_else(|| "info".to_string());
args.push("--https".to_string());
args.push("true".to_string());
args.push("false".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--http-port".to_string());
@@ -1151,10 +1120,6 @@ impl CliEntry {
}
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
if self.runner == Runner::Standalone {
return cli_args.to_vec();
}
let mut args = VecDeque::new();
if self.runner == Runner::Tsx {
if let Some(path) = &self.runner_path {
@@ -1227,37 +1192,45 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
first_existing(candidates)
}
fn resolve_standalone_entry(_app: &AppHandle) -> Option<String> {
let executable_name = if cfg!(windows) {
"codenomad-server.exe"
} else {
"codenomad-server"
};
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
let base = workspace_root();
let mut candidates = vec![base
.as_ref()
.map(|p| p.join("packages/server/dist").join(executable_name))];
let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref()
.map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")),
];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(Some(
dir.join("resources/server/dist").join(executable_name),
));
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/index.js")));
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist").join(executable_name)));
candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js")));
candidates.push(Some(resources.join("server/dist/server/bin.js")));
candidates.push(Some(resources.join("server/dist/server/index.js")));
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(
resources
.join("resources/server/dist")
.join(executable_name),
resources.join("resources/server/dist/server/index.js"),
));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots {
candidates.push(Some(root.join("server/dist").join(executable_name)));
candidates.push(Some(
root.join("resources/server/dist").join(executable_name),
));
candidates.push(Some(root.join("server/dist/bin.js")));
candidates.push(Some(root.join("server/dist/index.js")));
candidates.push(Some(root.join("server/dist/server/bin.js")));
candidates.push(Some(root.join("server/dist/server/index.js")));
candidates.push(Some(root.join("resources/server/dist/bin.js")));
candidates.push(Some(root.join("resources/server/dist/index.js")));
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
}
}
}
@@ -1271,55 +1244,16 @@ fn build_shell_command_string(
) -> anyhow::Result<ShellCommand> {
let shell = default_shell();
let mut quoted: Vec<String> = Vec::new();
let command = if entry.runner == Runner::Standalone {
quoted.push(shell_escape(&entry.entry));
for arg in cli_args {
quoted.push(shell_escape(arg));
}
format!("exec {}", quoted.join(" "))
} else {
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
format!(
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
shell_escape(&entry.node_binary),
quoted.join(" "),
MISSING_NODE_PREFIX,
shell_escape(&entry.node_binary),
)
};
let wrapped_command = wrap_command_for_shell(&command, &shell);
let args = build_shell_args(&shell, &wrapped_command);
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
let args = build_shell_args(&shell, &command);
log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args })
}
fn wrap_command_for_shell(command: &str, shell: &str) -> String {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
if shell_name.contains("bash") {
return format!(
"if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; {}",
command
);
}
if shell_name.contains("zsh") {
return format!(
"if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; {}",
command
);
}
command.to_string()
}
fn default_shell() -> String {
if let Ok(shell) = std::env::var("SHELL") {
if !shell.trim().is_empty() {
@@ -1354,11 +1288,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.unwrap_or("")
.to_lowercase();
if shell_name.contains("zsh") {
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
let _ = shell_name;
vec!["-l".into(), "-c".into(), command.into()]
}
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {

View File

@@ -1,88 +0,0 @@
use crate::AppState;
use tauri::{AppHandle, Manager, WebviewWindow};
use url::Url;
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
pub fn should_bootstrap_tls_navigation(target_url: &Url, allow_tls_certificate: bool) -> bool {
allow_tls_certificate && target_url.scheme() == "https"
}
pub fn ensure_remote_window_tls_handler(
window: &WebviewWindow,
app_handle: &AppHandle,
window_label: &str,
) -> Result<(), String> {
{
let state = app_handle.state::<AppState>();
let mut handlers = state
.remote_tls_handlers
.lock()
.map_err(|err| err.to_string())?;
if !handlers.insert(window_label.to_string()) {
return Ok(());
}
}
let app_handle = app_handle.clone();
let window_label = window_label.to_string();
window
.with_webview(move |platform_webview| {
let webview = platform_webview.inner();
let app_handle = app_handle.clone();
let window_label = window_label.clone();
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
allow_remote_tls_certificate(
&app_handle,
&window_label,
view,
failing_uri,
certificate,
)
});
})
.map_err(|err| err.to_string())
}
fn allow_remote_tls_certificate(
app_handle: &AppHandle,
window_label: &str,
view: &WebView,
failing_uri: &str,
certificate: &webkit2gtk::gio::TlsCertificate,
) -> bool {
let Ok(parsed_uri) = Url::parse(failing_uri) else {
return false;
};
let Some(host) = parsed_uri.host_str() else {
return false;
};
let state = app_handle.state::<AppState>();
let skip_tls_verify = state
.remote_skip_tls_verify
.lock()
.ok()
.and_then(|values| values.get(window_label).copied())
.unwrap_or(false);
if !skip_tls_verify {
return false;
}
let expected_origin = state
.remote_origins
.lock()
.ok()
.and_then(|origins| origins.get(window_label).cloned());
let parsed_origin = parsed_uri.origin().ascii_serialization();
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
return false;
}
let Some(context) = view.context() else {
return false;
};
context.allow_tls_certificate_for_host(certificate, host);
view.load_uri(failing_uri);
true
}

View File

@@ -1,16 +1,12 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[allow(dead_code)]
mod cert_manager;
mod cli_manager;
#[cfg(target_os = "linux")]
mod linux_tls;
use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -40,8 +36,6 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2;
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)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -51,9 +45,6 @@ pub struct AppState {
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
}
#[derive(Debug, Deserialize)]
@@ -62,59 +53,9 @@ struct RemoteWindowPayload {
id: String,
name: String,
base_url: String,
entry_url: Option<String>,
proxy_session_id: Option<String>,
#[allow(dead_code)]
skip_tls_verify: bool,
}
fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
tauri::async_runtime::spawn(async move {
if let Err(err) = cleanup_remote_proxy_session(&app, &session_id).await {
eprintln!(
"[tauri] failed to clean up remote proxy session {}: {}",
session_id, err
);
}
});
}
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
let status = app.state::<AppState>().manager.status();
let Some(base_url) = status.url else {
return Ok(());
};
let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?;
cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}"));
cleanup_url.set_query(None);
cleanup_url.set_fragment(None);
let client = if cleanup_url.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert()?;
let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der)
.map_err(|err| err.to_string())?;
reqwest::Client::builder()
.add_root_certificate(ca_cert)
.build()
.map_err(|err| err.to_string())?
} else {
reqwest::Client::new()
};
let response = client
.delete(cleanup_url.as_str())
.send()
.await
.map_err(|err| err.to_string())?;
if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(());
}
Err(format!("unexpected status {}", response.status()))
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
@@ -145,8 +86,8 @@ fn wake_lock_start(
config: Option<WakeLockConfig>,
) -> Result<(), String> {
let config = config.unwrap_or(WakeLockConfig {
display: false,
idle: true,
display: true,
idle: false,
sleep: false,
});
@@ -178,7 +119,7 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" | "about" => true,
"tauri" | "asset" | "file" => true,
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
@@ -226,61 +167,25 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false
}
async fn open_remote_window_impl(
app: AppHandle,
payload: RemoteWindowPayload,
) -> Result<(), String> {
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
#[tauri::command]
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
return Err(
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
.to_string(),
);
}
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!(
"{} - {}",
payload.name,
Url::parse(&payload.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| payload.base_url.clone())
parsed.host_str().unwrap_or(payload.base_url.as_str())
);
let window_url = parsed.clone();
let allow_linux_tls_certificate =
parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify);
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), window_url.origin().ascii_serialization());
app.state::<AppState>()
.remote_skip_tls_verify
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), allow_linux_tls_certificate);
let replaced_session = {
let state = app.state::<AppState>();
let mut sessions = state
.remote_proxy_sessions
.lock()
.map_err(|err| err.to_string())?;
match payload.proxy_session_id.clone() {
Some(session_id) => sessions.insert(label.clone(), session_id),
None => sessions.remove(&label),
}
};
if let Some(previous) = replaced_session {
if payload.proxy_session_id.as_deref() != Some(previous.as_str()) {
schedule_remote_proxy_session_cleanup(app.clone(), previous);
}
}
if let Some(existing) = app.get_webview_window(&label) {
#[cfg(target_os = "linux")]
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
let _ = existing.navigate(window_url.clone());
let _ = existing.navigate(parsed.clone());
let _ = existing.set_title(&title);
let _ = existing.show();
let _ = existing.unminimize();
@@ -288,52 +193,25 @@ async fn open_remote_window_impl(
return Ok(());
}
#[cfg(target_os = "linux")]
let initial_url = if linux_tls::should_bootstrap_tls_navigation(
&window_url,
allow_linux_tls_certificate,
) {
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
};
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization());
#[cfg(not(target_os = "linux"))]
let initial_url = window_url.clone();
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[cfg(target_os = "linux")]
{
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
if initial_url != window_url {
let _ = window.navigate(window_url.clone());
}
}
let window =
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
let app_handle = app.clone();
let label_for_cleanup = label.clone();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label_for_cleanup);
}
if let Ok(mut sessions) = app_handle.state::<AppState>().remote_proxy_sessions.lock() {
if let Some(session_id) = sessions.remove(&label_for_cleanup) {
schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id);
}
}
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
values.remove(&label_for_cleanup);
}
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
handlers.remove(&label_for_cleanup);
origins.remove(&label);
}
}
});
@@ -341,47 +219,6 @@ async fn open_remote_window_impl(
Ok(())
}
#[tauri::command]
fn needs_local_certificate_install() -> Result<bool, String> {
#[cfg(not(target_os = "linux"))]
{
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}")
})?;
return cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
});
}
#[cfg(target_os = "linux")]
{
Ok(false)
}
}
#[tauri::command]
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
if payload.proxy_session_id.is_some() && parsed.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!(
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
)
})?;
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
return Err(format!(
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
));
}
}
}
open_remote_window_impl(app, payload).await
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
@@ -509,8 +346,6 @@ fn set_windows_app_user_model_id() {
fn set_windows_app_user_model_id() {}
fn main() {
let _ = rustls::crypto::ring::default_provider().install_default();
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
@@ -538,16 +373,10 @@ fn main() {
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
remote_proxy_sessions: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
})
.setup(|app| {
set_windows_app_user_model_id();
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() {
let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone());
@@ -582,7 +411,6 @@ fn main() {
cli_restart,
wake_lock_start,
wake_lock_stop,
needs_local_certificate_install,
open_remote_window
])
.on_menu_event(|app_handle, event| {

View File

@@ -9,7 +9,6 @@
"frontendDist": "resources/ui-loading"
},
"app": {
"enableGTKAppId": true,
"withGlobalTauri": true,
"windows": [
{
@@ -42,30 +41,6 @@
},
"bundle": {
"active": true,
"linux": {
"deb": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
}
},
"rpm": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
}
}
},
"resources": [
"resources/server",
"resources/ui-loading"

View File

@@ -22,7 +22,7 @@ import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases"
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
@@ -50,7 +50,7 @@ import {
updateSessionModel,
} from "./stores/sessions"
import { hasWakeLockEligibleWork } from "./stores/session-status"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen"
import {
closeSidecarTab,
@@ -137,7 +137,7 @@ const App: Component = () => {
createEffect(() => {
if (typeof document === "undefined") return
const shouldShow =
!isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
})
@@ -204,7 +204,8 @@ const App: Component = () => {
const shouldHoldWakeLock = createMemo(() => {
const map = instances()
for (const id of map.keys()) {
if (hasWakeLockEligibleWork(id)) {
const status = getInstanceSessionIndicatorStatus(id)
if (status !== "idle") {
return true
}
}
@@ -443,7 +444,7 @@ const App: Component = () => {
// Listen for Tauri menu events
onMount(() => {
if (isTauriHost()) {
if (runtimeEnv.host === "tauri") {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null

View File

@@ -58,16 +58,6 @@ function resolveAbsolutePath(root: string, relativePath: string) {
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: "up"; path: string }
| { type: "folder"; entry: FileSystemEntry }
@@ -77,8 +67,6 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [pathInput, setPathInput] = createSignal("")
const [pathInputDirty, setPathInputDirty] = createSignal(false)
const [creatingFolder, setCreatingFolder] = createSignal(false)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
@@ -87,16 +75,12 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
let latestNavigationId = 0
function resetState() {
setRootPath("")
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
setLoadingPaths(new Set<string>())
setCurrentPathKey(null)
setCurrentMetadata(null)
setPathInput("")
setPathInputDirty(false)
metadataCache.clear()
inFlightRequests.clear()
setError(null)
@@ -125,7 +109,11 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function initialize() {
setLoading(true)
try {
await navigateTo()
const metadata = await loadDirectory()
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
} finally {
setLoading(false)
}
@@ -209,22 +197,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
}
async function navigateTo(path?: string) {
const navigationId = ++latestNavigationId
setError(null)
try {
const metadata = await loadDirectory(path)
if (navigationId !== latestNavigationId) {
return null
}
applyMetadata(metadata)
return metadata
} catch (err) {
if (navigationId !== latestNavigationId) {
return null
}
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
return null
}
}
@@ -246,58 +225,31 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
})
function handleNavigateTo(path: string) {
setPathInputDirty(false)
void navigateTo(path)
}
function handleNavigateUp() {
const parent = currentMetadata()?.parentPath
if (parent) {
setPathInputDirty(false)
void navigateTo(parent)
}
}
const currentAbsolutePath = createMemo(() => {
return getAbsolutePathFromMetadata(currentMetadata())
})
createEffect(() => {
const absolutePath = currentAbsolutePath()
if (!pathInputDirty()) {
setPathInput(absolutePath)
const metadata = currentMetadata()
if (!metadata) {
return ""
}
if (metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
})
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) {
const absolutePath = entry.absolutePath
@@ -310,13 +262,10 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function handleCreateFolder() {
if (creatingFolder()) return
const target = pathInput().trim()
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
const metadata = currentMetadata()
if (!metadata || metadata.pathKind === "drives") {
return
}
setPathInputDirty(false)
setPathInput(getAbsolutePathFromMetadata(metadata))
const name =
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
@@ -389,29 +338,19 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<div class="directory-browser-current">
<div class="directory-browser-current-meta">
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</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"
/>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
</div>
<div class="directory-browser-current-actions">
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
onClick={() => void handleSelectCurrent()}
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
{t("directoryBrowser.selectCurrent")}
</button>

View File

@@ -1,4 +1,4 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { loadMonaco } from "../../lib/monaco/setup"
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
import { inferMonacoLanguageId } from "../../lib/monaco/language"
@@ -15,55 +15,6 @@ interface MonacoDiffViewerProps {
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void
insertContextLabel?: string
}
function getLineCount(value: string): number {
if (!value) return 1
return value.split("\n").length
}
function getDigitCount(value: number): number {
return String(Math.max(1, value)).length
}
function getUnifiedGutterSizing(options: { before: string; after: string }) {
const beforeLineCount = getLineCount(options.before)
const afterLineCount = getLineCount(options.after)
const beforeDigitCount = getDigitCount(beforeLineCount)
const afterDigitCount = getDigitCount(afterLineCount)
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
const extraDigits = Math.max(0, maxDigitCount - 2)
const beforeNumberChars = Math.max(2, beforeDigitCount)
const afterNumberChars = Math.max(2, afterDigitCount)
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
return {
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
originalLineNumbersMinChars: beforeNumberChars,
modifiedLineNumbersMinChars: afterNumberChars,
lineDecorationsWidth: 6 + extraDigits * 2 + fourDigitPenalty * 2,
}
}
function getSplitGutterSizing(options: { before: string; after: string }) {
const beforeLineCount = getLineCount(options.before)
const afterLineCount = getLineCount(options.after)
const beforeDigitCount = getDigitCount(beforeLineCount)
const afterDigitCount = getDigitCount(afterLineCount)
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
const extraDigits = Math.max(0, maxDigitCount - 2)
const beforeNumberChars = Math.max(2, beforeDigitCount)
const afterNumberChars = Math.max(2, afterDigitCount)
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
return {
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
originalLineNumbersMinChars: beforeNumberChars,
modifiedLineNumbersMinChars: afterNumberChars,
lineDecorationsWidth: 8 + extraDigits * 2 + fourDigitPenalty,
}
}
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
@@ -72,12 +23,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
let diffEditor: any = null
let monaco: any = null
let splitLayoutFrame: number | null = null
const [ready, setReady] = createSignal(false)
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
const [widgetHovered, setWidgetHovered] = createSignal(false)
const [widgetPosition, setWidgetPosition] = createSignal<{ top: number; left: number } | null>(null)
const resolvedContent = createMemo(() => {
if (props.patch !== undefined && props.patch !== null) {
@@ -103,90 +49,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
diffEditor = null
}
const clearSplitLayoutVariables = () => {
if (!host) return
host.style.removeProperty("--split-original-line-number-width")
host.style.removeProperty("--split-original-delete-sign-left")
host.style.removeProperty("--split-original-gutter-width")
}
const syncSplitLayoutVariables = (options: {
viewMode: "split" | "unified"
originalLineNumbersMinChars: number
lineDecorationsWidth: number
}) => {
if (!host) return
if (splitLayoutFrame !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(splitLayoutFrame)
splitLayoutFrame = null
}
if (options.viewMode !== "split" || typeof window === "undefined") {
clearSplitLayoutVariables()
return
}
splitLayoutFrame = window.requestAnimationFrame(() => {
splitLayoutFrame = null
if (!host) return
const originalLineNumbers = host.querySelector<HTMLElement>(".editor.original .line-numbers")
const measuredWidth = originalLineNumbers?.getBoundingClientRect().width ?? 0
const lineNumberWidth =
measuredWidth > 0 ? measuredWidth : Math.max(12, options.originalLineNumbersMinChars * 6)
host.style.setProperty("--split-original-line-number-width", `${lineNumberWidth}px`)
host.style.setProperty("--split-original-delete-sign-left", `${lineNumberWidth}px`)
host.style.setProperty(
"--split-original-gutter-width",
`${lineNumberWidth + options.lineDecorationsWidth}px`,
)
})
}
const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
const getActiveInsertRange = () => {
const selection = selectedRange()
if (selection) return selection
if (widgetHovered() && hoveredLine()) {
return { startLine: hoveredLine() as number, endLine: hoveredLine() as number }
}
const line = hoveredLine()
if (!line) return null
return { startLine: line, endLine: line }
}
const layoutInsertWidget = () => {
const modifiedEditor = getModifiedEditor()
const container = host
if (!modifiedEditor || !container) return
const activeRange = getActiveInsertRange()
if (!activeRange) {
setWidgetPosition(null)
return
}
try {
const modifiedDom = modifiedEditor.getDomNode?.() as HTMLElement | null
if (!modifiedDom) {
setWidgetPosition(null)
return
}
const margin = modifiedDom.querySelector<HTMLElement>(".margin")
const scrollable = modifiedDom.querySelector<HTMLElement>(".monaco-scrollable-element.editor-scrollable")
const lineTop = modifiedEditor.getTopForLineNumber?.(activeRange.startLine) ?? 0
const scrollTop = modifiedEditor.getScrollTop?.() ?? 0
const lineHeight = Number(modifiedEditor.getOption?.(monaco.editor.EditorOption.lineHeight) ?? 18)
const modifiedRect = modifiedDom.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const seamLeft = modifiedRect.left - containerRect.left + (margin?.offsetWidth ?? scrollable?.offsetLeft ?? 0)
const centerTop = modifiedRect.top - containerRect.top + (lineTop - scrollTop) + lineHeight / 2
setWidgetPosition({ top: centerTop, left: seamLeft })
} catch {
setWidgetPosition(null)
}
}
onMount(() => {
let cancelled = false
void (async () => {
@@ -219,17 +81,10 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
setReady(true)
layoutInsertWidget()
})()
onCleanup(() => {
cancelled = true
if (splitLayoutFrame !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(splitLayoutFrame)
splitLayoutFrame = null
}
clearSplitLayoutVariables()
setReady(false)
disposeEditor()
})
@@ -240,101 +95,15 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})
createEffect(() => {
if (!host) return
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = diffEditor.getModifiedEditor?.()
if (!modifiedEditor?.onDidChangeCursorSelection) return
const disposable = modifiedEditor.onDidChangeCursorSelection((event: any) => {
const selection = event?.selection
if (!selection || selection.isEmpty?.()) {
setSelectedRange(null)
layoutInsertWidget()
return
}
setSelectedRange({
startLine: Math.min(selection.startLineNumber, selection.endLineNumber),
endLine: Math.max(selection.startLineNumber, selection.endLineNumber),
})
layoutInsertWidget()
})
onCleanup(() => {
try {
disposable?.dispose?.()
} catch {
// ignore
}
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = getModifiedEditor()
if (!modifiedEditor?.onMouseMove || !modifiedEditor?.onMouseLeave || !modifiedEditor?.onMouseDown) return
const moveDisposable = modifiedEditor.onMouseMove((event: any) => {
const lineNumber = event?.target?.position?.lineNumber
setHoveredLine(typeof lineNumber === "number" ? lineNumber : null)
layoutInsertWidget()
})
const leaveDisposable = modifiedEditor.onMouseLeave(() => {
if (!widgetHovered()) {
setHoveredLine(null)
}
layoutInsertWidget()
})
const scrollDisposable = modifiedEditor.onDidScrollChange?.(() => {
layoutInsertWidget()
})
onCleanup(() => {
try {
moveDisposable?.dispose?.()
leaveDisposable?.dispose?.()
scrollDisposable?.dispose?.()
} catch {
// ignore
}
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const activeRange = getActiveInsertRange()
if (!activeRange) setWidgetPosition(null)
layoutInsertWidget()
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
const wordWrap = props.wordWrap === "on" ? "on" : "off"
const { before, after } = resolvedContent()
const sizing =
viewMode === "unified"
? getUnifiedGutterSizing({ before, after })
: getSplitGutterSizing({ before, after })
const {
diffEditorLineNumbersMinChars,
originalLineNumbersMinChars,
modifiedLineNumbersMinChars,
lineDecorationsWidth,
} = sizing
diffEditor.updateOptions({
renderSideBySide: viewMode === "split",
renderSideBySideInlineBreakpoint: 0,
renderIndicators: true,
lineNumbersMinChars: diffEditorLineNumbersMinChars,
lineDecorationsWidth,
hideUnchangedRegions:
contextMode === "collapsed"
? { enabled: true }
@@ -343,30 +112,16 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
try {
diffEditor.getOriginalEditor?.()?.updateOptions?.({
wordWrap,
lineNumbersMinChars: originalLineNumbersMinChars,
lineDecorationsWidth,
})
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
try {
diffEditor.getModifiedEditor?.()?.updateOptions?.({
wordWrap,
lineNumbersMinChars: modifiedLineNumbersMinChars,
lineDecorationsWidth,
})
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
syncSplitLayoutVariables({
viewMode,
originalLineNumbersMinChars,
lineDecorationsWidth,
})
})
createEffect(() => {
@@ -390,46 +145,5 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
})
return (
<div class="monaco-viewer" ref={host}>
<div class="git-change-context-overlay">
<Show when={widgetPosition()}>
{(position: () => { top: number; left: number }) => (
<div
class="git-change-context-widget-host"
style={{ top: `${position().top}px`, left: `${position().left}px` }}
onMouseEnter={() => {
setWidgetHovered(true)
layoutInsertWidget()
}}
onMouseLeave={() => {
setWidgetHovered(false)
layoutInsertWidget()
}}
>
<button
type="button"
class="git-change-context-widget"
aria-label={props.insertContextLabel ?? "Add git change context to prompt"}
title={props.insertContextLabel ?? "Add git change context to prompt"}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
const activeRange = getActiveInsertRange()
if (!activeRange) return
props.onRequestInsertContext?.(activeRange)
}}
>
+
</button>
</div>
)}
</Show>
</div>
</div>
)
return <div class="monaco-viewer" ref={host} />
}

View File

@@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
@@ -16,7 +16,6 @@ import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -58,6 +57,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
type LanguageOption = { value: Locale; label: string }
@@ -77,7 +77,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const folders = () => recentFolders()
const serverList = () => remoteServers()
const isLoading = () => Boolean(props.isLoading)
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
function getActiveListLength() {
return activeTab() === "local" ? folders().length : serverList().length
@@ -124,7 +123,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const normalizedKey = e.key.toLowerCase()
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
const blockedKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", "Enter"]
const blockedKeys = [
"ArrowDown",
"ArrowUp",
"PageDown",
"PageUp",
"Home",
"End",
"Enter",
"Backspace",
"Delete",
]
if (isLoading()) {
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
@@ -182,6 +191,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} else if (e.key === "Enter") {
e.preventDefault()
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)
}
}
}
}
}
@@ -206,10 +230,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
createEffect(() => {
activeTab()
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
setActiveTab("local")
return
}
setSelectedIndex(0)
setFocusMode("recent")
})
@@ -284,16 +304,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}
function openServerDialog() {
if (!canUseRemoteServerWindows()) return
resetServerDialog()
setIsServerDialogOpen(true)
}
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 trimmedUrl = input.baseUrl.trim()
if (!trimmedName || !trimmedUrl) {
@@ -317,23 +332,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
})
if (openWindow) {
const remoteProxySession =
isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
? await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify,
})
: undefined
try {
await openRemoteServerWindow(profile, remoteProxySession?.windowUrl, remoteProxySession?.sessionId)
} catch (error) {
if (remoteProxySession) {
void serverApi.deleteRemoteProxySession(remoteProxySession.sessionId).catch(() => {})
}
throw error
}
await openRemoteServerWindow(profile)
await markRemoteServerConnected(profile.id)
}
@@ -363,7 +362,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}
async function handleConnectSavedServer(id: string) {
if (!canUseRemoteServerWindows()) return
const target = remoteServers().find((entry) => entry.id === id)
if (!target || connectingServerId()) return
setConnectingServerId(id)
@@ -382,7 +380,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
if (supportsNativeDialogsInCurrentWindow()) {
if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: t("folderSelection.dialog.title"),
@@ -539,17 +537,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
>
<Settings class="w-4 h-4" />
</button>
<Show when={canUseRemoteServerWindows()}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => openSettings("remote")}
aria-label={t("instanceTabs.remote.ariaLabel")}
title={t("instanceTabs.remote.title")}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => openSettings("remote")}
aria-label={t("instanceTabs.remote.ariaLabel")}
title={t("instanceTabs.remote.title")}
>
<MonitorUp class="w-4 h-4" />
</button>
<Show when={props.onClose}>
<button
type="button"
@@ -623,7 +619,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="panel flex flex-col flex-1 min-h-0">
<div class="panel-header !gap-0 !p-0">
<div class={`grid ${canUseRemoteServerWindows() ? "grid-cols-2" : "grid-cols-1"} gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none`}>
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
<button
type="button"
class="border-r border-base px-4 py-3 text-left transition-colors"
@@ -658,37 +654,35 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
)}
</p>
</button>
<Show when={canUseRemoteServerWindows()}>
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "servers",
"text-muted hover:text-secondary": activeTab() !== "servers",
}}
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"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={{
"background-color": "var(--surface-secondary)",
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-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={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
</Show>
{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>
</div>
</div>
@@ -696,25 +690,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
when={activeTab() === "local"}
fallback={
<Show
when={canUseRemoteServerWindows() && remoteServers().length > 0}
when={remoteServers().length > 0}
fallback={
<Show when={canUseRemoteServerWindows()}>
<div class="panel-empty-state flex-1">
<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 class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
</Show>
<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
@@ -882,17 +874,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</button>
<Show when={canUseRemoteServerWindows()}>
<button
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" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</Show>
<button
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" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</div>
{/* OpenCode settings section */}
@@ -928,6 +918,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<kbd class="kbd">Enter</kbd>
<span>{t("folderSelection.hints.select")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>{t("folderSelection.hints.remove")}</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" class="kbd-hint" />

View File

@@ -6,7 +6,6 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { canOpenRemoteWindows } from "../lib/runtime-env"
import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen"
import type { AppTabRecord } from "../stores/app-tabs"
@@ -100,16 +99,14 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
<Show when={canOpenRemoteWindows()}>
<button
class="new-tab-button tab-remote-button"
onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<button
class="new-tab-button tab-remote-button"
onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</div>
</div>

View File

@@ -171,6 +171,9 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
} else if (e.key === "Enter") {
e.preventDefault()
void handleEnterKey()
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault()
void handleDeleteKey()
}
}
@@ -184,6 +187,29 @@ 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(() => {
window.addEventListener("keydown", handleKeyDown)
@@ -536,6 +562,10 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<kbd class="kbd">Enter</kbd>
<span>{t("instanceWelcome.hints.resume")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>{t("instanceWelcome.hints.delete")}</span>
</div>
</div>
</div>

View File

@@ -43,7 +43,6 @@ import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid"
import type { PromptInputApi } from "../prompt-input/types"
import type { LayoutMode } from "./shell/types"
import {
@@ -106,7 +105,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now())
const [sessionPromptApis, setSessionPromptApis] = createSignal<Record<string, PromptInputApi | null>>({})
// Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
@@ -270,19 +268,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
const activePromptInputApi = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return sessionPromptApis()[sessionId] ?? null
})
const registerSessionPromptApi = (sessionId: string, api: PromptInputApi | null) => {
setSessionPromptApis((current) => ({
...current,
[sessionId]: api,
}))
}
createEffect(() => {
getPermissionAutoAcceptInFlightVersion()
@@ -357,11 +342,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pill = activeSessionStatusPill()
if (!pill) return null
return (
<span
class={`status-indicator session-status session-status-list ${pill.className} notranslate`}
title={pill.title}
translate="no"
>
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{pill.text}
</span>
@@ -613,7 +594,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCloseRightDrawer={closeRightDrawer}
onPinRightDrawer={pinRightDrawer}
onUnpinRightDrawer={unpinRightDrawer}
promptInputApi={activePromptInputApi}
setContentEl={setRightDrawerContentEl}
/>
</Box>
@@ -676,7 +656,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCloseRightDrawer={closeRightDrawer}
onPinRightDrawer={pinRightDrawer}
onUnpinRightDrawer={unpinRightDrawer}
promptInputApi={activePromptInputApi}
setContentEl={setRightDrawerContentEl}
/>
</Drawer>
@@ -913,7 +892,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()}
compactPromptLayout={compactPromptLayout()}
registerSessionPromptApi={registerSessionPromptApi}
showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()}

View File

@@ -10,7 +10,7 @@ import {
type Component,
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
@@ -19,23 +19,16 @@ import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session"
import type { PromptInputApi } from "../../../prompt-input/types"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import {
getDefaultWorktreeSlug,
getGitRepoStatus,
getOrCreateWorktreeClient,
getWorktreeSlugForSession,
getWorktrees,
} from "../../../../stores/worktrees"
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client"
import { showConfirmDialog } from "../../../../stores/alerts"
import { showToastNotification } from "../../../../lib/notifications"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import { useGitChanges } from "./useGitChanges"
import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
@@ -48,11 +41,7 @@ import {
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
RIGHT_PANEL_TAB_STORAGE_KEY,
readStoredBool,
readStoredEnum,
@@ -93,7 +82,6 @@ interface RightPanelProps {
onCloseRightDrawer: () => void
onPinRightDrawer: () => void
onUnpinRightDrawer: () => void
promptInputApi: Accessor<PromptInputApi | null>
setContentEl: (el: HTMLElement | null) => void
}
@@ -145,8 +133,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [changesListTouched, setChangesListTouched] = createSignal(false)
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
@@ -163,28 +149,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
}
const gitSectionStorageKey = (section: "staged" | "unstaged") => {
const layout = listLayoutKey()
if (section === "staged") {
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY
}
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY
}
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
}
const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(gitSectionStorageKey(section), value ? "true" : "false")
}
createEffect(() => {
// Refresh persisted visibility when layout changes (phone vs non-phone).
const layout = listLayoutKey()
@@ -216,12 +185,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setGitChangesListOpen(true)
setGitChangesListTouched(false)
}
const stagedPersisted = readStoredBool(gitSectionStorageKey("staged"))
setGitStagedOpen(stagedPersisted ?? true)
const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged"))
setGitUnstagedOpen(unstagedPersisted ?? true)
})
createEffect(() => {
@@ -376,56 +339,34 @@ const RightPanel: Component<RightPanelProps> = (props) => {
return getDefaultWorktreeSlug(props.instanceId)
})
const gitChangesWorktreeSlug = createMemo(() => {
if (getGitRepoStatus(props.instanceId) === false) return null
const slug = worktreeSlugForViewer().trim()
return slug ? slug : null
})
const gitChangesWorktree = createMemo(() => {
const slug = gitChangesWorktreeSlug()
if (!slug) return null
return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null
})
const gitChangesBranchLabel = createMemo(() => {
const branch = gitChangesWorktree()?.branch?.trim()
return branch || null
})
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
const {
gitStatusEntries,
gitStatusLoading,
gitStatusError,
gitSelectedItemId,
gitBulkSelectedItemIds,
gitSelectedLoading,
gitSelectedError,
gitSelectedBefore,
gitSelectedAfter,
gitCommitMessage,
gitCommitSubmitting,
gitMostChangedItemId,
setGitCommitMessage,
handleGitRowClick,
refreshGitStatus,
insertGitChangeContext,
submitGitCommit,
stageGitFile,
unstageGitFile,
} = useGitChanges({
t: props.t,
instanceId: props.instanceId,
rightPanelTab,
worktreeSlug: worktreeSlugForViewer,
isPhoneLayout: props.isPhoneLayout,
promptInputApi: props.promptInputApi,
closeGitList: () => setGitChangesListOpen(false),
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
const gitMostChangedPath = createMemo<string | null>(() => {
const entries = gitStatusEntries()
if (!Array.isArray(entries) || entries.length === 0) return null
const candidates = entries.filter((item) => item && item.status !== "deleted")
if (candidates.length === 0) return null
const best = candidates.reduce((currentBest, item) => {
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
const score = (item?.added ?? 0) + (item?.removed ?? 0)
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
}, candidates[0])
return typeof best?.path === "string" ? best.path : null
})
createEffect(() => {
// Reset tab state when worktree context changes.
worktreeSlugForViewer()
setBrowserPath(".")
setBrowserEntries(null)
@@ -434,8 +375,111 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedContent(null)
setBrowserSelectedError(null)
setBrowserSelectedLoading(false)
setGitStatusEntries(null)
setGitStatusError(null)
setGitStatusLoading(false)
setGitSelectedPath(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
})
const loadGitStatus = async (force = false) => {
if (!force && gitStatusEntries() !== null) return
setGitStatusLoading(true)
setGitStatusError(null)
try {
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
setGitStatusEntries(Array.isArray(list) ? list : [])
} catch (error) {
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
setGitStatusEntries([])
} finally {
setGitStatusLoading(false)
}
}
async function openGitFile(path: string) {
setGitSelectedPath(path)
setGitSelectedLoading(true)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
const list = gitStatusEntries() || []
const entry = list.find((item) => item.path === path) || null
if (entry?.status === "deleted") {
setGitSelectedError("Deleted file diff is not available yet")
setGitSelectedLoading(false)
return
}
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setGitChangesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
if (afterText === null) {
throw new Error("Unsupported file type")
}
setGitSelectedAfter(afterText)
if (entry?.status === "added") {
setGitSelectedBefore("")
return
}
const diffText =
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
? String((content as any).diff)
: (content as any)?.patch
? buildUnifiedDiffFromSdkPatch((content as any).patch)
: ""
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
if (beforeText === null) {
throw new Error("Unable to calculate diff for this file")
}
setGitSelectedBefore(beforeText)
} catch (error) {
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
} finally {
setGitSelectedLoading(false)
}
}
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
const entries = gitStatusEntries()
if (entries === null) return
if (gitSelectedPath()) return
const next = gitMostChangedPath()
if (!next) return
void openGitFile(next)
})
const refreshGitStatus = async () => {
await loadGitStatus(true)
const selected = gitSelectedPath()
if (selected) {
void openGitFile(selected)
}
}
const bestDiffFile = createMemo<string | null>(() => {
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return null
@@ -636,6 +680,21 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedDirty(false)
})
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
if (gitStatusLoading()) return
if (gitStatusEntries() !== null) return
void loadGitStatus()
})
createEffect(() => {
if (rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
const handleSelectChangesFile = (file: string, closeList: boolean) => {
setSelectedFile(file)
if (closeList) {
@@ -852,13 +911,12 @@ const RightPanel: Component<RightPanelProps> = (props) => {
entries={gitStatusEntries}
statusLoading={gitStatusLoading}
statusError={gitStatusError}
selectedItemId={gitSelectedItemId}
selectedBulkItemIds={gitBulkSelectedItemIds}
selectedPath={gitSelectedPath}
selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter}
mostChangedItemId={gitMostChangedItemId}
mostChangedPath={gitMostChangedPath}
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
@@ -866,28 +924,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onRowClick={handleGitRowClick}
onOpenFile={(path: string) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
onInsertContext={insertGitChangeContext}
onStageFile={stageGitFile}
onUnstageFile={unstageGitFile}
commitMessage={gitCommitMessage}
commitSubmitting={gitCommitSubmitting}
onCommitMessageInput={setGitCommitMessage}
onSubmitCommit={() => void submitGitCommit()}
branchLabel={gitChangesBranchLabel}
stagedOpen={gitStagedOpen}
unstagedOpen={gitUnstagedOpen}
onToggleStagedOpen={() => {
const next = !gitStagedOpen()
setGitStagedOpen(next)
persistGitSectionOpen("staged", next)
}}
onToggleUnstagedOpen={() => {
const next = !gitUnstagedOpen()
setGitUnstagedOpen(next)
persistGitSectionOpen("unstaged", next)
}}
listOpen={gitChangesListOpen}
onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth}

View File

@@ -1,148 +0,0 @@
import type { File as SdkGitFileStatus } from "@opencode-ai/sdk/v2/client"
import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types"
import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types"
function normalizeGitChangePath(path: unknown): string {
if (typeof path !== "string") return ""
const normalized = path.replace(/\\+/g, "/").replace(/^\.\//, "").trim()
return normalized
}
export function normalizeGitChangeStatus(status: unknown): GitChangeStatus {
return typeof status === "string" && status.trim().length > 0 ? status : "modified"
}
export function adaptSdkGitStatusEntry(entry: SdkGitFileStatus): GitChangeEntry {
return {
path: normalizeGitChangePath(entry?.path),
originalPath: null,
additions: typeof entry?.added === "number" ? entry.added : 0,
deletions: typeof entry?.removed === "number" ? entry.removed : 0,
status: normalizeGitChangeStatus(entry?.status),
}
}
export function adaptSdkGitStatusEntries(
entries: SdkGitFileStatus[] | null | undefined,
details?: WorktreeGitStatusEntry[] | null,
): GitChangeEntry[] {
const detailsByPath = new Map(
(details ?? [])
.map((entry) => {
const path = normalizeGitChangePath(entry.path)
return path ? [{ ...entry, path }, path] : null
})
.filter((entry): entry is [WorktreeGitStatusEntry, string] => Boolean(entry))
.map(([entry, path]) => [path, entry] as const),
)
const adaptedByPath = new Map<string, GitChangeEntry>()
for (const entry of entries ?? []) {
const adapted = adaptSdkGitStatusEntry(entry)
if (!adapted.path) continue
const detail = detailsByPath.get(adapted.path)
adaptedByPath.set(adapted.path, {
...adapted,
originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null,
stagedStatus: detail?.stagedStatus ?? null,
unstagedStatus: detail?.unstagedStatus ?? null,
stagedAdditions: detail?.stagedAdditions ?? 0,
stagedDeletions: detail?.stagedDeletions ?? 0,
unstagedAdditions: detail?.unstagedAdditions ?? 0,
unstagedDeletions: detail?.unstagedDeletions ?? 0,
})
}
for (const detail of details ?? []) {
const normalizedPath = normalizeGitChangePath(detail.path)
if (!normalizedPath || adaptedByPath.has(normalizedPath)) continue
adaptedByPath.set(normalizedPath, {
path: normalizedPath,
originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null,
additions: 0,
deletions: 0,
status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified",
stagedStatus: detail.stagedStatus,
unstagedStatus: detail.unstagedStatus,
stagedAdditions: detail.stagedAdditions,
stagedDeletions: detail.stagedDeletions,
unstagedAdditions: detail.unstagedAdditions,
unstagedDeletions: detail.unstagedDeletions,
})
}
return Array.from(adaptedByPath.values()).filter((entry) => entry.path.length > 0)
}
function buildGitChangeListItemId(section: GitChangeSection, path: string): string {
return `${section}:${path}`
}
function splitGitChangePath(path: string) {
const normalized = normalizeGitChangePath(path)
const lastSlash = normalized.lastIndexOf("/")
if (lastSlash === -1) {
return { displayName: normalized, parentPath: "" }
}
return {
displayName: normalized.slice(lastSlash + 1),
parentPath: normalized.slice(0, lastSlash),
}
}
export function buildGitChangeListItems(entries: GitChangeEntry[] | null | undefined): GitChangeListItem[] {
if (!Array.isArray(entries)) return []
const items: GitChangeListItem[] = []
for (const entry of entries) {
const pathParts = splitGitChangePath(entry.path)
if (entry.stagedStatus) {
items.push({
id: buildGitChangeListItemId("staged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "staged",
status: entry.stagedStatus,
additions: entry.stagedAdditions ?? 0,
deletions: entry.stagedDeletions ?? 0,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
if (entry.unstagedStatus) {
items.push({
id: buildGitChangeListItemId("unstaged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "unstaged",
status: entry.unstagedStatus,
additions: entry.unstagedAdditions ?? entry.additions,
deletions: entry.unstagedDeletions ?? entry.deletions,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
if (!entry.stagedStatus && !entry.unstagedStatus) {
items.push({
id: buildGitChangeListItemId("unstaged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "unstaged",
status: entry.status,
additions: entry.additions,
deletions: entry.deletions,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
}
return items.sort((a, b) => {
if (a.section !== b.section) return a.section.localeCompare(b.section)
return a.path.localeCompare(b.path)
})
}

View File

@@ -1,20 +1,11 @@
import {
For,
Show,
Suspense,
createMemo,
lazy,
type Accessor,
type Component,
type JSX,
} from "solid-js"
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { ChevronDown, ChevronRight, GitBranch, RefreshCw } from "lucide-solid"
import { RefreshCw } from "lucide-solid"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, GitChangeEntry, GitChangeListItem } from "../types"
import { buildGitChangeListItems } from "../git-changes-model"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
@@ -25,17 +16,16 @@ interface GitChangesTabProps {
activeSessionId: Accessor<string | null>
entries: Accessor<GitChangeEntry[] | null>
entries: Accessor<GitFileStatus[] | null>
statusLoading: Accessor<boolean>
statusError: Accessor<string | null>
selectedItemId: Accessor<string | null>
selectedBulkItemIds: Accessor<Set<string>>
selectedPath: Accessor<string | null>
selectedLoading: Accessor<boolean>
selectedError: Accessor<string | null>
selectedBefore: Accessor<string | null>
selectedAfter: Accessor<string | null>
mostChangedItemId: Accessor<string | null>
mostChangedPath: Accessor<string | null>
scopeKey: Accessor<string>
@@ -46,21 +36,8 @@ interface GitChangesTabProps {
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onRowClick: (item: GitChangeListItem, event: MouseEvent) => void
onOpenFile: (path: string) => void
onRefresh: () => void
onInsertContext: (item: GitChangeListItem, selection: { startLine: number; endLine: number }) => void
onStageFile: (item: GitChangeListItem) => void
onUnstageFile: (item: GitChangeListItem) => void
commitMessage: Accessor<string>
commitSubmitting: Accessor<boolean>
onCommitMessageInput: (value: string) => void
onSubmitCommit: () => void
branchLabel: Accessor<string | null>
stagedOpen: Accessor<boolean>
unstagedOpen: Accessor<boolean>
onToggleStagedOpen: () => void
onToggleUnstagedOpen: () => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -75,54 +52,48 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
const entries = createMemo(() => (hasSession() ? props.entries() : null))
const sorted = createMemo<GitChangeEntry[]>(() => {
const sorted = createMemo<GitFileStatus[]>(() => {
const list = entries()
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
})
const listItems = createMemo<GitChangeListItem[]>(() => buildGitChangeListItems(sorted()))
const totals = createMemo(() => {
return listItems().reduce(
return sorted().reduce(
(acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0
return acc
},
{ additions: 0, deletions: 0 },
)
})
const stagedItems = createMemo(() => listItems().filter((item) => item.section === "staged"))
const unstagedItems = createMemo(() => listItems().filter((item) => item.section === "unstaged"))
const canCommit = createMemo(() => stagedItems().length > 0 && props.commitMessage().trim().length > 0 && !props.commitSubmitting())
const selectedEntry = createMemo<GitChangeEntry | null>(() => {
const list = listItems()
const selectedId = props.selectedItemId()
const fallbackId = props.mostChangedItemId()
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
const selectedEntry = createMemo<GitFileStatus | null>(() => {
const list = sorted()
const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath()
const found =
list.find((item) => item.id === selectedId) ||
(fallbackId ? list.find((item) => item.id === fallbackId) : undefined)
return found?.entry ?? null
list.find((item) => item.path === selectedPath) ||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
return found ?? null
})
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
const currentEntries = entries()
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (listItems().length === 0) return props.t("instanceShell.gitChanges.empty")
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
})
const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer"))
const renderContent = (): JSX.Element => {
const totalsValue = totals()
const selected = selectedEntry()
const allItems = listItems()
const stagedList = stagedItems()
const unstagedList = unstagedItems()
const sortedList = sorted()
const nonDeletedList = nonDeleted()
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
@@ -138,7 +109,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
selected &&
props.selectedBefore() !== null &&
props.selectedAfter() !== null &&
true
selected.status !== "deleted"
? {
path: selected.path,
before: props.selectedBefore() as string,
@@ -168,14 +139,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
insertContextLabel={props.t("instanceShell.gitChanges.actions.insertContext")}
onRequestInsertContext={binaryViewerActive() ? undefined : (selection) => {
const selectedId = props.selectedItemId()
if (!selectedId) return
const item = listItems().find((entry) => entry.id === selectedId)
if (!item) return
props.onInsertContext(item, selection)
}}
/>
</Suspense>
)}
@@ -200,149 +163,66 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
const renderListItem = (item: GitChangeListItem) => {
const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id))
const actionLabel =
item.section === "staged"
? props.t("instanceShell.gitChanges.actions.unstage")
: props.t("instanceShell.gitChanges.actions.stage")
const triggerAction = () => {
if (item.section === "staged") props.onUnstageFile(item)
else props.onStageFile(item)
}
return (
<div
class={`file-list-item git-change-list-item ${props.selectedItemId() === item.id ? "file-list-item-active" : ""} ${isBulkSelected() ? "git-change-list-item-bulk-selected" : ""}`}
onMouseDown={(event) => {
if (event.shiftKey || event.ctrlKey || event.metaKey) {
event.preventDefault()
}
}}
onClick={(event) => props.onRowClick(item, event)}
title={item.path}
>
<div class="file-list-item-content" title={item.path}>
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="git-change-list-item-right">
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
const renderListPanel = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
props.onOpenFile(item.path)
}}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
</div>
</div>
</div>
<div class="git-change-list-item-actions-zone">
<div class="git-change-list-item-actions">
<button
type="button"
class="git-change-row-action"
title={actionLabel}
aria-label={actionLabel}
onClick={(event) => {
event.stopPropagation()
triggerAction()
}}
>
<span
class={`git-change-row-action-glyph ${item.section === "staged" ? "git-change-row-action-glyph-minus" : "git-change-row-action-glyph-plus"}`}
aria-hidden="true"
>
<span class="git-change-row-action-bar git-change-row-action-bar-horizontal" />
<Show when={item.section !== "staged"}>
<span class="git-change-row-action-bar git-change-row-action-bar-vertical" />
</Show>
</span>
</button>
</div>
</div>
</div>
)
}
const renderSection = (
title: string,
items: GitChangeListItem[],
isOpen: boolean,
onToggle: () => void,
) => (
<div class="git-change-section">
<button type="button" class="git-change-section-header" onClick={onToggle}>
<span class="git-change-section-header-main">
<span class="git-change-section-chevron">
{isOpen ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
</span>
<span class="git-change-section-title">{title}</span>
</span>
<span class="git-change-section-count">{items.length}</span>
</button>
<Show when={isOpen}>
<div class="git-change-section-items">
<For each={items}>{(item) => renderListItem(item)}</For>
</div>
</Show>
</div>
)}
</For>
</Show>
)
const renderGroupedList = () => (
<Show when={allItems.length > 0} fallback={renderEmptyList()}>
<div class="git-change-sections">
<div class="git-change-section">
<button type="button" class="git-change-section-header" onClick={props.onToggleStagedOpen}>
<span class="git-change-section-header-main">
<span class="git-change-section-chevron">
{props.stagedOpen() ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
</span>
<span class="git-change-section-title-row">
<span class="git-change-section-title">{props.t("instanceShell.gitChanges.sections.staged")}</span>
<Show when={props.branchLabel()}>
{(label) => (
<span class="status-indicator session-status-list worktree-indicator git-change-section-badge" title={`Branch: ${label()}`}>
<GitBranch class="w-3.5 h-3.5" aria-hidden="true" />
<span class="worktree-indicator-label">{label()}</span>
</span>
)}
</Show>
</span>
</span>
<span class="git-change-section-count">{stagedList.length}</span>
</button>
<Show when={props.stagedOpen()}>
<div class="git-change-section-items">
<div class="git-change-commit-box">
<div class="git-change-commit-input-wrap">
<textarea
class="git-change-commit-input"
value={props.commitMessage()}
rows={1}
placeholder={props.t("instanceShell.gitChanges.commit.placeholder")}
onInput={(event) => props.onCommitMessageInput(event.currentTarget.value)}
/>
<button
type="button"
class="git-change-commit-button git-change-commit-button-overlay"
disabled={!canCommit()}
onClick={() => props.onSubmitCommit()}
>
{props.commitSubmitting()
? props.t("instanceShell.gitChanges.commit.submitting")
: props.t("instanceShell.gitChanges.commit.submit")}
</button>
</div>
const renderListOverlay = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => props.onOpenFile(item.path)}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
<For each={stagedList}>{(item) => renderListItem(item)}</For>
</div>
</Show>
</div>
{renderSection(
props.t("instanceShell.gitChanges.sections.unstaged"),
unstagedList,
props.unstagedOpen(),
props.onToggleUnstagedOpen,
</div>
)}
</div>
</For>
</Show>
)
@@ -384,10 +264,9 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</>
}
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}

View File

@@ -5,40 +5,3 @@ export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed"
export type DiffWordWrapMode = "on" | "off"
export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | string
export interface GitChangeEntry {
path: string
originalPath?: string | null
additions: number
deletions: number
status: GitChangeStatus
stagedStatus?: GitChangeStatus | null
unstagedStatus?: GitChangeStatus | null
stagedAdditions?: number
stagedDeletions?: number
unstagedAdditions?: number
unstagedDeletions?: number
}
export type GitChangeSection = "staged" | "unstaged"
export interface GitChangeListItem {
id: string
path: string
originalPath?: string | null
section: GitChangeSection
status: GitChangeStatus
additions: number
deletions: number
entry: GitChangeEntry
displayName: string
parentPath: string
}
export interface GitSelectionDescriptor {
itemId: string | null
path: string | null
section: GitChangeSection | null
}

View File

@@ -1,470 +0,0 @@
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import type { PromptInputApi } from "../../../prompt-input/types"
import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types"
import { getOrCreateWorktreeClient } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client"
import { serverEvents } from "../../../../lib/server-events"
import { showToastNotification } from "../../../../lib/notifications"
import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model"
type UseGitChangesOptions = {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
rightPanelTab: Accessor<RightPanelTab>
worktreeSlug: Accessor<string>
isPhoneLayout: Accessor<boolean>
promptInputApi: Accessor<PromptInputApi | null>
closeGitList: () => void
}
export function useGitChanges(options: UseGitChangesOptions) {
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitChangeEntry[] | null>(null)
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
const [gitSelectedItemId, setGitSelectedItemId] = createSignal<string | null>(null)
const [gitBulkSelectedItemIds, setGitBulkSelectedItemIds] = createSignal<Set<string>>(new Set())
const [gitBulkSelectionAnchorId, setGitBulkSelectionAnchorId] = createSignal<string | null>(null)
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
const [gitCommitMessage, setGitCommitMessage] = createSignal("")
const [gitCommitSubmitting, setGitCommitSubmitting] = createSignal(false)
let gitStatusRequestVersion = 0
let gitDiffRequestVersion = 0
let passiveGitRefreshInFlight = false
let pendingGitPassiveRefreshOptions: { forceReloadSelectedDiff?: boolean } | null = null
let previousGitChangesActivationKey: string | null = null
const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries()))
const clearGitBulkSelection = () => {
setGitBulkSelectedItemIds((current) => (current.size === 0 ? current : new Set<string>()))
setGitBulkSelectionAnchorId(null)
}
const toggleGitBulkSelection = (itemId: string) => {
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
if (next.has(itemId)) next.delete(itemId)
else next.add(itemId)
return next
})
}
const addGitBulkRange = (anchorId: string, itemId: string) => {
const items = gitListItems()
const anchorIndex = items.findIndex((entry) => entry.id === anchorId)
const itemIndex = items.findIndex((entry) => entry.id === itemId)
if (anchorIndex < 0 || itemIndex < 0) {
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
next.add(itemId)
return next
})
return
}
const start = Math.min(anchorIndex, itemIndex)
const end = Math.max(anchorIndex, itemIndex)
const rangeIds = items.slice(start, end + 1).map((entry) => entry.id)
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
for (const rangeId of rangeIds) {
next.add(rangeId)
}
return next
})
}
const describeGitSelection = (itemId: string | null): GitSelectionDescriptor => {
if (!itemId) {
return { itemId: null, path: null, section: null }
}
const match = gitListItems().find((item) => item.id === itemId) ?? null
return {
itemId,
path: match?.path ?? null,
section: match?.section ?? null,
}
}
const gitMostChangedItemId = createMemo<string | null>(() => {
const items = gitListItems()
if (items.length === 0) return null
const candidates = items.filter((item) => item.status !== "deleted")
if (candidates.length === 0) return null
const best = candidates.reduce((currentBest, item) => {
const bestScore = (currentBest?.additions ?? 0) + (currentBest?.deletions ?? 0)
const score = (item.additions ?? 0) + (item.deletions ?? 0)
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.id || "").localeCompare(String(currentBest?.id || "")) < 0 ? item : currentBest
}, candidates[0])
return typeof best?.id === "string" ? best.id : null
})
const resolveValidGitSelection = (selection: GitSelectionDescriptor): string | null => {
const items = gitListItems()
if (items.length === 0) return null
if (selection.itemId && items.some((item) => item.id === selection.itemId)) return selection.itemId
if (selection.path && selection.section) {
const oppositeSection = selection.section === "staged" ? "unstaged" : "staged"
const moved = items.find((item) => item.path === selection.path && item.section === oppositeSection)
if (moved) return moved.id
const samePath = items.find((item) => item.path === selection.path)
if (samePath) return samePath.id
}
return gitMostChangedItemId()
}
const describeGitSelectionFingerprint = (itemId: string | null) => {
if (!itemId) return null
const item = gitListItems().find((entry) => entry.id === itemId) ?? null
if (!item) return null
return `${item.path}::${item.originalPath ?? ""}::${item.section}::${item.status}::${item.additions}::${item.deletions}`
}
const clearSelectedGitDiff = () => {
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
}
const clearSelectedGitDiffAndSelection = () => {
setGitSelectedItemId(null)
clearGitBulkSelection()
setGitSelectedLoading(false)
clearSelectedGitDiff()
}
const pruneGitBulkSelection = () => {
const validIds = new Set(gitListItems().map((item) => item.id))
setGitBulkSelectedItemIds((current) => {
if (current.size === 0) return current
const next = new Set<string>()
for (const itemId of current) {
if (validIds.has(itemId)) next.add(itemId)
}
return next.size === current.size ? current : next
})
const anchorId = gitBulkSelectionAnchorId()
if (anchorId && !validIds.has(anchorId)) {
setGitBulkSelectionAnchorId(null)
}
}
createEffect(() => {
gitListItems()
pruneGitBulkSelection()
})
const loadGitStatus = async (force = false) => {
if (!force && gitStatusEntries() !== null) return
const slug = options.worktreeSlug()
const client = getOrCreateWorktreeClient(options.instanceId, slug)
const requestVersion = ++gitStatusRequestVersion
setGitStatusLoading(true)
setGitStatusError(null)
try {
const sdkStatusPromise = requestData<GitFileStatus[]>(client.file.status(), "file.status")
const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug)
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
const sdkResult = await Promise.race([
sdkStatusPromise.then((value) => ({ kind: "fulfilled" as const, value })),
new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 1500)),
]).catch(() => null)
const sdkList = sdkResult && sdkResult.kind === "fulfilled" ? sdkResult.value : null
setGitStatusEntries(adaptSdkGitStatusEntries(sdkList, detailList))
} catch (error) {
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
setGitStatusEntries([])
} finally {
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
setGitStatusLoading(false)
}
}
async function openGitFile(itemId: string) {
const requestVersion = ++gitDiffRequestVersion
setGitSelectedItemId(itemId)
setGitSelectedLoading(true)
clearSelectedGitDiff()
const item = gitListItems().find((entry) => entry.id === itemId) || null
if (!item) {
if (requestVersion !== gitDiffRequestVersion) return
clearSelectedGitDiffAndSelection()
return
}
if (options.isPhoneLayout()) {
options.closeGitList()
}
try {
const diff = await serverApi.fetchWorktreeGitDiff(options.instanceId, options.worktreeSlug(), {
path: item.path,
originalPath: item.originalPath ?? null,
scope: item.section,
})
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
if (diff.isBinary) {
setGitSelectedError(options.t("instanceShell.gitChanges.binaryViewer"))
return
}
setGitSelectedBefore(diff.before)
setGitSelectedAfter(diff.after)
} catch (error) {
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
} finally {
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
setGitSelectedLoading(false)
}
}
const passiveRefreshGitStatus = async (optionsArg?: { forceReloadSelectedDiff?: boolean }) => {
if (options.rightPanelTab() !== "git-changes") return
if (passiveGitRefreshInFlight) {
pendingGitPassiveRefreshOptions = {
forceReloadSelectedDiff:
pendingGitPassiveRefreshOptions?.forceReloadSelectedDiff || optionsArg?.forceReloadSelectedDiff || false,
}
return
}
if (gitCommitSubmitting()) return
passiveGitRefreshInFlight = true
const refreshSelectionId = gitSelectedItemId()
const previousSelection = describeGitSelection(gitSelectedItemId())
const previousFingerprint = describeGitSelectionFingerprint(previousSelection.itemId)
const hadSelectedDiff =
previousSelection.itemId !== null &&
(gitSelectedBefore() !== null || gitSelectedAfter() !== null || gitSelectedError() !== null)
try {
await loadGitStatus(true)
if (gitSelectedItemId() !== refreshSelectionId) return
const nextSelection = resolveValidGitSelection(previousSelection)
setGitSelectedItemId(nextSelection)
if (!nextSelection) {
clearSelectedGitDiff()
return
}
const nextFingerprint = describeGitSelectionFingerprint(nextSelection)
const shouldReloadSelectedDiff =
optionsArg?.forceReloadSelectedDiff ||
!hadSelectedDiff ||
previousFingerprint !== nextFingerprint ||
previousSelection.itemId === nextSelection
if (shouldReloadSelectedDiff) {
await openGitFile(nextSelection)
}
} finally {
passiveGitRefreshInFlight = false
if (pendingGitPassiveRefreshOptions) {
const nextOptions = pendingGitPassiveRefreshOptions
pendingGitPassiveRefreshOptions = null
void passiveRefreshGitStatus(nextOptions)
}
}
}
const mutateGitFile = async (item: GitChangeListItem, action: "stage" | "unstage") => {
const currentSelection = describeGitSelection(gitSelectedItemId())
const fallbackSelection = currentSelection.path === item.path ? currentSelection : describeGitSelection(item.id)
const selectedIds = gitBulkSelectedItemIds()
const selectedItems = gitListItems().filter((candidate) => selectedIds.has(candidate.id))
const bulkTargets = selectedItems.filter((candidate) => candidate.section === item.section)
const targetItems = bulkTargets.some((candidate) => candidate.id === item.id) ? bulkTargets : [item]
const targetPaths = Array.from(new Set(targetItems.map((candidate) => candidate.path)))
try {
if (action === "stage") {
await serverApi.stageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
} else {
await serverApi.unstageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
}
await loadGitStatus(true)
clearGitBulkSelection()
const nextSelection = resolveValidGitSelection(fallbackSelection)
setGitSelectedItemId(nextSelection)
if (nextSelection) {
await openGitFile(nextSelection)
} else {
clearSelectedGitDiff()
}
} catch (error) {
showToastNotification({
message: error instanceof Error ? error.message : `Failed to ${action} file`,
variant: "error",
})
}
}
const handleGitRowClick = (item: GitChangeListItem, event: MouseEvent) => {
if (event.shiftKey) {
event.preventDefault()
const anchorId = gitBulkSelectionAnchorId() ?? item.id
addGitBulkRange(anchorId, item.id)
return
}
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
toggleGitBulkSelection(item.id)
setGitBulkSelectionAnchorId(item.id)
return
}
clearGitBulkSelection()
setGitBulkSelectionAnchorId(item.id)
void openGitFile(item.id)
}
const submitGitCommit = async () => {
const message = gitCommitMessage().trim()
if (!message || gitCommitSubmitting()) return
setGitCommitSubmitting(true)
try {
await serverApi.commitWorktreeGitChanges(options.instanceId, options.worktreeSlug(), { message })
setGitCommitMessage("")
await loadGitStatus(true)
const nextSelection = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
setGitSelectedItemId(nextSelection)
if (nextSelection) {
await openGitFile(nextSelection)
} else {
clearSelectedGitDiff()
}
showToastNotification({
message: options.t("instanceShell.gitChanges.commit.success"),
variant: "success",
})
} catch (error) {
showToastNotification({
message: error instanceof Error ? error.message : options.t("instanceShell.gitChanges.commit.error"),
variant: "error",
})
} finally {
setGitCommitSubmitting(false)
}
}
const refreshGitStatus = async () => {
await loadGitStatus(true)
const selected = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
setGitSelectedItemId(selected)
if (selected) {
void openGitFile(selected)
} else {
clearSelectedGitDiff()
}
}
const insertGitChangeContext = (item: GitChangeListItem, selection: { startLine: number; endLine: number } | null) => {
const startLine = selection?.startLine ?? 1
const endLine = selection?.endLine ?? startLine
options.promptInputApi()?.insertComment(`Git Diff: File: ${item.path} : ${startLine}-${endLine}`)
}
createEffect(() => {
options.worktreeSlug()
gitStatusRequestVersion += 1
gitDiffRequestVersion += 1
passiveGitRefreshInFlight = false
pendingGitPassiveRefreshOptions = null
setGitStatusEntries(null)
setGitStatusError(null)
setGitStatusLoading(false)
setGitSelectedItemId(null)
clearGitBulkSelection()
setGitSelectedLoading(false)
clearSelectedGitDiff()
setGitCommitMessage("")
setGitCommitSubmitting(false)
})
createEffect(() => {
if (options.rightPanelTab() !== "git-changes") return
const items = gitListItems()
if (gitStatusEntries() === null) return
if (items.length === 0) return
if (gitSelectedItemId()) return
const next = gitMostChangedItemId()
if (!next) return
void openGitFile(next)
})
createEffect(() => {
const activationKey = options.rightPanelTab() === "git-changes" ? `${options.instanceId}:${options.worktreeSlug()}` : null
if (!activationKey) {
previousGitChangesActivationKey = null
return
}
if (previousGitChangesActivationKey === activationKey) return
previousGitChangesActivationKey = activationKey
void passiveRefreshGitStatus()
})
createEffect(() => {
if (options.rightPanelTab() !== "git-changes") return
const unsubscribe = serverEvents.on("instance.event", (event) => {
if (event.type !== "instance.event") return
if (event.instanceId !== options.instanceId) return
const eventType = (event.event as { type?: unknown } | undefined)?.type
if (eventType !== "session.updated" && eventType !== "session.diff") return
void passiveRefreshGitStatus({ forceReloadSelectedDiff: true })
})
onCleanup(() => {
unsubscribe()
})
})
createEffect(() => {
if (options.rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
return {
gitStatusEntries,
gitStatusLoading,
gitStatusError,
gitSelectedItemId,
gitBulkSelectedItemIds,
gitSelectedLoading,
gitSelectedError,
gitSelectedBefore,
gitSelectedAfter,
gitCommitMessage,
gitCommitSubmitting,
gitMostChangedItemId,
setGitCommitMessage,
handleGitRowClick,
refreshGitStatus,
insertGitChangeContext,
submitGitCommit,
stageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "stage"),
unstageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "unstage"),
}
}

View File

@@ -21,10 +21,6 @@ export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"

View File

@@ -638,25 +638,18 @@ export default function MessageSection(props: MessageSectionProps) {
const autoPinHoldTargetKey = createMemo(() => {
if (!holdLongAssistantRepliesEnabled()) return null
const messageId = lastVisibleMessageId()
return isStreamingAssistantTextMessage(messageId) ? messageId : null
return isAssistantTextMessage(messageId) ? messageId : null
})
function toggleHoldLongAssistantReplies() {
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
}
function isStreamingAssistantTextMessage(messageId: string | null | undefined) {
function isAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false
const resolvedStore = store()
const record = resolvedStore.getMessage(messageId)
if (!record || record.role !== "assistant") return false
if (record.status !== "streaming") return false
const info = resolvedStore.getMessageInfo(messageId)
if (!info) return false
const timeInfo = info?.time as { end?: number } | undefined
const isStreaming = timeInfo?.end === undefined || timeInfo.end === 0
if (!isStreaming) return false
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
return orderedParts.some((part) => {

View File

@@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
@@ -38,6 +38,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
const binaries = () => opencodeBinaries()
@@ -138,7 +139,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
async function handleBrowseBinary() {
if (props.disabled) return
setValidationError(null)
if (supportsNativeDialogsInCurrentWindow()) {
if (nativeDialogsAvailable) {
const selected = await openNativeFileDialog({
title: t("opencodeBinarySelector.dialog.title"),
})

View File

@@ -120,11 +120,6 @@ export default function PromptInput(props: PromptInputProps) {
insertQuotedSelection(text)
}
},
insertComment: (text: string) => {
const normalized = (text ?? "").replace(/\r/g, "").trim()
if (!normalized) return
insertBlockContent(`${normalized}\n\n`)
},
expandTextAttachment: (attachmentId: string) => {
const attachment = attachments().find((a) => a.id === attachmentId)
if (!attachment) return
@@ -581,6 +576,113 @@ export default function PromptInput(props: PromptInputProps) {
autoCapitalize="off"
autocomplete="off"
/>
<div class="prompt-nav-buttons">
<div class="prompt-nav-column prompt-nav-column-left">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<Mic class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
</Show>
<Show when={showConversationToggle()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
onClick={() => toggleConversationMode(props.instanceId)}
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
aria-pressed={conversationModeEnabled()}
aria-label={conversationModeButtonTitle()}
title={conversationModeButtonTitle()}
>
<Volume2 class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="prompt-clear-button"
onClick={handleClearPrompt}
disabled={!canClearPrompt()}
aria-label={t("promptInput.clear.ariaLabel")}
title={t("promptInput.clear.title")}
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div class="prompt-nav-column prompt-nav-column-right">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectPreviousHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
@@ -635,116 +737,6 @@ export default function PromptInput(props: PromptInputProps) {
</div>
<div class="prompt-input-actions">
<div class="prompt-nav-buttons">
<div class="prompt-nav-column prompt-nav-column-left">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<Mic class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
</Show>
<Show when={showConversationToggle()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
onClick={() => toggleConversationMode(props.instanceId)}
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
aria-pressed={conversationModeEnabled()}
aria-label={conversationModeButtonTitle()}
title={conversationModeButtonTitle()}
>
<Volume2 class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="prompt-clear-button"
onClick={handleClearPrompt}
disabled={!canClearPrompt()}
aria-label={t("promptInput.clear.ariaLabel")}
title={t("promptInput.clear.title")}
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div class="prompt-nav-column prompt-nav-column-right">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectPreviousHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
</div>
</div>
<div class="prompt-input-primary-actions">
<button
type="button"
class="stop-button"

View File

@@ -7,7 +7,6 @@ export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void
insertComment(text: string): void
expandTextAttachment(attachmentId: string): void
removeAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void

View File

@@ -520,11 +520,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span>
</Show>
<span
class={`status-indicator session-status session-status-list ${statusClassName()} notranslate`}
title={statusTooltip()}
translate="no"
>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()}
</span>
@@ -740,9 +736,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? (
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-primary notranslate" translate="no">
{t("sessionList.header.title")}
</h3>
<h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3>
<KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/>

View File

@@ -36,7 +36,6 @@ interface SessionViewProps {
onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean
isActive?: boolean
registerSessionPromptApi?: (sessionId: string, api: PromptInputApi | null) => void
}
export const SessionView: Component<SessionViewProps> = (props) => {
@@ -150,7 +149,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
function registerPromptInputApi(api: PromptInputApi) {
promptInputApi = api
props.registerSessionPromptApi?.(props.sessionId, api)
if (pendingPromptText) {
api.setPromptText(pendingPromptText, { focus: true })
@@ -165,7 +163,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return () => {
if (promptInputApi === api) {
promptInputApi = null
props.registerSessionPromptApi?.(props.sessionId, null)
}
}
}

View File

@@ -15,33 +15,25 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
import { canOpenRemoteWindows } from "../lib/runtime-env"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
const sections = createMemo(() => {
const items = [
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ 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 sections = createMemo(() => [
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
const renderSection = () => {
switch (activeSettingsSection()) {
case "notifications":
return <NotificationsSettingsSection />
case "remote":
return canOpenRemoteWindows() ? <RemoteAccessSettingsSection /> : <AppearanceSettingsSection />
return <RemoteAccessSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "sidecars":

View File

@@ -17,8 +17,6 @@ interface LspDiagnostic {
range?: LspRange
}
export type DiagnosticsMap = Record<string, LspDiagnostic[] | undefined>
export interface DiagnosticEntry {
id: string
severity: number
@@ -32,7 +30,7 @@ export interface DiagnosticEntry {
column: number
}
export function normalizeDiagnosticPath(path: string) {
function normalizeDiagnosticPath(path: string) {
return path.replace(/\\/g, "/")
}
@@ -55,73 +53,49 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr
const metadata = (state.metadata || {}) as Record<string, unknown>
const input = (state.input || {}) as Record<string, unknown>
const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
if (!diagnosticsMap) return []
return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path].map((value) =>
typeof value === "string" ? value : undefined,
))
}
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find(
(value) => typeof value === "string" && value.length > 0,
) as string | undefined
export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): string | undefined {
if (Object.keys(diagnostics).length === 0) return undefined
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
if (!normalizedPreferred) return []
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
if (candidateEntries.length === 0) return []
const normalizedPreferred = preferredPaths
.filter((value): value is string => typeof value === "string" && value.length > 0)
.map((value) => normalizeDiagnosticPath(value))
const prioritizedEntries = candidateEntries.filter(([path]) => {
const normalized = normalizeDiagnosticPath(path)
return normalized === normalizedPreferred
})
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 []
if (prioritizedEntries.length === 0) return []
const entries: DiagnosticEntry[] = []
const normalizedPath = normalizeDiagnosticPath(key)
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)
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: `${normalizedPath}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedPath,
displayPath: getRelativePath(normalizedPath),
line,
column,
})
for (const [pathKey, list] of prioritizedEntries) {
if (!Array.isArray(list)) continue
const normalizedPath = normalizeDiagnosticPath(pathKey)
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)
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: `${normalizedPath}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedPath,
displayPath: getRelativePath(normalizedPath),
line,
column,
})
}
}
return entries.sort((a, b) => a.severity - b.severity)

View File

@@ -1,14 +1,107 @@
import { For, Show, createMemo } from "solid-js"
import type { ToolRenderer } from "../types"
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics"
import type { DiagnosticEntry } from "../diagnostics"
type LspRangePosition = {
line?: number
character?: number
}
type LspRange = {
start?: LspRangePosition
}
type LspDiagnostic = {
message?: string
severity?: number
range?: LspRange
}
type ApplyPatchFile = {
filePath?: string
relativePath?: string
type?: 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 }) {
@@ -71,7 +164,7 @@ export const applyPatchRenderer: ToolRenderer = {
})
const diagnosticsMap = createMemo(() => {
const value = (payload.metadata as any).diagnostics
return value && typeof value === "object" ? (value as DiagnosticsMap) : {}
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
})
if (files().length === 0) {
@@ -85,9 +178,9 @@ export const applyPatchRenderer: ToolRenderer = {
<For each={files()}>
{(file, index) => {
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
const diffText = typeof file.diff === "string" ? file.diff : ""
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath]))
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
return (
<div class="tool-call-apply-patch-file">

View File

@@ -3,6 +3,7 @@ import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
@@ -161,9 +162,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [activeHoldTargetKey, setActiveHoldTargetKey] = createSignal<string | null>(null)
const [didTriggerHoldForCurrentTarget, setDidTriggerHoldForCurrentTarget] = createSignal(false)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>()
@@ -174,8 +174,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
let lastResetKey: string | number | undefined
let suppressAutoScrollOnce = false
let pendingInitialScroll = true
let lastObservedScrollOffset = 0
let lastObservedPinnedAtBottom = false
const state: VirtualFollowListState = {
autoScroll,
@@ -197,17 +195,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
return performance.now() <= userScrollIntentUntil
}
function clearAutoPinHold(options?: { resumeBottom?: boolean }) {
if (activeHoldTargetKey() === null) return
setActiveHoldTargetKey(null)
if (options?.resumeBottom && autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll() || activeHoldTargetKey() !== null) return
scrollToBottom(false)
})
}
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
@@ -252,40 +239,26 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (!handle || !element) return
const offset = handle.scrollOffset
const scrolledUp = offset < lastObservedScrollOffset - 1
const wasPinnedAtBottom = lastObservedPinnedAtBottom
const scrollHeight = handle.scrollSize
const clientHeight = element.clientHeight
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
lastObservedScrollOffset = offset
const hasItems = props.items().length > 0
setShowScrollBottomButton(hasItems && !atBottom)
setShowScrollTopButton(hasItems && !atTop)
// Keyboard/PageUp scrolls can move the viewport without ever hitting our
// local key intent listeners (for example after dragging the native
// scrollbar). If follow mode stays enabled, the next render notification
// snaps the list straight back to bottom. A real upward viewport move away
// from bottom should always break follow unless a hold target is active.
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && activeHoldTargetKey() === null) {
setAutoScroll(false)
lastObservedPinnedAtBottom = false
return
}
// Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) {
clearAutoPinHold()
if (atBottom && heldItemCount() !== null) {
setHeldItemCount(null)
}
if (atBottom && !autoScroll()) {
setAutoScroll(true)
} else if (!atBottom && autoScroll()) {
setAutoScroll(false)
}
}
lastObservedPinnedAtBottom = autoScroll() && atBottom
}
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
@@ -313,6 +286,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
}
updateScrollButtons()
updateAutoPinHold()
props.onScroll?.()
// Find active key (roughly the first visible item)
@@ -344,14 +318,25 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
function updateAutoPinHold() {
const element = scrollElement()
const itemCount = props.items().length
const heldCount = heldItemCount()
if (!element) return
const targetKey = holdTargetKey()
const heldKey = activeHoldTargetKey()
if (heldCount !== null) {
if (itemCount > heldCount) {
setHeldItemCount(null)
if (autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll()) return
scrollToBottom(false)
})
}
return
}
if (heldKey !== null) {
if (targetKey !== heldKey) {
clearAutoPinHold({ resumeBottom: true })
if (itemCount < heldCount) {
setHeldItemCount(null)
return
}
return
@@ -359,8 +344,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return
const targetKey = holdTargetKey()
if (!targetKey) return
if (didTriggerHoldForCurrentTarget()) return
const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return
@@ -371,13 +357,12 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const relativeTop = targetRect.top - containerRect.top
const exceedsViewport = targetRect.height > element.clientHeight
if (exceedsViewport && relativeTop < 0) {
const alignDelta = relativeTop - holdTargetTopThresholdPx()
if (Math.abs(alignDelta) > 1) {
element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
}
setActiveHoldTargetKey(targetKey)
setDidTriggerHoldForCurrentTarget(true)
if (
exceedsViewport &&
relativeTop <= holdTargetTopThresholdPx() &&
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
) {
setHeldItemCount(itemCount)
}
}
@@ -393,7 +378,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
},
notifyContentRendered: () => {
updateAutoPinHold()
if (activeHoldTargetKey() !== null) return
if (heldItemCount() !== null) return
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true)
}
@@ -409,23 +394,12 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
createEffect(on(() => props.resetKey?.(), () => {
itemElements.clear()
setActiveHoldTargetKey(null)
setDidTriggerHoldForCurrentTarget(false)
lastObservedScrollOffset = 0
lastObservedPinnedAtBottom = false
setHeldItemCount(null)
}))
createEffect(on(holdTargetKey, (nextTargetKey, prevTargetKey) => {
if (nextTargetKey !== prevTargetKey && didTriggerHoldForCurrentTarget()) {
setDidTriggerHoldForCurrentTarget(false)
}
if (activeHoldTargetKey() === null) return
if (nextTargetKey === activeHoldTargetKey()) return
clearAutoPinHold({ resumeBottom: true })
}, { defer: true }))
// Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => {
updateAutoPinHold()
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true))
}
@@ -434,11 +408,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Handle followToken change
createEffect(on(() => props.followToken?.(), () => {
updateAutoPinHold()
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true)
}
}, { defer: true }))
createEffect(on(() => holdTargetKey(), () => {
updateAutoPinHold()
}, { defer: true }))
// Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return
@@ -461,6 +440,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
})
createEffect(() => {
if (typeof window === "undefined") return
const handleResize = () => updateAutoPinHold()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
return (
<div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement)

View File

@@ -26,14 +26,6 @@ type WorktreeOption =
| { kind: "action"; key: "__create__"; label: string }
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
type DeleteErrorKind = "localChanges" | "inUse" | "notFound" | "permissionDenied" | "unknown"
type DeleteErrorDetails = {
summary: string
causeLabel: string
nextStep: string
}
function preventSelectPress(event: PointerEvent | MouseEvent) {
// Prevent Select.Item from treating this as a selection.
// We intentionally prevent default to stop Kobalte's internal press handling.
@@ -72,57 +64,6 @@ function relativePath(fromDir: string, toDir: string): string {
return relParts.join("/") || "."
}
function extractDeleteErrorMessage(input: string): string {
const trimmed = (input ?? "").trim()
if (!trimmed) return ""
try {
const parsed = JSON.parse(trimmed) as { error?: unknown }
if (typeof parsed?.error === "string" && parsed.error.trim()) {
return parsed.error.trim()
}
} catch {
// Fall back to the raw string when the backend returned plain text.
}
return trimmed
}
function classifyDeleteError(message: string): DeleteErrorKind {
const normalized = message.toLowerCase()
if (
normalized.includes("modified or untracked files") ||
normalized.includes("contains modified") ||
normalized.includes("contains untracked") ||
normalized.includes("use --force to delete it")
) {
return "localChanges"
}
if (
normalized.includes("in use") ||
normalized.includes("resource busy") ||
normalized.includes("device or resource busy") ||
normalized.includes("ebusy") ||
normalized.includes("file is being used") ||
normalized.includes("process cannot access the file") ||
normalized.includes("directory not empty")
) {
return "inUse"
}
if (normalized.includes("not found") || normalized.includes("no such file") || normalized.includes("cannot find")) {
return "notFound"
}
if (normalized.includes("permission denied") || normalized.includes("access is denied") || normalized.includes("eperm")) {
return "permissionDenied"
}
return "unknown"
}
interface WorktreeSelectorProps {
instanceId: string
sessionId: string
@@ -139,7 +80,6 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
const [forceDelete, setForceDelete] = createSignal(false)
const [isDeleting, setIsDeleting] = createSignal(false)
const [deleteError, setDeleteError] = createSignal<string | null>(null)
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
const isChildSession = createMemo(() => Boolean(session()?.parentId))
@@ -174,16 +114,10 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
if (opt.slug === "root") return
setForceDelete(false)
setDeleteError(null)
setDeleteTarget(opt)
setDeleteOpen(true)
}
const closeDeleteDialog = () => {
setDeleteOpen(false)
setDeleteError(null)
}
const repoRoot = createMemo(() => {
const list = getWorktrees(props.instanceId)
return list.find((wt) => wt.slug === "root")?.directory ?? ""
@@ -205,89 +139,6 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
}
}
const sanitizeDeleteError = (input: string) => {
let sanitized = (input ?? "").trim()
if (!sanitized) {
return t("instanceShell.worktree.delete.error.fallback")
}
sanitized = sanitized.replace(/[A-Za-z]:[\\/][^\r\n"']+/g, "[path]")
sanitized = sanitized.replace(/\\Users\\[^\\/\r\n]+/gi, "\\Users\\[user]")
sanitized = sanitized.replace(/\/Users\/[^/\r\n]+/g, "/Users/[user]")
sanitized = sanitized.replace(/\/home\/[^/\r\n]+/g, "/home/[user]")
sanitized = sanitized.replace(/([A-Za-z]:[\\/])?Users[\\/][^\\/\r\n]+/gi, "$1Users/[user]")
return sanitized
}
const handleCopyDeleteError = async (mode: "raw" | "sanitized") => {
const raw = deleteError()
if (!raw) return
const text = mode === "sanitized" ? sanitizeDeleteError(raw) : raw
try {
const ok = await copyToClipboard(text)
showToastNotification({
message: ok
? t(mode === "sanitized" ? "instanceShell.worktree.delete.error.copySanitizedSuccess" : "instanceShell.worktree.delete.error.copySuccess")
: t("instanceShell.worktree.delete.error.copyFailure"),
variant: ok ? "success" : "error",
})
} catch (error) {
log.error("Failed to copy delete worktree error", error)
showToastNotification({
message: t("instanceShell.worktree.delete.error.copyFailure"),
variant: "error",
})
}
}
const deleteErrorDetails = createMemo<DeleteErrorDetails | null>(() => {
const raw = deleteError()
if (!raw) return null
const parsed = extractDeleteErrorMessage(raw)
const kind = classifyDeleteError(parsed)
switch (kind) {
case "localChanges":
return {
summary: t("instanceShell.worktree.delete.error.summary.localChanges"),
causeLabel: t("instanceShell.worktree.delete.error.cause.localChanges"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.localChanges"),
}
case "inUse":
return {
summary: t("instanceShell.worktree.delete.error.summary.inUse"),
causeLabel: t("instanceShell.worktree.delete.error.cause.inUse"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.inUse"),
}
case "notFound":
return {
summary: t("instanceShell.worktree.delete.error.summary.notFound"),
causeLabel: t("instanceShell.worktree.delete.error.cause.notFound"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.notFound"),
}
case "permissionDenied":
return {
summary: t("instanceShell.worktree.delete.error.summary.permissionDenied"),
causeLabel: t("instanceShell.worktree.delete.error.cause.permissionDenied"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.permissionDenied"),
}
default:
return {
summary: t("instanceShell.worktree.delete.error.summary.unknown"),
causeLabel: t("instanceShell.worktree.delete.error.cause.unknown"),
nextStep: t("instanceShell.worktree.delete.error.nextStep.unknown"),
}
}
})
const displayDeleteError = createMemo(() => {
const raw = deleteError()
if (!raw) return null
return extractDeleteErrorMessage(raw)
})
const handleChange = async (value: WorktreeOption | null) => {
if (worktreesUnavailable()) return
if (!value) return
@@ -492,23 +343,22 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
</Dialog.Portal>
</Dialog>
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && closeDeleteDialog()}>
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-3 md:p-4">
<Dialog.Content class="modal-surface w-[clamp(640px,45vw,960px)] max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] overflow-y-auto p-4 flex flex-col gap-3">
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">Deletes this branch worktree and its local folder.</Dialog.Description>
<Dialog.Description class="text-sm text-secondary mt-2">Removes the git worktree checkout directory for this branch.</Dialog.Description>
</div>
<Show when={deleteTarget()}>
{(target) => (
<div class="rounded-lg border border-base bg-surface-secondary px-3 py-2">
<p class="text-sm text-primary">
Worktree <span class="font-semibold font-mono">&quot;{target().slug}&quot;</span>
</p>
<p class="text-[11px] text-secondary break-all font-mono leading-5">{target().directory}</p>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
</div>
)}
</Show>
@@ -527,7 +377,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
<button
type="button"
class="selector-button selector-button-secondary"
onClick={closeDeleteDialog}
onClick={() => setDeleteOpen(false)}
disabled={isDeleting()}
>
Cancel
@@ -539,13 +389,12 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
onClick={() => {
const target = deleteTarget()
if (!target) {
closeDeleteDialog()
setDeleteOpen(false)
return
}
void (async () => {
setIsDeleting(true)
setDeleteError(null)
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
await reloadWorktrees(props.instanceId)
await reloadWorktreeMap(props.instanceId)
@@ -554,12 +403,15 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
}
closeDeleteDialog()
setDeleteOpen(false)
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
})()
.catch((error) => {
log.warn("Failed to delete worktree", error)
setDeleteError(error instanceof Error ? error.message : t("instanceShell.worktree.delete.error.fallback"))
showToastNotification({
message: error instanceof Error ? error.message : "Failed to delete worktree",
variant: "error",
})
})
.finally(() => {
setIsDeleting(false)
@@ -569,56 +421,6 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
{isDeleting() ? "Deleting..." : "Delete"}
</button>
</div>
<Show when={displayDeleteError()}>
{(message) => (
<div class="rounded-lg border border-danger bg-danger/10 p-3 flex flex-col gap-2">
<div class="flex flex-col gap-1">
<p class="text-xs font-medium text-danger uppercase tracking-wide">
{t("instanceShell.worktree.delete.error.title")}
</p>
<Show when={deleteErrorDetails()}>
{(details) => (
<>
<p class="text-sm text-primary font-medium">{details().summary}</p>
<p class="text-sm text-secondary">
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.causeLabel")}</span>{" "}
{details().causeLabel}
</p>
<p class="text-sm text-secondary">
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.nextStepLabel")}</span>{" "}
{details().nextStep}
</p>
</>
)}
</Show>
</div>
<pre class="max-h-[40vh] overflow-auto whitespace-pre-wrap break-all rounded border border-danger/30 bg-surface-primary px-3 py-2 text-xs text-primary select-text leading-5">{message()}</pre>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => {
void handleCopyDeleteError("raw")
}}
>
{t("instanceShell.worktree.delete.error.copyRaw")}
</button>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => {
void handleCopyDeleteError("sanitized")
}}
>
{t("instanceShell.worktree.delete.error.copySanitized")}
</button>
</div>
</div>
)}
</Show>
</Dialog.Content>
</div>
</Dialog.Portal>

View File

@@ -12,16 +12,9 @@ import type {
SpeechTranscriptionResponse,
SideCar,
ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
WorktreeGitCommitRequest,
WorktreeGitCommitResponse,
WorktreeGitDiffRequest,
WorktreeGitMutationResponse,
WorktreeGitPathsRequest,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
@@ -33,8 +26,6 @@ import type {
WorktreeListResponse,
WorktreeMap,
WorktreeCreateRequest,
WorktreeGitDiffResponse,
WorktreeGitStatusResponse,
} from "../../../server/src/api-types"
import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger"
@@ -107,25 +98,6 @@ function logHttp(message: string, context?: Record<string, unknown>) {
httpLogger.info(message)
}
async function readErrorMessage(response: Response): Promise<string> {
const text = await response.text()
if (!text) return `Request failed with ${response.status}`
try {
const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }
if (typeof parsed?.error === "string" && parsed.error.trim()) {
return parsed.error
}
if (typeof parsed?.message === "string" && parsed.message.trim()) {
return parsed.message
}
} catch {
// Keep the original body for plain-text responses.
}
return text
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers = normalizeHeaders(init?.headers)
@@ -140,7 +112,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await readErrorMessage(response)
const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`)
}
@@ -169,7 +141,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await readErrorMessage(response)
const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`)
}
@@ -258,15 +230,6 @@ export const serverApi = {
body: JSON.stringify(payload),
})
},
createRemoteProxySession(payload: RemoteProxySessionCreateRequest): Promise<RemoteProxySessionCreateResponse> {
return request<RemoteProxySessionCreateResponse>("/api/remote-proxy/sessions", {
method: "POST",
body: JSON.stringify(payload),
})
},
deleteRemoteProxySession(id: string): Promise<void> {
return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" })
},
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
},
@@ -319,47 +282,6 @@ export const serverApi = {
},
)
},
fetchWorktreeGitStatus(id: string, slug: string): Promise<WorktreeGitStatusResponse> {
return request<WorktreeGitStatusResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-status`,
)
},
fetchWorktreeGitDiff(id: string, slug: string, requestPayload: WorktreeGitDiffRequest): Promise<WorktreeGitDiffResponse> {
const params = new URLSearchParams({ path: requestPayload.path, scope: requestPayload.scope })
if (requestPayload.originalPath) {
params.set("originalPath", requestPayload.originalPath)
}
return request<WorktreeGitDiffResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-diff?${params.toString()}`,
)
},
stageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
return request<WorktreeGitMutationResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-stage`,
{
method: "POST",
body: JSON.stringify(payload),
},
)
},
unstageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
return request<WorktreeGitMutationResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-unstage`,
{
method: "POST",
body: JSON.stringify(payload),
},
)
},
commitWorktreeGitChanges(id: string, slug: string, payload: WorktreeGitCommitRequest): Promise<WorktreeGitCommitResponse> {
return request<WorktreeGitCommitResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-commit`,
{
method: "POST",
body: JSON.stringify(payload),
},
)
},
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)

View File

@@ -7,7 +7,7 @@ import {
normalizeDroppedDirectoryPaths,
supportsDesktopFolderDrop,
} from "../native/desktop-file-drop"
import { isTauriHost } from "../runtime-env"
import { runtimeEnv } from "../runtime-env"
interface UseFolderDropOptions {
enabled: Accessor<boolean>
@@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
const bind: FolderDropBindings = {
onDragEnter(event) {
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
return
}
event.preventDefault()
@@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
setIsActive(true)
},
onDragOver(event) {
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
return
}
event.preventDefault()
@@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
setIsActive(true)
},
onDragLeave(event) {
if (!isSupported || isTauriHost() || !containsFileDrop(event)) {
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
return
}
event.preventDefault()
@@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
return
}
if (isTauriHost()) {
if (runtimeEnv.host === "tauri") {
reset()
return
}

View File

@@ -69,10 +69,5 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Connecting...",
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
"folderSelection.servers.certificateInstall.title": "Install Local Certificate",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continue",
"folderSelection.servers.certificateInstall.cancelLabel": "Cancel",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -131,17 +131,6 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted",
"instanceShell.gitChanges.binaryViewer": "Binary file cannot be displayed",
"instanceShell.gitChanges.sections.staged": "Staged Changes",
"instanceShell.gitChanges.sections.unstaged": "Changes",
"instanceShell.gitChanges.actions.insertContext": "Add to prompt",
"instanceShell.gitChanges.actions.stage": "Stage file",
"instanceShell.gitChanges.actions.unstage": "Unstage file",
"instanceShell.gitChanges.commit.placeholder": "Enter commit message",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "Committing...",
"instanceShell.gitChanges.commit.success": "Commit created successfully",
"instanceShell.gitChanges.commit.error": "Failed to create commit",
"instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
@@ -158,30 +147,6 @@ export const instanceMessages = {
"instanceShell.diff.enableWordWrap": "Enable word wrap",
"instanceShell.diff.disableWordWrap": "Disable word wrap",
"instanceShell.worktree.create": "+ Create worktree",
"instanceShell.worktree.delete.error.title": "Delete failed",
"instanceShell.worktree.delete.error.fallback": "Failed to delete worktree",
"instanceShell.worktree.delete.error.causeLabel": "Likely cause:",
"instanceShell.worktree.delete.error.nextStepLabel": "Suggested next step:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git refused to delete this worktree because it has modified or untracked files.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad could not delete this worktree because something is still using files in the directory.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad could not delete this worktree because the directory or worktree record was not found.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad could not delete this worktree because access to the directory was denied.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad could not delete this worktree.",
"instanceShell.worktree.delete.error.cause.localChanges": "Local changes",
"instanceShell.worktree.delete.error.cause.inUse": "Another process is using this worktree",
"instanceShell.worktree.delete.error.cause.notFound": "The worktree directory or record is missing",
"instanceShell.worktree.delete.error.cause.permissionDenied": "Insufficient filesystem permissions",
"instanceShell.worktree.delete.error.cause.unknown": "The backend returned an unclassified delete error",
"instanceShell.worktree.delete.error.nextStep.localChanges": "Enable Force delete if you want to discard local changes, or clean the worktree and try again.",
"instanceShell.worktree.delete.error.nextStep.inUse": "Close terminals, editors, watchers, or background processes using this worktree and try again.",
"instanceShell.worktree.delete.error.nextStep.notFound": "Refresh worktrees and try again. If it still fails, inspect the worktree path on disk.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Check filesystem permissions and close applications that may be locking this directory, then try again.",
"instanceShell.worktree.delete.error.nextStep.unknown": "Review the raw error below for details, then retry after addressing the reported problem.",
"instanceShell.worktree.delete.error.copyRaw": "Copy error",
"instanceShell.worktree.delete.error.copySanitized": "Copy sanitized",
"instanceShell.worktree.delete.error.copySuccess": "Copied delete error",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Copied sanitized delete error",
"instanceShell.worktree.delete.error.copyFailure": "Failed to copy delete error",
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.",

View File

@@ -69,10 +69,5 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Conectando...",
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
"folderSelection.servers.certificateInstall.cancelLabel": "Cancelar",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -130,17 +130,6 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado",
"instanceShell.gitChanges.binaryViewer": "No se puede mostrar un archivo binario",
"instanceShell.gitChanges.sections.staged": "Cambios preparados",
"instanceShell.gitChanges.sections.unstaged": "Cambios",
"instanceShell.gitChanges.actions.insertContext": "Agregar al prompt",
"instanceShell.gitChanges.actions.stage": "Preparar archivo",
"instanceShell.gitChanges.actions.unstage": "Quitar del área preparada",
"instanceShell.gitChanges.commit.placeholder": "Escribe el mensaje del commit",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "Confirmando...",
"instanceShell.gitChanges.commit.success": "Commit creado correctamente",
"instanceShell.gitChanges.commit.error": "No se pudo crear el commit",
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
@@ -166,30 +155,6 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "Salida",
"instanceShell.backgroundProcesses.actions.stop": "Detener",
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",
"instanceShell.worktree.delete.error.title": "Error al eliminar",
"instanceShell.worktree.delete.error.fallback": "Error al eliminar el worktree",
"instanceShell.worktree.delete.error.causeLabel": "Causa probable:",
"instanceShell.worktree.delete.error.nextStepLabel": "Siguiente paso sugerido:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git rechazo la eliminacion de este worktree porque contiene archivos modificados o sin seguimiento.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad no pudo eliminar este worktree porque algo sigue usando archivos dentro del directorio.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad no pudo eliminar este worktree porque no se encontro el directorio o el registro del worktree.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad no pudo eliminar este worktree porque se denego el acceso al directorio.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad no pudo eliminar este worktree.",
"instanceShell.worktree.delete.error.cause.localChanges": "Cambios locales",
"instanceShell.worktree.delete.error.cause.inUse": "Otro proceso esta usando este worktree",
"instanceShell.worktree.delete.error.cause.notFound": "Falta el directorio o el registro del worktree",
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permisos insuficientes del sistema de archivos",
"instanceShell.worktree.delete.error.cause.unknown": "El backend devolvio un error de eliminacion sin clasificar",
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activa Forzar eliminacion si quieres descartar los cambios locales, o limpia el worktree e intentalo de nuevo.",
"instanceShell.worktree.delete.error.nextStep.inUse": "Cierra terminales, editores, observadores o procesos en segundo plano que usen este worktree y vuelve a intentarlo.",
"instanceShell.worktree.delete.error.nextStep.notFound": "Recarga los worktrees y vuelve a intentarlo. Si sigue fallando, inspecciona la ruta del worktree en disco.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Revisa los permisos del sistema de archivos y cierra aplicaciones que puedan estar bloqueando este directorio, luego vuelve a intentarlo.",
"instanceShell.worktree.delete.error.nextStep.unknown": "Revisa el error sin procesar de abajo para ver los detalles y vuelve a intentarlo despues de corregir el problema indicado.",
"instanceShell.worktree.delete.error.copyRaw": "Copiar error",
"instanceShell.worktree.delete.error.copySanitized": "Copiar saneado",
"instanceShell.worktree.delete.error.copySuccess": "Error de eliminacion copiado",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Error de eliminacion saneado copiado",
"instanceShell.worktree.delete.error.copyFailure": "No se pudo copiar el error de eliminacion",
"versionPill.appWithVersion": "App {version}",
"versionPill.ui": "UI",

View File

@@ -69,10 +69,5 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Connexion...",
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
"folderSelection.servers.certificateInstall.title": "Installer le certificat local",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.",
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
"folderSelection.servers.certificateInstall.cancelLabel": "Annuler",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -130,17 +130,6 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé",
"instanceShell.gitChanges.binaryViewer": "Impossible d'afficher un fichier binaire",
"instanceShell.gitChanges.sections.staged": "Changements indexés",
"instanceShell.gitChanges.sections.unstaged": "Changements",
"instanceShell.gitChanges.actions.insertContext": "Ajouter au prompt",
"instanceShell.gitChanges.actions.stage": "Indexer le fichier",
"instanceShell.gitChanges.actions.unstage": "Retirer de l'index",
"instanceShell.gitChanges.commit.placeholder": "Saisissez le message du commit",
"instanceShell.gitChanges.commit.submit": "Valider",
"instanceShell.gitChanges.commit.submitting": "Validation...",
"instanceShell.gitChanges.commit.success": "Commit créé avec succès",
"instanceShell.gitChanges.commit.error": "Impossible de créer le commit",
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
@@ -166,30 +155,6 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "Sortie",
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",
"instanceShell.worktree.delete.error.title": "Echec de suppression",
"instanceShell.worktree.delete.error.fallback": "Impossible de supprimer le worktree",
"instanceShell.worktree.delete.error.causeLabel": "Cause probable :",
"instanceShell.worktree.delete.error.nextStepLabel": "Etape suivante suggeree :",
"instanceShell.worktree.delete.error.summary.localChanges": "Git a refuse de supprimer ce worktree car il contient des fichiers modifies ou non suivis.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad n'a pas pu supprimer ce worktree car quelque chose utilise encore des fichiers dans ce dossier.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad n'a pas pu supprimer ce worktree car le dossier ou l'enregistrement du worktree est introuvable.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad n'a pas pu supprimer ce worktree car l'acces au dossier a ete refuse.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad n'a pas pu supprimer ce worktree.",
"instanceShell.worktree.delete.error.cause.localChanges": "Modifications locales",
"instanceShell.worktree.delete.error.cause.inUse": "Un autre processus utilise ce worktree",
"instanceShell.worktree.delete.error.cause.notFound": "Le dossier ou l'enregistrement du worktree est manquant",
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permissions du systeme de fichiers insuffisantes",
"instanceShell.worktree.delete.error.cause.unknown": "Le backend a renvoye une erreur de suppression non classee",
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activez la suppression forcee si vous voulez jeter les modifications locales, ou nettoyez le worktree puis reessayez.",
"instanceShell.worktree.delete.error.nextStep.inUse": "Fermez les terminaux, editeurs, observateurs ou processus en arrière-plan qui utilisent ce worktree puis reessayez.",
"instanceShell.worktree.delete.error.nextStep.notFound": "Rechargez les worktrees puis reessayez. Si cela echoue encore, inspectez le chemin du worktree sur le disque.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Verifiez les permissions du systeme de fichiers et fermez les applications qui peuvent verrouiller ce dossier, puis reessayez.",
"instanceShell.worktree.delete.error.nextStep.unknown": "Consultez l'erreur brute ci-dessous pour les details, puis reessayez apres avoir corrige le probleme signale.",
"instanceShell.worktree.delete.error.copyRaw": "Copier l'erreur",
"instanceShell.worktree.delete.error.copySanitized": "Copier la version nettoyee",
"instanceShell.worktree.delete.error.copySuccess": "Erreur de suppression copiee",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Erreur de suppression nettoyee copiee",
"instanceShell.worktree.delete.error.copyFailure": "Impossible de copier l'erreur de suppression",
"versionPill.appWithVersion": "Appli {version}",
"versionPill.ui": "UI",

View File

@@ -69,10 +69,5 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "מתחבר...",
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
"folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.",
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
"folderSelection.servers.certificateInstall.cancelLabel": "ביטול",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -138,17 +138,6 @@ export const instanceMessages = {
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
"instanceShell.gitChanges.binaryViewer": "לא ניתן להציג קובץ בינארי",
"instanceShell.gitChanges.sections.staged": "שינויים שנשמרו ל-staging",
"instanceShell.gitChanges.sections.unstaged": "שינויים",
"instanceShell.gitChanges.actions.insertContext": "הוסף לפרומפט",
"instanceShell.gitChanges.actions.stage": "העבר ל-staging",
"instanceShell.gitChanges.actions.unstage": "הוצא מ-staging",
"instanceShell.gitChanges.commit.placeholder": "הזן הודעת commit",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "מבצע commit...",
"instanceShell.gitChanges.commit.success": "ה-commit נוצר בהצלחה",
"instanceShell.gitChanges.commit.error": "יצירת ה-commit נכשלה",
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
"instanceShell.diff.showFull": "הצג קובץ מלא",
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
@@ -174,30 +163,6 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
"instanceShell.worktree.delete.error.title": "המחיקה נכשלה",
"instanceShell.worktree.delete.error.fallback": "מחיקת ה-worktree נכשלה",
"instanceShell.worktree.delete.error.causeLabel": "סיבה סבירה:",
"instanceShell.worktree.delete.error.nextStepLabel": "השלב הבא המומלץ:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git סירב למחוק את ה-worktree הזה כי יש בו קבצים ששונו או קבצים לא במעקב.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי משהו עדיין משתמש בקבצים שבתיקייה.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי התיקייה או רשומת ה-worktree לא נמצאו.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי הגישה לתיקייה נדחתה.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad לא הצליח למחוק את ה-worktree הזה.",
"instanceShell.worktree.delete.error.cause.localChanges": "שינויים מקומיים",
"instanceShell.worktree.delete.error.cause.inUse": "תהליך אחר משתמש ב-worktree הזה",
"instanceShell.worktree.delete.error.cause.notFound": "תיקיית ה-worktree או הרשומה שלו חסרות",
"instanceShell.worktree.delete.error.cause.permissionDenied": "אין הרשאות מתאימות במערכת הקבצים",
"instanceShell.worktree.delete.error.cause.unknown": "ה-backend החזיר שגיאת מחיקה שלא סווגה",
"instanceShell.worktree.delete.error.nextStep.localChanges": "הפעילו מחיקה בכפייה אם אתם רוצים לזרוק את השינויים המקומיים, או נקו את ה-worktree ונסו שוב.",
"instanceShell.worktree.delete.error.nextStep.inUse": "סגרו טרמינלים, עורכים, watchers או תהליכי רקע שמשתמשים ב-worktree הזה ונסו שוב.",
"instanceShell.worktree.delete.error.nextStep.notFound": "רעננו את רשימת ה-worktrees ונסו שוב. אם זה עדיין נכשל, בדקו את נתיב ה-worktree על הדיסק.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "בדקו את הרשאות מערכת הקבצים וסגרו אפליקציות שעשויות לנעול את התיקייה הזאת, ואז נסו שוב.",
"instanceShell.worktree.delete.error.nextStep.unknown": "עיינו בשגיאה הגולמית למטה לפרטים, ואז נסו שוב אחרי טיפול בבעיה שדווחה.",
"instanceShell.worktree.delete.error.copyRaw": "העתק שגיאה",
"instanceShell.worktree.delete.error.copySanitized": "העתק גרסה מסוננת",
"instanceShell.worktree.delete.error.copySuccess": "שגיאת המחיקה הועתקה",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "שגיאת המחיקה המסוננת הועתקה",
"instanceShell.worktree.delete.error.copyFailure": "העתקת שגיאת המחיקה נכשלה",
"versionPill.appWithVersion": "אפליקציה {version}",
"versionPill.ui": "ממשק",

View File

@@ -69,10 +69,5 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "接続中...",
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
"folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。",
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
"folderSelection.servers.certificateInstall.cancelLabel": "キャンセル",
"folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -130,17 +130,6 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
"instanceShell.gitChanges.deleted": "削除済み",
"instanceShell.gitChanges.binaryViewer": "バイナリファイルは表示できません",
"instanceShell.gitChanges.sections.staged": "ステージ済みの変更",
"instanceShell.gitChanges.sections.unstaged": "変更",
"instanceShell.gitChanges.actions.insertContext": "プロンプトに追加",
"instanceShell.gitChanges.actions.stage": "ファイルをステージ",
"instanceShell.gitChanges.actions.unstage": "ステージ解除",
"instanceShell.gitChanges.commit.placeholder": "コミットメッセージを入力",
"instanceShell.gitChanges.commit.submit": "コミット",
"instanceShell.gitChanges.commit.submitting": "コミット中...",
"instanceShell.gitChanges.commit.success": "コミットを作成しました",
"instanceShell.gitChanges.commit.error": "コミットを作成できませんでした",
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
@@ -166,30 +155,6 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "出力",
"instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "終了",
"instanceShell.worktree.delete.error.title": "削除に失敗しました",
"instanceShell.worktree.delete.error.fallback": "worktree の削除に失敗しました",
"instanceShell.worktree.delete.error.causeLabel": "考えられる原因:",
"instanceShell.worktree.delete.error.nextStepLabel": "推奨される次の手順:",
"instanceShell.worktree.delete.error.summary.localChanges": "この worktree に変更済みまたは未追跡のファイルがあるため、Git が削除を拒否しました。",
"instanceShell.worktree.delete.error.summary.inUse": "このディレクトリ内のファイルがまだ使用中のため、CodeNomad はこの worktree を削除できませんでした。",
"instanceShell.worktree.delete.error.summary.notFound": "ディレクトリまたは worktree レコードが見つからなかったため、CodeNomad はこの worktree を削除できませんでした。",
"instanceShell.worktree.delete.error.summary.permissionDenied": "ディレクトリへのアクセスが拒否されたため、CodeNomad はこの worktree を削除できませんでした。",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad はこの worktree を削除できませんでした。",
"instanceShell.worktree.delete.error.cause.localChanges": "ローカル変更",
"instanceShell.worktree.delete.error.cause.inUse": "別のプロセスがこの worktree を使用中です",
"instanceShell.worktree.delete.error.cause.notFound": "worktree のディレクトリまたは記録が見つかりません",
"instanceShell.worktree.delete.error.cause.permissionDenied": "ファイルシステム権限が不足しています",
"instanceShell.worktree.delete.error.cause.unknown": "バックエンドが分類できない削除エラーを返しました",
"instanceShell.worktree.delete.error.nextStep.localChanges": "ローカル変更を破棄したい場合は Force delete を有効にするか、worktree を整理してから再試行してください。",
"instanceShell.worktree.delete.error.nextStep.inUse": "この worktree を使用している端末、エディタ、watcher、バックグラウンドプロセスを閉じてから再試行してください。",
"instanceShell.worktree.delete.error.nextStep.notFound": "worktree 一覧を更新して再試行してください。まだ失敗する場合は、ディスク上の worktree パスを確認してください。",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "ファイルシステム権限を確認し、このディレクトリをロックしている可能性のあるアプリを閉じてから再試行してください。",
"instanceShell.worktree.delete.error.nextStep.unknown": "下の生エラーで詳細を確認し、報告された問題に対処してから再試行してください。",
"instanceShell.worktree.delete.error.copyRaw": "エラーをコピー",
"instanceShell.worktree.delete.error.copySanitized": "サニタイズ済みをコピー",
"instanceShell.worktree.delete.error.copySuccess": "削除エラーをコピーしました",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "サニタイズ済みの削除エラーをコピーしました",
"instanceShell.worktree.delete.error.copyFailure": "削除エラーをコピーできませんでした",
"versionPill.appWithVersion": "アプリ {version}",
"versionPill.ui": "UI",

View File

@@ -69,10 +69,5 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "Подключение...",
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
"folderSelection.servers.certificateInstall.title": "Установить локальный сертификат",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.",
"folderSelection.servers.certificateInstall.confirmLabel": "Продолжить",
"folderSelection.servers.certificateInstall.cancelLabel": "Отмена",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -130,17 +130,6 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
"instanceShell.gitChanges.deleted": "Удалено",
"instanceShell.gitChanges.binaryViewer": "Невозможно показать бинарный файл",
"instanceShell.gitChanges.sections.staged": "Подготовленные изменения",
"instanceShell.gitChanges.sections.unstaged": "Изменения",
"instanceShell.gitChanges.actions.insertContext": "Добавить в промпт",
"instanceShell.gitChanges.actions.stage": "Подготовить файл",
"instanceShell.gitChanges.actions.unstage": "Убрать из staging",
"instanceShell.gitChanges.commit.placeholder": "Введите сообщение коммита",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "Создание commit...",
"instanceShell.gitChanges.commit.success": "Commit успешно создан",
"instanceShell.gitChanges.commit.error": "Не удалось создать commit",
"instanceShell.filesShell.fileListTitle": "Список файлов",
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
@@ -166,30 +155,6 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "Вывод",
"instanceShell.backgroundProcesses.actions.stop": "Остановить",
"instanceShell.backgroundProcesses.actions.terminate": "Завершить",
"instanceShell.worktree.delete.error.title": "Удаление не удалось",
"instanceShell.worktree.delete.error.fallback": "Не удалось удалить worktree",
"instanceShell.worktree.delete.error.causeLabel": "Вероятная причина:",
"instanceShell.worktree.delete.error.nextStepLabel": "Рекомендуемый следующий шаг:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git отказался удалять этот worktree, потому что в нем есть измененные или неотслеживаемые файлы.",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad не смог удалить этот worktree, потому что что-то все еще использует файлы в каталоге.",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad не смог удалить этот worktree, потому что каталог или запись worktree не найдены.",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad не смог удалить этот worktree, потому что доступ к каталогу был запрещен.",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad не смог удалить этот worktree.",
"instanceShell.worktree.delete.error.cause.localChanges": "Локальные изменения",
"instanceShell.worktree.delete.error.cause.inUse": "Другой процесс использует этот worktree",
"instanceShell.worktree.delete.error.cause.notFound": "Каталог или запись worktree отсутствуют",
"instanceShell.worktree.delete.error.cause.permissionDenied": "Недостаточно прав файловой системы",
"instanceShell.worktree.delete.error.cause.unknown": "Бэкенд вернул неклассифицированную ошибку удаления",
"instanceShell.worktree.delete.error.nextStep.localChanges": "Включите принудительное удаление, если хотите отбросить локальные изменения, либо очистите worktree и попробуйте снова.",
"instanceShell.worktree.delete.error.nextStep.inUse": "Закройте терминалы, редакторы, watcher-процессы или фоновые процессы, использующие этот worktree, и попробуйте снова.",
"instanceShell.worktree.delete.error.nextStep.notFound": "Обновите список worktree и попробуйте снова. Если ошибка сохранится, проверьте путь worktree на диске.",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Проверьте права файловой системы и закройте приложения, которые могут удерживать этот каталог, затем попробуйте снова.",
"instanceShell.worktree.delete.error.nextStep.unknown": "Посмотрите необработанную ошибку ниже, затем попробуйте снова после устранения указанной проблемы.",
"instanceShell.worktree.delete.error.copyRaw": "Копировать ошибку",
"instanceShell.worktree.delete.error.copySanitized": "Копировать обезличенную",
"instanceShell.worktree.delete.error.copySuccess": "Ошибка удаления скопирована",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Обезличенная ошибка удаления скопирована",
"instanceShell.worktree.delete.error.copyFailure": "Не удалось скопировать ошибку удаления",
"versionPill.appWithVersion": "Приложение {version}",
"versionPill.ui": "UI",

View File

@@ -69,10 +69,5 @@ export const folderSelectionMessages = {
"folderSelection.servers.dialog.connecting": "连接中...",
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
"folderSelection.servers.certificateInstall.title": "安装本地证书",
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。",
"folderSelection.servers.certificateInstall.confirmLabel": "继续",
"folderSelection.servers.certificateInstall.cancelLabel": "取消",
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -130,17 +130,6 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
"instanceShell.gitChanges.deleted": "已删除",
"instanceShell.gitChanges.binaryViewer": "无法显示二进制文件",
"instanceShell.gitChanges.sections.staged": "已暂存的更改",
"instanceShell.gitChanges.sections.unstaged": "更改",
"instanceShell.gitChanges.actions.insertContext": "添加到提示词",
"instanceShell.gitChanges.actions.stage": "暂存文件",
"instanceShell.gitChanges.actions.unstage": "取消暂存",
"instanceShell.gitChanges.commit.placeholder": "输入提交信息",
"instanceShell.gitChanges.commit.submit": "提交",
"instanceShell.gitChanges.commit.submitting": "正在提交...",
"instanceShell.gitChanges.commit.success": "提交已成功创建",
"instanceShell.gitChanges.commit.error": "无法创建提交",
"instanceShell.filesShell.fileListTitle": "文件列表",
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
@@ -166,30 +155,6 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.actions.output": "输出",
"instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "终止",
"instanceShell.worktree.delete.error.title": "删除失败",
"instanceShell.worktree.delete.error.fallback": "删除 worktree 失败",
"instanceShell.worktree.delete.error.causeLabel": "可能原因:",
"instanceShell.worktree.delete.error.nextStepLabel": "建议的下一步:",
"instanceShell.worktree.delete.error.summary.localChanges": "Git 拒绝删除这个 worktree因为其中包含已修改或未跟踪的文件。",
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad 无法删除这个 worktree因为目录中的文件仍在被某些进程使用。",
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad 无法删除这个 worktree因为目录或 worktree 记录未找到。",
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad 无法删除这个 worktree因为目录访问被拒绝。",
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad 无法删除这个 worktree。",
"instanceShell.worktree.delete.error.cause.localChanges": "本地更改",
"instanceShell.worktree.delete.error.cause.inUse": "另一个进程正在使用这个 worktree",
"instanceShell.worktree.delete.error.cause.notFound": "worktree 目录或记录缺失",
"instanceShell.worktree.delete.error.cause.permissionDenied": "文件系统权限不足",
"instanceShell.worktree.delete.error.cause.unknown": "后端返回了未分类的删除错误",
"instanceShell.worktree.delete.error.nextStep.localChanges": "如果你想丢弃本地更改,请启用强制删除,或者先清理 worktree 后再重试。",
"instanceShell.worktree.delete.error.nextStep.inUse": "关闭正在使用这个 worktree 的终端、编辑器、watcher 或后台进程,然后再试一次。",
"instanceShell.worktree.delete.error.nextStep.notFound": "刷新 worktree 列表后再试一次。如果仍然失败,请检查磁盘上的 worktree 路径。",
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "检查文件系统权限,并关闭可能锁定此目录的应用程序,然后再试一次。",
"instanceShell.worktree.delete.error.nextStep.unknown": "查看下方原始错误详情,并在处理提示的问题后再次重试。",
"instanceShell.worktree.delete.error.copyRaw": "复制错误",
"instanceShell.worktree.delete.error.copySanitized": "复制脱敏内容",
"instanceShell.worktree.delete.error.copySuccess": "已复制删除错误",
"instanceShell.worktree.delete.error.copySanitizedSuccess": "已复制脱敏后的删除错误",
"instanceShell.worktree.delete.error.copyFailure": "复制删除错误失败",
"versionPill.appWithVersion": "应用 {version}",
"versionPill.ui": "UI",

View File

@@ -1,16 +1,12 @@
import { invoke } from "@tauri-apps/api/core"
import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env"
import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger"
const log = getLogger("actions")
export async function restartCli(): Promise<boolean> {
if (!canRestartCli()) {
return false
}
try {
if (isElectronHost()) {
if (runtimeEnv.host === "electron") {
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
if (api?.restartCli) {
await api.restartCli()
@@ -19,7 +15,7 @@ export async function restartCli(): Promise<boolean> {
return false
}
if (isTauriHost()) {
if (runtimeEnv.host === "tauri") {
if (typeof window.__TAURI__?.core?.invoke === "function") {
await invoke("cli_restart")
return true

View File

@@ -1,6 +1,6 @@
import { listen } from "@tauri-apps/api/event"
import { getLogger } from "../logger"
import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env"
import { runtimeEnv } from "../runtime-env"
const log = getLogger("actions")
@@ -21,7 +21,7 @@ function getFilePath(file: File): string | null {
if (typeof file.path === "string" && file.path.trim().length > 0) {
return file.path
}
if (isElectronHost()) {
if (runtimeEnv.host === "electron") {
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
return electronPath
@@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]>
}
export function supportsDesktopFolderDrop(): boolean {
return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop()
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
}
export function containsFileDrop(event: DragEvent): boolean {
@@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<s
if (uniquePaths.length === 0) {
return []
}
if (isElectronHost()) {
if (runtimeEnv.host === "electron") {
return resolveElectronDirectoryPaths(uniquePaths)
}
return uniquePaths
}
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
if (!isTauriHost()) {
if (runtimeEnv.host !== "tauri") {
return () => {}
}
@@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
}
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
if (!isTauriHost()) {
if (runtimeEnv.host !== "tauri") {
return () => {}
}

View File

@@ -1,4 +1,4 @@
import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env"
import { runtimeEnv } from "../runtime-env"
import type { NativeDialogOptions } from "./types"
import { openElectronNativeDialog } from "./electron/functions"
import { openTauriNativeDialog } from "./tauri/functions"
@@ -6,23 +6,20 @@ import { openTauriNativeDialog } from "./tauri/functions"
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
if (isElectronHost()) {
return openElectronNativeDialog
switch (runtimeEnv.host) {
case "electron":
return openElectronNativeDialog
case "tauri":
return openTauriNativeDialog
default:
return null
}
if (isTauriHost()) {
return openTauriNativeDialog
}
return null
}
export function supportsNativeDialogs(): boolean {
return resolveNativeHandler() !== null
}
export function supportsNativeDialogsInCurrentWindow(): boolean {
return canUseNativeDialogs()
}
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
const handler = resolveNativeHandler()
if (!handler) {

View File

@@ -1,37 +1,23 @@
import { invoke } from "@tauri-apps/api/core"
import type { RemoteServerProfile } from "../../../../server/src/api-types"
import { showConfirmDialog } from "../../stores/alerts"
import { tGlobal } from "../i18n"
import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
import { runtimeEnv } from "../runtime-env"
export interface RemoteWindowOpenPayload {
id: string
name: string
baseUrl: string
entryUrl?: string
proxySessionId?: string
skipTlsVerify: boolean
}
export async function openRemoteServerWindow(
profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">,
entryUrl?: string,
proxySessionId?: string,
): Promise<void> {
if (!canOpenRemoteWindows()) {
throw new Error("Remote server windows can only be opened from a local desktop window")
}
export async function openRemoteServerWindow(profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">): Promise<void> {
const payload: RemoteWindowOpenPayload = {
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
entryUrl,
proxySessionId,
skipTlsVerify: profile.skipTlsVerify,
}
if (isElectronHost()) {
if (runtimeEnv.host === "electron") {
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
if (typeof api?.openRemoteWindow === "function") {
await api.openRemoteWindow(payload)
@@ -39,29 +25,7 @@ export async function openRemoteServerWindow(
}
}
if (isTauriHost()) {
const requiresLocalCertificate =
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")
if (requiresLocalCertificate) {
const needsInstall = await invoke<boolean>("needs_local_certificate_install")
if (needsInstall) {
const accepted = await showConfirmDialog(
tGlobal("folderSelection.servers.certificateInstall.confirmMessage"),
{
title: tGlobal("folderSelection.servers.certificateInstall.title"),
variant: "warning",
confirmLabel: tGlobal("folderSelection.servers.certificateInstall.confirmLabel"),
cancelLabel: tGlobal("folderSelection.servers.certificateInstall.cancelLabel"),
},
)
if (!accepted) {
throw new Error(tGlobal("folderSelection.servers.certificateInstall.cancelled"))
}
}
}
if (runtimeEnv.host === "tauri") {
await invoke("open_remote_window", { payload })
return
}

Some files were not shown because too many files have changed in this diff Show More