Compare commits
98 Commits
v0.13.3-de
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6381934661 | ||
|
|
67a10d12e0 | ||
|
|
68551f6731 | ||
|
|
662a6b94b0 | ||
|
|
77df40169a | ||
|
|
3b411e2e73 | ||
|
|
016c7bda4a | ||
|
|
04fc28c492 | ||
|
|
623a09fd7e | ||
|
|
b00aa7ef84 | ||
|
|
acfa265595 | ||
|
|
35b171764e | ||
|
|
6b53ab2d73 | ||
|
|
1b829094ef | ||
|
|
e28e9f5879 | ||
|
|
cb84547c88 | ||
|
|
e022a158eb | ||
|
|
9d9a6a79ec | ||
|
|
82a7c95dba | ||
|
|
313a0e579e | ||
|
|
a795869064 | ||
|
|
9bf4d351de | ||
|
|
657e78da6a | ||
|
|
dee356558f | ||
|
|
03ed3d3b2c | ||
|
|
a111de1af8 | ||
|
|
8a3b162be9 | ||
|
|
c62cb3ce4a | ||
|
|
d9811e735d | ||
|
|
1ce58b9dd9 | ||
|
|
1907a4da03 | ||
|
|
abf4c67fcc | ||
|
|
bc130ceb5b | ||
|
|
8505a43b16 | ||
|
|
2a3329b5ed | ||
|
|
c9c1cf21f0 | ||
|
|
c7d4f99e48 | ||
|
|
d50c00afb4 | ||
|
|
0ef57df3bc | ||
|
|
0739ec857c | ||
|
|
b060ab45ff | ||
|
|
af6429162f | ||
|
|
2e9ee2cde6 | ||
|
|
d45c0b9367 | ||
|
|
197898c01c | ||
|
|
0c0cfd2d22 | ||
|
|
5107ac207e | ||
|
|
1130066a33 | ||
|
|
403a3ff189 | ||
|
|
7996e514c4 | ||
|
|
141be2cde0 | ||
|
|
259d457209 | ||
|
|
d0a0325d7e | ||
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 | ||
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
278b563c1a | ||
|
|
27bccb8d6b | ||
|
|
153065d025 | ||
|
|
2abda0e6b4 | ||
|
|
800133361d | ||
|
|
034cb5dea9 | ||
|
|
d7ab84f245 | ||
|
|
201988b97c | ||
|
|
6a6fcff2c8 | ||
|
|
f29f197b9a | ||
|
|
dbde403b3e | ||
|
|
230c981cc2 | ||
|
|
34978c87fb | ||
|
|
3e6d0a402c | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
19
.github/workflows/build-and-upload.yml
vendored
19
.github/workflows/build-and-upload.yml
vendored
@@ -53,7 +53,7 @@ on:
|
||||
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; 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; do
|
||||
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
@@ -324,7 +324,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||
path: packages/electron-app/release/*.zip
|
||||
path: |
|
||||
packages/electron-app/release/*.zip
|
||||
packages/electron-app/release/*.AppImage
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -370,7 +372,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-x64@2.10.1 --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
|
||||
@@ -454,7 +456,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-arm64@2.10.1 --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
|
||||
@@ -540,7 +542,7 @@ jobs:
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
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
|
||||
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
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
@@ -612,6 +614,7 @@ jobs:
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
xdg-utils \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
@@ -640,6 +643,7 @@ 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
|
||||
@@ -739,6 +743,7 @@ jobs:
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
xdg-utils \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
libgtk-3-dev:arm64 \
|
||||
|
||||
5
.github/workflows/comment-pr-artifacts.yml
vendored
5
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
@@ -19,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
fi
|
||||
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
14
.github/workflows/manual-npm-publish.yml
vendored
14
.github/workflows/manual-npm-publish.yml
vendored
@@ -46,7 +46,8 @@ jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
PUBLISH_NPM_VERSION: 11.5.1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -59,8 +60,15 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Ensure npm >=11.5.1
|
||||
run: npm install -g npm@latest
|
||||
- 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 "$tool_dir/node_modules/npm/bin" >> "$GITHUB_PATH"
|
||||
"$tool_dir/node_modules/npm/bin/npm-cli.js" --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
7
.github/workflows/pr-build.yml
vendored
7
.github/workflows/pr-build.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
@@ -23,7 +24,7 @@ jobs:
|
||||
allowed: ${{ steps.auth.outputs.allowed }}
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
steps:
|
||||
- name: Check PR authorization
|
||||
@@ -37,11 +38,11 @@ jobs:
|
||||
fi
|
||||
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
||||
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
|
||||
fi
|
||||
|
||||
build:
|
||||
|
||||
2
.github/workflows/release-ui.yml
vendored
2
.github/workflows/release-ui.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
release-ui:
|
||||
|
||||
7
.github/workflows/restrict-non-dev-prs.yml
vendored
7
.github/workflows/restrict-non-dev-prs.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
|
||||
@@ -17,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
steps:
|
||||
@@ -27,7 +28,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
normalized=",${ALLOWED_ACTORS},"
|
||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -50,5 +51,5 @@ jobs:
|
||||
- name: Fail unauthorized PR
|
||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||
run: |
|
||||
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||
exit 1
|
||||
|
||||
2
.github/workflows/reusable-release.yml
vendored
2
.github/workflows/reusable-release.yml
vendored
@@ -39,7 +39,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
|
||||
55
README.md
55
README.md
@@ -18,6 +18,7 @@ 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**
|
||||
@@ -61,6 +62,60 @@ 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`
|
||||
|
||||
1457
package-lock.json
generated
1457
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -30,5 +30,13 @@
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.13.3",
|
||||
"minServerVersion": "0.14.0",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"remote:openWindow",
|
||||
async (
|
||||
_event,
|
||||
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
|
||||
): Promise<{ ok: boolean }> => {
|
||||
const opener = (mainWindow as BrowserWindow & {
|
||||
__codenomadOpenRemoteWindow?: (payload: {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
skipTlsVerify: boolean
|
||||
}) => Promise<void>
|
||||
}).__codenomadOpenRemoteWindow
|
||||
if (!opener) {
|
||||
throw new Error("Remote window opening is not available")
|
||||
}
|
||||
await opener(payload)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"notifications:show",
|
||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
@@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename)
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
function configureDevStoragePaths() {
|
||||
if (app.isPackaged) {
|
||||
return
|
||||
}
|
||||
|
||||
const appName = "CodeNomad"
|
||||
|
||||
try {
|
||||
app.setName(appName)
|
||||
|
||||
const userDataPath = join(app.getPath("appData"), appName)
|
||||
const sessionDataPath = join(userDataPath, "session-data")
|
||||
|
||||
mkdirSync(userDataPath, { recursive: true })
|
||||
mkdirSync(sessionDataPath, { recursive: true })
|
||||
|
||||
app.setPath("userData", userDataPath)
|
||||
app.setPath("sessionData", sessionDataPath)
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to configure dev storage paths", error)
|
||||
}
|
||||
}
|
||||
|
||||
configureDevStoragePaths()
|
||||
|
||||
const cliManager = new CliProcessManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
@@ -21,6 +46,8 @@ let pendingCliUrl: string | null = null
|
||||
let pendingBootstrapToken: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
@@ -91,10 +118,17 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
loader.catch((error) => {
|
||||
console.error("[cli] failed to load loading screen:", error)
|
||||
})
|
||||
|
||||
return loader
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||
const origins = new Set<string>()
|
||||
if (window) {
|
||||
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
|
||||
origins.add(origin)
|
||||
}
|
||||
}
|
||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
@@ -109,13 +143,13 @@ function getAllowedRendererOrigins(): string[] {
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
@@ -128,7 +162,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
@@ -136,13 +170,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
event.preventDefault()
|
||||
handleExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
remoteWindowOrigins.set(window.id, new Set([origin]))
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to store allowed origin", url, error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindowAllowedOrigin(window: BrowserWindow) {
|
||||
remoteWindowOrigins.delete(window.id)
|
||||
}
|
||||
|
||||
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
insecureWindowOrigins.set(window.id, new Set([origin]))
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to store insecure origin", url, error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindowInsecureOrigin(window: BrowserWindow) {
|
||||
insecureWindowOrigins.delete(window.id)
|
||||
}
|
||||
|
||||
function isInsecureOriginAllowed(url: string) {
|
||||
try {
|
||||
const targetOrigin = new URL(url).origin
|
||||
for (const origins of insecureWindowOrigins.values()) {
|
||||
if (origins.has(targetOrigin)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
@@ -204,39 +279,41 @@ function createWindow() {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
additionalArguments: ["--codenomad-window-context=local"],
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
const window = mainWindow
|
||||
|
||||
setupNavigationGuards(window)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
clearWindowAllowedOrigin(window)
|
||||
const loadingReady = loadLoadingScreen(window)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
window.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
createApplicationMenu(window)
|
||||
setupCliIPC(window, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
window.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
showingLoadingScreen = false
|
||||
})
|
||||
|
||||
if (pendingCliUrl) {
|
||||
const url = pendingCliUrl
|
||||
pendingCliUrl = null
|
||||
startCliPreload(url)
|
||||
}
|
||||
return loadingReady
|
||||
}
|
||||
|
||||
function showLoadingScreen(force = false) {
|
||||
@@ -322,13 +399,69 @@ function finalizeCliSwap(url: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const window = mainWindow
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
setWindowAllowedOrigin(window, url)
|
||||
pendingCliUrl = null
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
function buildRemoteWindowTitle(name: string, baseUrl: string) {
|
||||
try {
|
||||
const parsed = new URL(baseUrl)
|
||||
return `${name} - ${parsed.host}`
|
||||
} catch {
|
||||
return `${name} - ${baseUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
|
||||
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
|
||||
}
|
||||
|
||||
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
|
||||
const targetUrl = new URL(payload.baseUrl)
|
||||
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
|
||||
const window = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor: "#1a1a1a",
|
||||
icon: getIconPath(),
|
||||
title,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
additionalArguments: ["--codenomad-window-context=remote"],
|
||||
},
|
||||
})
|
||||
|
||||
setWindowAllowedOrigin(window, targetUrl.toString())
|
||||
if (payload.skipTlsVerify) {
|
||||
addWindowInsecureOrigin(window, targetUrl.toString())
|
||||
}
|
||||
|
||||
setupNavigationGuards(window)
|
||||
window.on("closed", () => {
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
})
|
||||
|
||||
try {
|
||||
await window.loadURL(targetUrl.toString())
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||
let bootstrapExchangeInFlight = false
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
@@ -351,6 +484,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const sessionCookieName = cliManager.getAuthCookieName()
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
@@ -381,14 +515,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
name: sessionCookieName,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
@@ -486,7 +620,8 @@ app.whenReady().then(() => {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startCli()
|
||||
const loadingReady = createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
@@ -503,7 +638,21 @@ app.whenReady().then(() => {
|
||||
}
|
||||
}
|
||||
|
||||
createWindow()
|
||||
void loadingReady.finally(() => {
|
||||
setTimeout(() => {
|
||||
void startCli()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||
if (isInsecureOriginAllowed(url)) {
|
||||
event.preventDefault()
|
||||
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||
callback(true)
|
||||
return
|
||||
}
|
||||
callback(false)
|
||||
})
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
||||
@@ -14,6 +14,7 @@ const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = path.dirname(mainFilename)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
@@ -37,7 +38,7 @@ interface StartOptions {
|
||||
|
||||
interface CliEntryResolution {
|
||||
entry: string
|
||||
runner: "node" | "tsx"
|
||||
runner: "node" | "tsx" | "standalone"
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
@@ -129,6 +130,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
@@ -139,21 +141,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
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)) {
|
||||
const runtimePath = this.resolveShellNodeCommand()
|
||||
const entryPath = this.resolveBundledProdEntry()
|
||||
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
|
||||
const supervisorPath = this.resolveCliSupervisorPath()
|
||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||
const shellTarget = cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
|
||||
const supervisorPayload = JSON.stringify({
|
||||
command: shellCommand.command,
|
||||
args: shellCommand.args,
|
||||
@@ -161,28 +164,33 @@ export class CliProcessManager extends EventEmitter {
|
||||
})
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||
|
||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||
env: shellEnv,
|
||||
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
|
||||
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 }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
if (cliEntry.runner !== "standalone") {
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
}
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
? buildUserShellCommand(
|
||||
`${cliEntry.runner === "standalone" ? "" : "ELECTRON_RUN_AS_NODE=1 "}exec ${
|
||||
cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||
}`,
|
||||
)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
@@ -436,6 +444,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getAuthCookieName(): string {
|
||||
return this.authCookieName
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
@@ -532,7 +544,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--generate-token"]
|
||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
|
||||
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
@@ -556,6 +568,10 @@ 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))
|
||||
@@ -570,6 +586,10 @@ 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] }
|
||||
}
|
||||
@@ -586,9 +606,8 @@ export class CliProcessManager extends EventEmitter {
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
|
||||
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
|
||||
}
|
||||
|
||||
private resolveTsx(): string | null {
|
||||
@@ -628,20 +647,25 @@ export class CliProcessManager extends EventEmitter {
|
||||
return entry
|
||||
}
|
||||
|
||||
private resolveProdEntry(): string {
|
||||
try {
|
||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||
if (existsSync(entry)) {
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
|
||||
throw new Error(`Unable to locate standalone CodeNomad server executable (${executableName}). Run npm run build:standalone --workspace @neuralnomads/codenomad.`)
|
||||
}
|
||||
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
|
||||
}
|
||||
|
||||
private resolveCliSupervisorPath(): string {
|
||||
@@ -659,26 +683,6 @@ 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
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
function resolveWindowContext() {
|
||||
const prefix = "--codenomad-window-context="
|
||||
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
|
||||
const context = arg ? arg.slice(prefix.length) : "local"
|
||||
return context === "remote" ? "remote" : "local"
|
||||
}
|
||||
|
||||
function resolveRuntimeHost(windowContext) {
|
||||
return "electron"
|
||||
}
|
||||
|
||||
const windowContext = resolveWindowContext()
|
||||
|
||||
const localElectronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
@@ -23,6 +36,18 @@ const electronAPI = {
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
const remoteElectronAPI = {
|
||||
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
|
||||
setWakeLock: localElectronAPI.setWakeLock,
|
||||
showNotification: localElectronAPI.showNotification,
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld(
|
||||
"electronAPI",
|
||||
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
|
||||
)
|
||||
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
|
||||
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -62,7 +62,7 @@
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.opencode.client",
|
||||
"appId": "ai.neuralnomads.codenomad.client",
|
||||
"productName": "CodeNomad",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
@@ -147,6 +147,13 @@
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import path, { join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -14,6 +14,46 @@ 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"],
|
||||
@@ -105,6 +145,8 @@ 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,
|
||||
|
||||
@@ -16,6 +16,7 @@ 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}`)
|
||||
@@ -29,6 +30,34 @@ 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
|
||||
@@ -65,6 +94,51 @@ 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 })
|
||||
@@ -121,7 +195,9 @@ function stripNodeModuleBins() {
|
||||
|
||||
async function main() {
|
||||
ensureServerBuild()
|
||||
ensureStandaloneServerBuild()
|
||||
ensureServerDependencies()
|
||||
ensureEsbuildPlatformBinary()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.7"
|
||||
"@opencode-ai/plugin": "1.14.19"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ type BackgroundProcess = {
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
type BackgroundProcessNotificationRequest = {
|
||||
sessionID: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
type BackgroundProcessOptions = {
|
||||
baseDir: string
|
||||
}
|
||||
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
|
||||
args: {
|
||||
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
||||
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
||||
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
|
||||
},
|
||||
async execute(args) {
|
||||
async execute(args, context) {
|
||||
assertCommandWithinBase(args.command, options.baseDir)
|
||||
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
|
||||
? {
|
||||
sessionID: context.sessionID,
|
||||
directory: context.directory,
|
||||
}
|
||||
: undefined
|
||||
const process = await request<BackgroundProcess>("", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: args.title, command: args.command }),
|
||||
body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
|
||||
})
|
||||
|
||||
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -25,16 +26,16 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/reply-from": "^12.6.2",
|
||||
"@fastify/static": "^9.1.1",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fastify": "^5.8.5",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"undici": "^8.1.0",
|
||||
"yaml": "^2.4.2",
|
||||
"yauzl": "^2.10.0",
|
||||
"zod": "^3.23.8"
|
||||
@@ -42,6 +43,7 @@
|
||||
"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",
|
||||
|
||||
99
packages/server/scripts/build-standalone.mjs
Normal file
99
packages/server/scripts/build-standalone.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/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)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "child_process"
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -14,6 +14,67 @@ 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)
|
||||
@@ -58,4 +119,14 @@ 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}`)
|
||||
|
||||
@@ -81,6 +81,55 @@ 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 {
|
||||
@@ -170,6 +219,24 @@ export interface InstanceStreamEvent {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SideCarKind = "port"
|
||||
|
||||
export type SideCarPrefixMode = "strip" | "preserve"
|
||||
|
||||
export type SideCarStatus = "running" | "stopped"
|
||||
|
||||
export interface SideCar {
|
||||
id: string
|
||||
kind: SideCarKind
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: SideCarPrefixMode
|
||||
status: SideCarStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
id: string
|
||||
path: string
|
||||
@@ -244,12 +311,50 @@ export interface VoiceModeStateResponse {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProfile {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
skipTlsVerify: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastConnectedAt?: string
|
||||
}
|
||||
|
||||
export interface RemoteServerProbeRequest {
|
||||
baseUrl: string
|
||||
skipTlsVerify?: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProbeResponse {
|
||||
ok: boolean
|
||||
reachable: boolean
|
||||
normalizedUrl: string
|
||||
skipTlsVerify: boolean
|
||||
requiresAuth: boolean
|
||||
authenticated: boolean
|
||||
error?: string
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionCreateRequest {
|
||||
baseUrl: string
|
||||
skipTlsVerify?: boolean
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionCreateResponse {
|
||||
sessionId: string
|
||||
windowUrl: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "sidecar.updated"
|
||||
| "sidecar.removed"
|
||||
| "storage.configChanged"
|
||||
| "storage.stateChanged"
|
||||
| "instance.dataChanged"
|
||||
@@ -262,6 +367,8 @@ export type WorkspaceEventPayload =
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||
| { type: "sidecar.removed"; sidecarId: string }
|
||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
@@ -328,6 +435,8 @@ export interface ServerMeta {
|
||||
|
||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||
|
||||
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
|
||||
|
||||
export interface BackgroundProcess {
|
||||
id: string
|
||||
workspaceId: string
|
||||
@@ -340,6 +449,8 @@ export interface BackgroundProcess {
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
terminalReason?: BackgroundProcessTerminalReason
|
||||
notifyEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface BackgroundProcessListResponse {
|
||||
|
||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
cookieName?: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore | null
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
private readonly cookieName: string
|
||||
private readonly authEnabled: boolean
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||
|
||||
if (!this.authEnabled) {
|
||||
@@ -102,13 +104,18 @@ export class AuthManager {
|
||||
}
|
||||
|
||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||
return this.getSessionFromHeaders(request.headers)
|
||||
}
|
||||
|
||||
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
|
||||
if (!this.authEnabled) {
|
||||
// When auth is disabled, treat all requests as authenticated.
|
||||
// We still return a stable username so callers can display it.
|
||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers.cookie)
|
||||
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
|
||||
const cookies = parseCookies(cookieHeader)
|
||||
const sessionId = cookies[this.cookieName]
|
||||
const session = this.sessionManager.getSession(sessionId)
|
||||
if (!session) return null
|
||||
@@ -139,6 +146,16 @@ export class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeCookieName(value: string | undefined): string {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return DEFAULT_AUTH_COOKIE_NAME
|
||||
}
|
||||
|
||||
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
|
||||
}
|
||||
|
||||
function resolveAuthFilePath(configPath: string) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
|
||||
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
|
||||
|
||||
const ROOT_DIR = ".codenomad/background_processes"
|
||||
const INDEX_FILE = "index.json"
|
||||
@@ -27,6 +27,31 @@ interface RunningProcess {
|
||||
outputPath: string
|
||||
exitPromise: Promise<void>
|
||||
workspaceId: string
|
||||
completion?: ProcessCompletion
|
||||
}
|
||||
|
||||
interface ProcessCompletion {
|
||||
reason: BackgroundProcessTerminalReason
|
||||
endContext: "normal" | "workspace_cleanup"
|
||||
removeAfterFinalize?: boolean
|
||||
}
|
||||
|
||||
interface BackgroundProcessNotificationState {
|
||||
sessionID: string
|
||||
directory: string
|
||||
sentAt?: string
|
||||
}
|
||||
|
||||
interface PersistedBackgroundProcess extends BackgroundProcess {
|
||||
notify?: BackgroundProcessNotificationState
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
notify?: boolean
|
||||
notification?: {
|
||||
sessionID: string
|
||||
directory: string
|
||||
}
|
||||
}
|
||||
|
||||
export class BackgroundProcessManager {
|
||||
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const enriched = await Promise.all(
|
||||
records.map(async (record) => ({
|
||||
...record,
|
||||
...this.toPublicProcess(record),
|
||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||
})),
|
||||
)
|
||||
return enriched
|
||||
}
|
||||
|
||||
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
|
||||
async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
|
||||
this.killProcessTree(child, "SIGTERM")
|
||||
})
|
||||
|
||||
const record: BackgroundProcess = {
|
||||
|
||||
const record: PersistedBackgroundProcess = {
|
||||
id,
|
||||
workspaceId,
|
||||
title,
|
||||
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
outputSizeBytes: 0,
|
||||
notify: options.notify && options.notification
|
||||
? {
|
||||
sessionID: options.notification.sessionID,
|
||||
directory: options.notification.directory,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
const runningState: RunningProcess = {
|
||||
id,
|
||||
child,
|
||||
outputPath,
|
||||
exitPromise: Promise.resolve(),
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
|
||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||
this.running.delete(id)
|
||||
|
||||
record.status = this.statusFromExit(code)
|
||||
const completion = runningState.completion ?? this.completionFromExit(code)
|
||||
|
||||
record.terminalReason = completion.reason
|
||||
record.status = this.statusFromReason(completion.reason)
|
||||
record.exitCode = code === null ? undefined : code
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
await this.finalizeRecord(workspaceId, record, completion)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
||||
runningState.exitPromise = exitPromise
|
||||
|
||||
this.running.set(id, runningState)
|
||||
|
||||
let lastPublishAt = 0
|
||||
const maybePublishSize = () => {
|
||||
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
return record
|
||||
return this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
running.completion = { reason: "user_stopped", endContext: "normal" }
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
const updated = await this.findProcess(workspaceId, processId)
|
||||
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
if (record.status === "running") {
|
||||
record.status = "stopped"
|
||||
record.terminalReason = "user_stopped"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
|
||||
}
|
||||
|
||||
return record
|
||||
return this.toPublicProcess(record)
|
||||
}
|
||||
|
||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
return
|
||||
}
|
||||
|
||||
await this.removeFromIndex(workspaceId, processId)
|
||||
await this.removeProcessDir(workspaceId, processId)
|
||||
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.removed", properties: { processId } },
|
||||
record.status = "stopped"
|
||||
record.terminalReason = "user_terminated"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.finalizeRecord(workspaceId, record, {
|
||||
reason: "user_terminated",
|
||||
endContext: "normal",
|
||||
removeAfterFinalize: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
|
||||
private async cleanupWorkspace(workspaceId: string) {
|
||||
for (const [, running] of this.running.entries()) {
|
||||
if (running.workspaceId !== workspaceId) continue
|
||||
running.completion = {
|
||||
reason: "user_terminated",
|
||||
endContext: "workspace_cleanup",
|
||||
removeAfterFinalize: true,
|
||||
}
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
|
||||
return args
|
||||
}
|
||||
|
||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||
if (code === null) return "stopped"
|
||||
if (code === 0) return "stopped"
|
||||
return "error"
|
||||
private completionFromExit(code: number | null): ProcessCompletion {
|
||||
if (code === 0) {
|
||||
return { reason: "finished", endContext: "normal" }
|
||||
}
|
||||
|
||||
return { reason: "failed", endContext: "normal" }
|
||||
}
|
||||
|
||||
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
|
||||
if (reason === "failed") return "error"
|
||||
return "stopped"
|
||||
}
|
||||
|
||||
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
||||
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
|
||||
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
||||
}
|
||||
|
||||
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
return records.find((entry) => entry.id === processId) ?? null
|
||||
}
|
||||
|
||||
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
|
||||
private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
if (!existsSync(indexPath)) return []
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
||||
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
||||
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const index = records.findIndex((entry) => entry.id === record.id)
|
||||
if (index >= 0) {
|
||||
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
|
||||
await this.writeIndex(workspaceId, next)
|
||||
}
|
||||
|
||||
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
|
||||
private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
||||
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
|
||||
private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.updated", properties: { process: record } },
|
||||
event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
|
||||
})
|
||||
}
|
||||
|
||||
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
|
||||
return {
|
||||
id: record.id,
|
||||
workspaceId: record.workspaceId,
|
||||
title: record.title,
|
||||
command: record.command,
|
||||
cwd: record.cwd,
|
||||
status: record.status,
|
||||
pid: record.pid,
|
||||
startedAt: record.startedAt,
|
||||
stoppedAt: record.stoppedAt,
|
||||
exitCode: record.exitCode,
|
||||
outputSizeBytes: record.outputSizeBytes,
|
||||
terminalReason: record.terminalReason,
|
||||
notifyEnabled: Boolean(record.notify),
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
|
||||
if (this.shouldSendCompletionPrompt(record, completion)) {
|
||||
try {
|
||||
await this.sendCompletionPrompt(workspaceId, record)
|
||||
if (record.notify) {
|
||||
record.notify.sentAt = new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
|
||||
}
|
||||
}
|
||||
|
||||
if (completion.removeAfterFinalize) {
|
||||
await this.removeFromIndex(workspaceId, record.id)
|
||||
await this.removeProcessDir(workspaceId, record.id)
|
||||
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.removed", properties: { processId: record.id } },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
}
|
||||
|
||||
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
|
||||
if (completion.endContext === "workspace_cleanup") return false
|
||||
if (!record.notify) return false
|
||||
return !record.notify.sentAt
|
||||
}
|
||||
|
||||
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||
const notify = record.notify
|
||||
if (!notify || !record.terminalReason) return
|
||||
|
||||
if (!this.deps.workspaceManager.get(workspaceId)) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
|
||||
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
throw new Error("Workspace instance is not ready")
|
||||
}
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
|
||||
}
|
||||
|
||||
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||
if (authorization) {
|
||||
headers.authorization = authorization
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: this.buildSyntheticCompletionPrompt(record),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Prompt request failed with ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||
const ref = `Background process "${record.title}" (${record.id})`
|
||||
|
||||
switch (record.terminalReason) {
|
||||
case "finished":
|
||||
return `${ref} finished successfully.`
|
||||
case "failed":
|
||||
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
|
||||
case "user_stopped":
|
||||
return `${ref} was stopped by user.`
|
||||
case "user_terminated":
|
||||
return `${ref} was terminated by user.`
|
||||
}
|
||||
|
||||
return `${ref} ended.`
|
||||
}
|
||||
|
||||
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
|
||||
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
|
||||
}
|
||||
|
||||
private escapeTaggedText(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||
const random = randomBytes(3).toString("hex")
|
||||
|
||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: z.boolean().default(false),
|
||||
|
||||
@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
|
||||
this.on("workspace.error", handler)
|
||||
this.on("workspace.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("sidecar.updated", handler)
|
||||
this.on("sidecar.removed", handler)
|
||||
this.on("storage.configChanged", handler)
|
||||
this.on("storage.stateChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("sidecar.updated", handler)
|
||||
this.off("sidecar.removed", handler)
|
||||
this.off("storage.configChanged", handler)
|
||||
this.off("storage.stateChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
|
||||
@@ -19,18 +19,24 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
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"
|
||||
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 = require("../package.json") as { version: string }
|
||||
const packageJson = { version: readServerPackageVersion(import.meta.url) }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
||||
const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url)
|
||||
|
||||
interface CliOptions {
|
||||
host: string
|
||||
@@ -55,6 +61,7 @@ interface CliOptions {
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
authCookieName: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
@@ -100,6 +107,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||
.addOption(
|
||||
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
||||
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
||||
.default(DEFAULT_AUTH_COOKIE_NAME),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
@@ -139,6 +151,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
@@ -185,6 +198,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
authCookieName: parsed.authCookieName,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
@@ -266,6 +280,7 @@ async function main() {
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
cookieName: options.authCookieName,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
@@ -306,6 +321,11 @@ async function main() {
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||
const sidecarManager = new SideCarManager({
|
||||
settings,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "sidecars" }),
|
||||
})
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
@@ -357,12 +377,21 @@ 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,
|
||||
logger: logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||
|
||||
@@ -391,7 +420,12 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
@@ -412,7 +446,12 @@ async function main() {
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
sidecarManager,
|
||||
authManager,
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
@@ -442,18 +481,22 @@ async function main() {
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteAddresses = resolved.userVisible
|
||||
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
if (!remoteUrl) {
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
@@ -464,7 +507,9 @@ async function main() {
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
serverMeta.addresses = remoteAddresses.length
|
||||
? remoteAddresses
|
||||
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
@@ -472,6 +517,16 @@ async function main() {
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (serverMeta.remoteUrl) {
|
||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||
const additionalRemoteUrls = serverMeta.addresses
|
||||
.map((addr) => addr.remoteUrl)
|
||||
.filter((url) => url !== serverMeta.remoteUrl)
|
||||
|
||||
if (additionalRemoteUrls.length > 0) {
|
||||
console.log("Other Accessible URLs:")
|
||||
for (const url of additionalRemoteUrls) {
|
||||
console.log(` - ${url}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.launch) {
|
||||
@@ -495,6 +550,18 @@ async function main() {
|
||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await sidecarManager.shutdown()
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
clientConnectionManager.shutdown()
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Client connection manager shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
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 __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 templateDir = resolveOpencodeTemplateDir(import.meta.url)
|
||||
|
||||
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]
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
|
||||
|
||||
export function getOpencodeConfigDir(): string {
|
||||
if (!existsSync(templateDir)) {
|
||||
|
||||
@@ -19,13 +19,13 @@ export class VoiceModeManager {
|
||||
})
|
||||
}
|
||||
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
|
||||
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||
this.options.logger.debug(
|
||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||
"Ignoring voice mode enable for disconnected client connection",
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const key = getConnectionKey(connection)
|
||||
@@ -44,6 +44,7 @@ export class VoiceModeManager {
|
||||
|
||||
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||
this.publishIfChanged(instanceId)
|
||||
return true
|
||||
}
|
||||
|
||||
syncInstance(instanceId: string): void {
|
||||
@@ -76,7 +77,10 @@ export class VoiceModeManager {
|
||||
this.aggregateByInstance.delete(instanceId)
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
||||
this.options.logger.debug(
|
||||
{ instanceId, enabled },
|
||||
"Broadcasting aggregate voice mode",
|
||||
)
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
79
packages/server/src/runtime-paths.ts
Normal file
79
packages/server/src/runtime-paths.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict"
|
||||
import os from "node:os"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
||||
|
||||
describe("resolveNetworkAddresses", () => {
|
||||
it("preserves interface order among external addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "10.0.0.8", family: 4, internal: false },
|
||||
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.map((entry) => entry.ip),
|
||||
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveRemoteAddresses", () => {
|
||||
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers private LAN addresses over public addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("uses a public address when no private LAN address is available", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
||||
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function usingMockedNetworkInterfaces(
|
||||
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
||||
callback: () => void,
|
||||
) {
|
||||
const original = os.networkInterfaces
|
||||
os.networkInterfaces = (() => ({
|
||||
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
||||
})) as typeof os.networkInterfaces
|
||||
|
||||
try {
|
||||
callback()
|
||||
} finally {
|
||||
os.networkInterfaces = original
|
||||
}
|
||||
}
|
||||
248
packages/server/src/server/__tests__/remote-proxy.test.ts
Normal file
248
packages/server/src/server/__tests__/remote-proxy.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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
|
||||
}
|
||||
@@ -3,11 +3,16 @@ import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
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"
|
||||
@@ -22,6 +27,9 @@ import { registerPluginRoutes } from "./routes/plugin"
|
||||
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"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
@@ -32,6 +40,8 @@ import type { SpeechService } from "../speech/service"
|
||||
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
|
||||
@@ -47,7 +57,12 @@ interface HttpServerDeps {
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
speechService: SpeechService
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
clientConnectionManager: ClientConnectionManager
|
||||
pluginChannel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
remoteProxySessionManager: RemoteProxySessionManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
@@ -176,13 +191,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: deps.logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
@@ -196,14 +204,19 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
publicPagePaths.add("/auth/token")
|
||||
}
|
||||
|
||||
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
||||
const isLoopbackRemoteProxyDelete =
|
||||
request.method === "DELETE" &&
|
||||
pathname.startsWith("/api/remote-proxy/sessions/") &&
|
||||
deps.authManager.isLoopbackRequest(request)
|
||||
|
||||
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
const session = deps.authManager.getSessionFromRequest(request)
|
||||
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
|
||||
if (requiresAuthForApi && !session) {
|
||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||
@@ -262,7 +275,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
registerClient: registerSseClient,
|
||||
logger: sseLogger,
|
||||
connectionManager: clientConnectionManager,
|
||||
connectionManager: deps.clientConnectionManager,
|
||||
})
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
@@ -270,13 +283,22 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
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 })
|
||||
setupSideCarWebSocketProxy(app, {
|
||||
sidecarManager: deps.sidecarManager,
|
||||
authManager: deps.authManager,
|
||||
logger: proxyLogger,
|
||||
})
|
||||
registerPluginRoutes(app, {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
logger: proxyLogger,
|
||||
channel: pluginChannel,
|
||||
voiceModeManager,
|
||||
channel: deps.pluginChannel,
|
||||
voiceModeManager: deps.voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
@@ -342,7 +364,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
clientConnectionManager.shutdown()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
@@ -353,6 +374,68 @@ interface InstanceProxyDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarProxyDeps {
|
||||
sidecarManager: SideCarManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
|
||||
authManager: AuthManager
|
||||
}
|
||||
|
||||
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
|
||||
const proxyBaseHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxySideCarRequest({
|
||||
request,
|
||||
reply,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
logger: deps.logger,
|
||||
pathSuffix: "",
|
||||
})
|
||||
}
|
||||
|
||||
const proxyWildcardHandler = async (
|
||||
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
await proxySideCarRequest({
|
||||
request,
|
||||
reply,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
logger: deps.logger,
|
||||
pathSuffix: request.params["*"] ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
app.all("/sidecars/:id", proxyBaseHandler)
|
||||
app.all("/sidecars/:id/*", proxyWildcardHandler)
|
||||
}
|
||||
|
||||
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
|
||||
app.server.on("upgrade", (request, socket, head) => {
|
||||
const rawUrl = request.url ?? "/"
|
||||
const parsed = parseSideCarUpgradePath(rawUrl)
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
void proxySideCarWebSocketUpgrade({
|
||||
request,
|
||||
socket: socket as Socket,
|
||||
head,
|
||||
sidecarId: parsed.sidecarId,
|
||||
incomingPath: parsed.pathname,
|
||||
search: parsed.search,
|
||||
sidecarManager: deps.sidecarManager,
|
||||
authManager: deps.authManager,
|
||||
logger: deps.logger,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||
app.register(async (instance) => {
|
||||
instance.removeAllContentTypeParsers()
|
||||
@@ -545,57 +628,57 @@ async function proxyWorkspaceRequest(args: {
|
||||
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||
}
|
||||
|
||||
return reply.from(targetUrl, {
|
||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||
if (instanceAuthHeader) {
|
||||
headers.authorization = instanceAuthHeader
|
||||
}
|
||||
const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
|
||||
|
||||
// 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
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
||||
const init: any = {
|
||||
method: request.method,
|
||||
headers,
|
||||
redirect: "manual",
|
||||
}
|
||||
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
const outgoing: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
outgoing[key] = value
|
||||
}
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
const body = toProxyRequestBody(request.body)
|
||||
if (body !== undefined) {
|
||||
init.body = body
|
||||
init.duplex = "half"
|
||||
}
|
||||
}
|
||||
|
||||
// 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>"
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await fetch(targetUrl, init)
|
||||
reply.code(response.status)
|
||||
applyInstanceProxyResponseHeaders(reply, response)
|
||||
|
||||
logger.trace(
|
||||
{
|
||||
workspaceId,
|
||||
method: request.method,
|
||||
targetUrl,
|
||||
worktreeSlug,
|
||||
directory,
|
||||
contentType: request.headers["content-type"],
|
||||
body: bodyToJson(request.body),
|
||||
headers: outgoing,
|
||||
},
|
||||
"Proxy -> OpenCode request",
|
||||
)
|
||||
}
|
||||
if (!response.body || request.method === "HEAD") {
|
||||
reply.send()
|
||||
return
|
||||
}
|
||||
|
||||
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" })
|
||||
}
|
||||
},
|
||||
})
|
||||
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" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||
@@ -689,52 +772,6 @@ 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")
|
||||
@@ -832,8 +869,364 @@ 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 ?? {})) {
|
||||
if (!value || key.toLowerCase() === "host") continue
|
||||
const lower = key.toLowerCase()
|
||||
if (!value || lower === "host" || isHopByHopHeader(lower)) 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
|
||||
sidecarManager: SideCarManager
|
||||
logger: Logger
|
||||
pathSuffix?: string
|
||||
}) {
|
||||
const sidecarId = (args.request.params as { id?: string }).id ?? ""
|
||||
const sidecar = await args.sidecarManager.get(sidecarId)
|
||||
if (!sidecar) {
|
||||
args.reply.code(404).send({ error: "SideCar not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
|
||||
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
|
||||
const pathSuffix = args.pathSuffix ?? ""
|
||||
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
|
||||
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
|
||||
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
|
||||
const targetUrl = `${targetOrigin}${targetPath}`
|
||||
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
|
||||
|
||||
await args.reply.from(targetUrl, {
|
||||
rewriteRequestHeaders: (_originalRequest, headers) =>
|
||||
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
|
||||
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
||||
onError: (reply, { error }) => {
|
||||
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send({ error: "SideCar proxy failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(rawUrl, "http://localhost")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
sidecarId: decodeURIComponent(match[1] ?? ""),
|
||||
pathname: parsed.pathname,
|
||||
search: parsed.search,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function proxySideCarWebSocketUpgrade(args: {
|
||||
request: import("http").IncomingMessage
|
||||
socket: Socket
|
||||
head: Buffer
|
||||
sidecarId: string
|
||||
incomingPath: string
|
||||
search: string
|
||||
sidecarManager: SideCarManager
|
||||
authManager: AuthManager
|
||||
logger: Logger
|
||||
}) {
|
||||
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
|
||||
|
||||
if (!isWebSocketUpgradeRequest(request)) {
|
||||
rejectUpgrade(socket, 400, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
const session = authManager.getSessionFromHeaders(request.headers)
|
||||
if (!session) {
|
||||
rejectUpgrade(socket, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
const sidecar = await sidecarManager.get(sidecarId)
|
||||
if (!sidecar) {
|
||||
rejectUpgrade(socket, 404, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
|
||||
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
|
||||
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
|
||||
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
|
||||
|
||||
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
|
||||
|
||||
const closeBoth = () => {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy()
|
||||
}
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
upstream.once("error", (error) => {
|
||||
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
|
||||
rejectUpgrade(socket, 502, "Bad Gateway")
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
socket.once("error", (error) => {
|
||||
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
upstream.once(readyEvent, () => {
|
||||
try {
|
||||
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
|
||||
if (head.length > 0) {
|
||||
upstream.write(head)
|
||||
}
|
||||
upstream.pipe(socket)
|
||||
socket.pipe(upstream)
|
||||
} catch (error) {
|
||||
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
|
||||
closeBoth()
|
||||
}
|
||||
})
|
||||
|
||||
upstream.once("close", () => {
|
||||
if (!socket.destroyed) {
|
||||
socket.end()
|
||||
}
|
||||
})
|
||||
|
||||
socket.once("close", () => {
|
||||
if (!upstream.destroyed) {
|
||||
upstream.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
|
||||
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
|
||||
if (targetUrl.protocol === "https:") {
|
||||
return {
|
||||
socket: connectTls({
|
||||
host: targetUrl.hostname,
|
||||
port,
|
||||
servername: targetUrl.hostname,
|
||||
}),
|
||||
readyEvent: "secureConnect",
|
||||
}
|
||||
}
|
||||
return {
|
||||
socket: connectTcp(port, targetUrl.hostname),
|
||||
readyEvent: "connect",
|
||||
}
|
||||
}
|
||||
|
||||
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
|
||||
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
|
||||
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
|
||||
const headerLines: string[] = []
|
||||
const rawHeaders = request.rawHeaders ?? []
|
||||
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||
|
||||
for (let index = 0; index < rawHeaders.length; index += 2) {
|
||||
const key = rawHeaders[index]
|
||||
const value = rawHeaders[index + 1]
|
||||
if (!key || value === undefined) continue
|
||||
const lower = key.toLowerCase()
|
||||
if (blockedHeaders.has(lower)) continue
|
||||
if (lower === "origin") {
|
||||
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
|
||||
continue
|
||||
}
|
||||
headerLines.push(`${key}: ${value}\r\n`)
|
||||
}
|
||||
|
||||
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
|
||||
headerLines.push(`Host: ${hostValue}\r\n`)
|
||||
headerLines.push("\r\n")
|
||||
|
||||
return requestLine + headerLines.join("")
|
||||
}
|
||||
|
||||
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
|
||||
const upgrade = request.headers.upgrade
|
||||
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
|
||||
return false
|
||||
}
|
||||
const connection = request.headers.connection
|
||||
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
|
||||
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
|
||||
if (socket.destroyed) {
|
||||
return
|
||||
}
|
||||
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
socket.destroy()
|
||||
}
|
||||
|
||||
function rewriteSideCarResponseHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
sidecarId: string,
|
||||
targetOrigin: string,
|
||||
prefixMode: "strip" | "preserve",
|
||||
) {
|
||||
if (prefixMode === "preserve") {
|
||||
return headers
|
||||
}
|
||||
|
||||
const next = { ...headers }
|
||||
const locationHeader = next.location
|
||||
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
|
||||
if (!location) {
|
||||
return next
|
||||
}
|
||||
|
||||
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
|
||||
|
||||
if (location.startsWith("/")) {
|
||||
next.location = `${publicBase}${location}`
|
||||
return next
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(location)
|
||||
if (parsed.origin === targetOrigin) {
|
||||
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
} catch {
|
||||
// Relative redirects should continue to resolve against the public sidecar path.
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function sanitizeSideCarProxyRequestHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
targetOrigin: string,
|
||||
): Record<string, string | string[] | undefined> {
|
||||
const blockedHeaders = getBlockedSideCarRequestHeaders()
|
||||
const next: Record<string, string | string[] | undefined> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!value) continue
|
||||
if (blockedHeaders.has(key.toLowerCase())) continue
|
||||
next[key] = value
|
||||
}
|
||||
|
||||
next.origin = targetOrigin
|
||||
return next
|
||||
}
|
||||
|
||||
function getBlockedSideCarRequestHeaders(): Set<string> {
|
||||
return new Set([
|
||||
"host",
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export interface ResolvedRemoteAddresses {
|
||||
all: NetworkAddress[]
|
||||
userVisible: NetworkAddress[]
|
||||
primaryRemoteUrl?: string
|
||||
}
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRemoteAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
port: number
|
||||
}): ResolvedRemoteAddresses {
|
||||
const all = resolveNetworkAddresses(args)
|
||||
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
||||
return {
|
||||
all,
|
||||
userVisible,
|
||||
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
||||
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
||||
}
|
||||
|
||||
function getUserVisiblePriority(ip: string): number {
|
||||
if (isPrivateIPv4(ip)) return 0
|
||||
if (isLinkLocalIPv4(ip)) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function isLinkLocalIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
return first === 169 && second === 254
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
|
||||
if (first === 10) return true
|
||||
if (first === 192 && second === 168) return true
|
||||
return first === 172 && second >= 16 && second <= 31
|
||||
}
|
||||
|
||||
function parseIPv4(value: string): number[] | null {
|
||||
if (!isIPv4Address(value)) return null
|
||||
return value.split(".").map((part) => Number(part))
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
|
||||
566
packages/server/src/server/remote-proxy.ts
Normal file
566
packages/server/src/server/remote-proxy.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
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)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -21,21 +22,21 @@ const PasswordSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
})
|
||||
|
||||
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)
|
||||
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
|
||||
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
|
||||
|
||||
let cachedLoginTemplate: string | null = null
|
||||
let cachedTokenTemplate: string | null = null
|
||||
|
||||
function readTemplate(url: URL, cache: string | null): string {
|
||||
function readTemplate(filePath: string, cache: string | null): string {
|
||||
if (cache) return cache
|
||||
const content = fs.readFileSync(url, "utf-8")
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
function getLoginHtml(defaultUsername: string): string {
|
||||
if (!cachedLoginTemplate) {
|
||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
|
||||
}
|
||||
|
||||
const escapedUsername = escapeHtml(defaultUsername)
|
||||
@@ -44,7 +45,7 @@ function getLoginHtml(defaultUsername: string): string {
|
||||
|
||||
function getTokenHtml(): string {
|
||||
if (!cachedTokenTemplate) {
|
||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
|
||||
}
|
||||
|
||||
return cachedTokenTemplate
|
||||
|
||||
@@ -9,6 +9,21 @@ interface RouteDeps {
|
||||
const StartSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
command: z.string().trim().min(1),
|
||||
notify: z.boolean().optional(),
|
||||
notification: z
|
||||
.object({
|
||||
sessionID: z.string().trim().min(1),
|
||||
directory: z.string().trim().min(1),
|
||||
})
|
||||
.optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.notify && !value.notification) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Notification metadata is required when notify is enabled",
|
||||
path: ["notification"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const OutputQuerySchema = z.object({
|
||||
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
|
||||
|
||||
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
||||
const payload = StartSchema.parse(request.body ?? {})
|
||||
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
|
||||
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
|
||||
notify: payload.notify,
|
||||
notification: payload.notification,
|
||||
})
|
||||
reply.code(201)
|
||||
return process
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import { resolveNetworkAddresses } from "../network-addresses"
|
||||
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const localPort = resolveLocalPort(meta)
|
||||
const remote = resolveRemote(meta)
|
||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
localPort,
|
||||
remotePort: remote?.port,
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,11 +66,17 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||
deps.voiceModeManager.setEnabled(
|
||||
const applied = deps.voiceModeManager.setEnabled(
|
||||
request.params.id,
|
||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||
payload.enabled,
|
||||
)
|
||||
|
||||
if (payload.enabled && !applied) {
|
||||
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
|
||||
return
|
||||
}
|
||||
|
||||
return { enabled: payload.enabled }
|
||||
})
|
||||
|
||||
|
||||
54
packages/server/src/server/routes/remote-proxy.ts
Normal file
54
packages/server/src/server/routes/remote-proxy.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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" }
|
||||
}
|
||||
})
|
||||
}
|
||||
166
packages/server/src/server/routes/remote-servers.ts
Normal file
166
packages/server/src/server/routes/remote-servers.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Agent, fetch } from "undici"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { Logger } from "../../logger"
|
||||
import type { RemoteServerProbeResponse } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const ProbeSchema = z.object({
|
||||
baseUrl: z.string().min(1),
|
||||
skipTlsVerify: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.post("/api/remote-servers/probe", async (request, reply) => {
|
||||
try {
|
||||
const body = ProbeSchema.parse(request.body ?? {})
|
||||
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||
} catch (error) {
|
||||
deps.logger.warn({ err: error }, "Failed to probe remote server")
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid request" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl)
|
||||
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||
|
||||
try {
|
||||
const response = await fetch(probeUrl, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: `Remote server returned HTTP ${response.status}`,
|
||||
errorCode: "http_error",
|
||||
}
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { authenticated?: unknown }
|
||||
if (typeof payload?.authenticated !== "boolean") {
|
||||
return {
|
||||
ok: false,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: "Remote server did not return a valid CodeNomad auth response",
|
||||
errorCode: "invalid_server",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: !payload.authenticated,
|
||||
authenticated: payload.authenticated,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = describeProbeError(error)
|
||||
return {
|
||||
ok: false,
|
||||
reachable: false,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: message.message,
|
||||
errorCode: message.code,
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await dispatcher?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(input: string): string {
|
||||
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(/\/+$/, "") || "/"
|
||||
const value = parsed.toString()
|
||||
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function describeProbeError(error: unknown): { code: string; message: string } {
|
||||
const chain = unwrapErrorChain(error)
|
||||
const detailed =
|
||||
chain.find((entry) => {
|
||||
const code = (entry?.code ?? "").toString()
|
||||
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
|
||||
}) ?? chain[0]
|
||||
|
||||
const code = (detailed?.code ?? "").toString()
|
||||
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
|
||||
|
||||
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
|
||||
return {
|
||||
code: "tls_error",
|
||||
message: "Certificate check failed while connecting to the remote server.",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code:
|
||||
code === "ERR_INVALID_URL"
|
||||
? "invalid_url"
|
||||
: code === "ECONNREFUSED"
|
||||
? "connection_refused"
|
||||
: code === "ENOTFOUND"
|
||||
? "dns_error"
|
||||
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
|
||||
? "timeout"
|
||||
: code
|
||||
? code.toLowerCase()
|
||||
: "probe_failed",
|
||||
message: exactMessage || "Failed to connect to the remote server.",
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
|
||||
const results: Array<{ code?: unknown; message?: string }> = []
|
||||
let current: unknown = error
|
||||
const seen = new Set<unknown>()
|
||||
|
||||
while (current && typeof current === "object" && !seen.has(current)) {
|
||||
seen.add(current)
|
||||
const entry = current as { code?: unknown; message?: string; cause?: unknown }
|
||||
results.push({ code: entry.code, message: entry.message })
|
||||
current = entry.cause
|
||||
}
|
||||
|
||||
if (results.length === 0 && error instanceof Error) {
|
||||
results.push({ message: error.message })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||
import { probeBinaryVersion } from "../../workspaces/spawn"
|
||||
import type { SettingsService } from "../../settings/service"
|
||||
import type { Logger } from "../../logger"
|
||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||
|
||||
56
packages/server/src/server/routes/sidecars.ts
Normal file
56
packages/server/src/server/routes/sidecars.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { SideCarManager } from "../../sidecars/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
sidecarManager: SideCarManager
|
||||
}
|
||||
|
||||
const SideCarCreateSchema = z.object({
|
||||
kind: z.literal("port").default("port"),
|
||||
name: z.string().trim().min(1),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
insecure: z.boolean().default(false),
|
||||
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
|
||||
})
|
||||
|
||||
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
|
||||
message: "At least one field is required",
|
||||
})
|
||||
|
||||
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/sidecars", async () => {
|
||||
return { sidecars: await deps.sidecarManager.list() }
|
||||
})
|
||||
|
||||
app.post("/api/sidecars", async (request, reply) => {
|
||||
try {
|
||||
const body = SideCarCreateSchema.parse(request.body ?? {})
|
||||
const sidecar = await deps.sidecarManager.create(body)
|
||||
reply.code(201)
|
||||
return sidecar
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||
try {
|
||||
const body = SideCarUpdateSchema.parse(request.body ?? {})
|
||||
return await deps.sidecarManager.update(request.params.id, body)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
|
||||
}
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
|
||||
const removed = await deps.sidecarManager.delete(request.params.id)
|
||||
if (!removed) {
|
||||
reply.code(404)
|
||||
return { error: "SideCar not found" }
|
||||
}
|
||||
reply.code(204)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
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
|
||||
@@ -23,6 +27,20 @@ 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(),
|
||||
@@ -118,10 +136,138 @@ 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" }
|
||||
|
||||
@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
||||
if (typeof listeningMode === "string") {
|
||||
serverConfig.listeningMode = listeningMode
|
||||
}
|
||||
const logLevel = preferences.logLevel
|
||||
if (typeof logLevel === "string") {
|
||||
serverConfig.logLevel = logLevel
|
||||
}
|
||||
const lastUsedBinary = preferences.lastUsedBinary
|
||||
if (typeof lastUsedBinary === "string") {
|
||||
serverConfig.opencodeBinary = lastUsedBinary
|
||||
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
||||
const moved = new Set([
|
||||
"environmentVariables",
|
||||
"listeningMode",
|
||||
"logLevel",
|
||||
"lastUsedBinary",
|
||||
"modelRecents",
|
||||
"modelFavorites",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Logger } from "../logger"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { ConfigLocation } from "../config/location"
|
||||
import { z } from "zod"
|
||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
const CanonicalLogLevelSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
|
||||
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
|
||||
)
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
|
||||
if (!isPlainObject(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const next: SettingsDoc = { ...value }
|
||||
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
|
||||
if (parsedLogLevel.success) {
|
||||
next.logLevel = parsedLogLevel.data
|
||||
} else if (next.logLevel !== undefined) {
|
||||
next.logLevel = "DEBUG"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
|
||||
if (!isPlainObject(doc)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (!isPlainObject(doc.server)) {
|
||||
return doc
|
||||
}
|
||||
|
||||
return {
|
||||
...doc,
|
||||
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsService {
|
||||
private readonly configStore: YamlDocStore
|
||||
private readonly stateStore: YamlDocStore
|
||||
@@ -23,22 +72,44 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
||||
if (kind !== "config") {
|
||||
return this.stateStore.get()
|
||||
}
|
||||
|
||||
const current = this.configStore.get()
|
||||
const normalized = normalizeConfigDoc(current)
|
||||
if (!isDeepEqual(current, normalized)) {
|
||||
this.configStore.replace(normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
||||
const updated =
|
||||
kind === "config"
|
||||
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||
: this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
}
|
||||
|
||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
||||
if (kind !== "config") {
|
||||
return this.stateStore.getOwner(owner)
|
||||
}
|
||||
|
||||
return owner === "server"
|
||||
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
|
||||
: this.getDoc("config")[owner] as SettingsDoc
|
||||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
const updated =
|
||||
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
||||
kind === "config"
|
||||
? owner === "server"
|
||||
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||
: this.configStore.mergePatchOwner(owner, patch)
|
||||
: this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
256
packages/server/src/sidecars/manager.ts
Normal file
256
packages/server/src/sidecars/manager.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { connect } from "net"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { Logger } from "../logger"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
|
||||
|
||||
interface SideCarManagerOptions {
|
||||
settings: SettingsService
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface SideCarConfigRecord {
|
||||
id: string
|
||||
kind: SideCarKind
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: SideCarPrefixMode
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface SideCarRuntimeRecord {
|
||||
status: SideCarStatus
|
||||
}
|
||||
|
||||
export class SideCarManager {
|
||||
private readonly configs = new Map<string, SideCarConfigRecord>()
|
||||
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
|
||||
|
||||
constructor(private readonly options: SideCarManagerOptions) {
|
||||
for (const record of this.loadConfiguredSideCars()) {
|
||||
this.configs.set(record.id, record)
|
||||
this.runtime.set(record.id, { status: "stopped" })
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
for (const record of this.configs.values()) {
|
||||
void this.refreshPortSideCar(record.id).catch((error) => {
|
||||
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async list(): Promise<SideCar[]> {
|
||||
await this.refreshPortStatuses()
|
||||
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<SideCar | undefined> {
|
||||
if (!this.configs.has(id)) return undefined
|
||||
await this.refreshPortSideCar(id)
|
||||
return this.toSideCar(this.requireConfig(id))
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
kind: SideCarKind
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: SideCarPrefixMode
|
||||
}): Promise<SideCar> {
|
||||
const normalizedName = input.name.trim()
|
||||
const id = this.buildSideCarId(normalizedName)
|
||||
if (this.configs.has(id)) {
|
||||
throw new Error(`SideCar '${id}' already exists`)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const record: SideCarConfigRecord = {
|
||||
id,
|
||||
kind: input.kind,
|
||||
name: normalizedName,
|
||||
port: input.port,
|
||||
insecure: input.insecure,
|
||||
prefixMode: input.prefixMode,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
this.configs.set(record.id, record)
|
||||
this.runtime.set(record.id, { status: "stopped" })
|
||||
this.persistConfigs()
|
||||
await this.refreshPortSideCar(record.id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
name: string
|
||||
port: number
|
||||
insecure: boolean
|
||||
prefixMode: SideCarPrefixMode
|
||||
}>,
|
||||
): Promise<SideCar> {
|
||||
const record = this.requireConfig(id)
|
||||
|
||||
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||
record.port = typeof input.port === "number" ? input.port : record.port
|
||||
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||
record.updatedAt = new Date().toISOString()
|
||||
|
||||
this.persistConfigs()
|
||||
await this.refreshPortSideCar(id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const record = this.configs.get(id)
|
||||
if (!record) return false
|
||||
|
||||
this.configs.delete(id)
|
||||
this.runtime.delete(id)
|
||||
this.persistConfigs()
|
||||
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||
return true
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
return
|
||||
}
|
||||
|
||||
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
|
||||
const protocol = sidecar.insecure ? "http" : "https"
|
||||
return `${protocol}://127.0.0.1:${sidecar.port}`
|
||||
}
|
||||
|
||||
buildProxyBasePath(id: string): string {
|
||||
return `/sidecars/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
buildTargetPath(id: string, incomingPath: string, search = ""): string {
|
||||
const record = this.requireConfig(id)
|
||||
const publicBase = this.buildProxyBasePath(id)
|
||||
const normalizedPath = incomingPath || publicBase
|
||||
|
||||
if (record.prefixMode === "preserve") {
|
||||
return `${normalizedPath}${search}`
|
||||
}
|
||||
|
||||
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
|
||||
if (!stripped || stripped === "/") {
|
||||
stripped = "/"
|
||||
} else if (!stripped.startsWith("/")) {
|
||||
stripped = `/${stripped}`
|
||||
}
|
||||
return `${stripped}${search}`
|
||||
}
|
||||
|
||||
private async refreshPortStatuses() {
|
||||
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
|
||||
}
|
||||
|
||||
private async refreshPortSideCar(id: string) {
|
||||
const record = this.configs.get(id)
|
||||
if (!record) return
|
||||
const isAvailable = await this.isPortAvailable(record.port)
|
||||
const current = this.runtime.get(id)
|
||||
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
|
||||
if (current?.status === nextStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
this.runtime.set(id, { status: nextStatus })
|
||||
record.updatedAt = new Date().toISOString()
|
||||
this.publish(id)
|
||||
}
|
||||
|
||||
private publish(id: string) {
|
||||
const record = this.configs.get(id)
|
||||
if (!record) return
|
||||
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
|
||||
}
|
||||
|
||||
private toSideCar(record: SideCarConfigRecord): SideCar {
|
||||
const runtime = this.runtime.get(record.id)
|
||||
return {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
name: record.name,
|
||||
port: record.port,
|
||||
insecure: record.insecure,
|
||||
prefixMode: record.prefixMode,
|
||||
status: runtime?.status ?? "stopped",
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
private requireConfig(id: string): SideCarConfigRecord {
|
||||
const record = this.configs.get(id)
|
||||
if (!record) {
|
||||
throw new Error("SideCar not found")
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
private persistConfigs() {
|
||||
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
|
||||
this.options.settings.mergePatchOwner("config", "server", { sidecars })
|
||||
}
|
||||
|
||||
private loadConfiguredSideCars(): SideCarConfigRecord[] {
|
||||
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
|
||||
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
|
||||
const records: SideCarConfigRecord[] = []
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const record = item as Record<string, unknown>
|
||||
const kind = record.kind === "port" ? "port" : null
|
||||
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
|
||||
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
|
||||
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
|
||||
if (!kind || !id || !name || !port) continue
|
||||
|
||||
const insecure = record.insecure === true
|
||||
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
|
||||
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
|
||||
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
|
||||
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||
socket.end()
|
||||
resolve(true)
|
||||
})
|
||||
socket.once("error", () => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private buildSideCarId(name: string): string {
|
||||
const normalized = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
|
||||
if (!normalized) {
|
||||
throw new Error("SideCar name must include letters or numbers")
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
|
||||
|
||||
describe("parseWslUncPath", () => {
|
||||
it("parses WSL UNC paths into distro and linux path", () => {
|
||||
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
|
||||
distro: "Ubuntu",
|
||||
linuxPath: "/home/dev/.opencode/bin/opencode",
|
||||
})
|
||||
})
|
||||
|
||||
it("supports the legacy wsl$ UNC prefix", () => {
|
||||
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
|
||||
distro: "Ubuntu",
|
||||
linuxPath: "/home/dev",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveWslWorkingDirectory", () => {
|
||||
it("keeps WSL workspace folders in the same distro", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects WSL workspace folders from a different distro", () => {
|
||||
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildWindowsSpawnSpec", () => {
|
||||
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve", "--port", "0"],
|
||||
{
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||
CODENOMAD_INSTANCE_ID: "workspace-123",
|
||||
OPENCODE_SERVER_PASSWORD: "secret",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--cd",
|
||||
"/home/dev/workspace",
|
||||
"--exec",
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
"0",
|
||||
])
|
||||
assert.equal(spec.cwd, undefined)
|
||||
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
|
||||
})
|
||||
|
||||
it("upgrades existing WSLENV path entries to include /p", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
|
||||
})
|
||||
|
||||
it("propagates inherited known path variables even when they are not explicitly requested", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
env: {
|
||||
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
|
||||
})
|
||||
|
||||
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve", "--port", "0"],
|
||||
{
|
||||
cwd: String.raw`C:\Users\dev\workspace`,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||
"codenomad-wsl-launch",
|
||||
String.raw`C:\Users\dev\workspace`,
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
"0",
|
||||
])
|
||||
})
|
||||
|
||||
it("uses wslpath for UNC network workspace folders", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
cwd: String.raw`\\server\share\workspace`,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||
"codenomad-wsl-launch",
|
||||
String.raw`\\server\share\workspace`,
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
])
|
||||
})
|
||||
|
||||
it("can wrap WSL launches to emit the Linux PID marker", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||
wslPidMarker: "__CODENOMAD_WSL_PID__:",
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
|
||||
"codenomad-wsl-launch",
|
||||
"/home/dev/workspace",
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
])
|
||||
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
|
||||
})
|
||||
|
||||
it("builds the WSL kill command for tracked Linux PIDs", () => {
|
||||
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
|
||||
})
|
||||
})
|
||||
121
packages/server/src/workspaces/git-mutations.ts
Normal file
121
packages/server/src/workspaces/git-mutations.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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 } : {}
|
||||
}
|
||||
385
packages/server/src/workspaces/git-status.ts
Normal file
385
packages/server/src/workspaces/git-status.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ 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"] })
|
||||
@@ -38,6 +42,9 @@ 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 }
|
||||
@@ -49,6 +56,11 @@ 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/)
|
||||
@@ -90,15 +102,22 @@ 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"])
|
||||
|
||||
@@ -21,6 +21,70 @@ 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
|
||||
@@ -142,12 +206,15 @@ export class WorkspaceManager {
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
const logLevel = (serverConfig as any)?.logLevel
|
||||
|
||||
try {
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
logLevel,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
@@ -263,6 +330,12 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -4,100 +4,10 @@ import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
|
||||
|
||||
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> = {}
|
||||
@@ -116,6 +26,7 @@ interface LaunchOptions {
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
logLevel?: string
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
@@ -129,6 +40,10 @@ export interface ProcessExitInfo {
|
||||
interface ManagedProcess {
|
||||
child: ChildProcess
|
||||
requestedStop: boolean
|
||||
wsl?: {
|
||||
distro: string
|
||||
linuxPid: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
@@ -139,7 +54,8 @@ export class WorkspaceRuntime {
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
@@ -165,7 +81,13 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||
const propagatedEnvKeys = Object.keys(options.environment ?? {})
|
||||
const spec = buildSpawnSpec(options.binaryPath, args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
propagateEnvKeys: propagatedEnvKeys,
|
||||
wslPidMarker: WSL_PID_MARKER,
|
||||
})
|
||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||
this.logger.info(
|
||||
{
|
||||
@@ -195,14 +117,18 @@ export class WorkspaceRuntime {
|
||||
)
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached,
|
||||
...spec.options,
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
const managed: ManagedProcess = {
|
||||
child,
|
||||
requestedStop: false,
|
||||
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
|
||||
}
|
||||
this.processes.set(options.workspaceId, managed)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
@@ -282,6 +208,15 @@ 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()
|
||||
@@ -396,11 +331,44 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
const trySignalWslProcess = (signal: NodeJS.Signals) => {
|
||||
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
|
||||
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("no such process") || combined.includes("not found")) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
|
||||
"WSL kill failed",
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
// Best-effort: terminate the whole process tree rooted at pid.
|
||||
// Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
// 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")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
307
packages/server/src/workspaces/spawn.ts
Normal file
307
packages/server/src/workspaces/spawn.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||
|
||||
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
||||
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
|
||||
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
|
||||
|
||||
export interface SpawnSpec {
|
||||
command: string
|
||||
args: string[]
|
||||
options: {
|
||||
windowsVerbatimArguments?: boolean
|
||||
}
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
wsl?: {
|
||||
distro: string
|
||||
pidMarker?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildSpawnSpecOptions {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
propagateEnvKeys?: string[]
|
||||
wslPidMarker?: string
|
||||
}
|
||||
|
||||
interface WslPath {
|
||||
distro: string
|
||||
linuxPath: string
|
||||
}
|
||||
|
||||
export type WslWorkingDirectory =
|
||||
| { kind: "linux"; path: string }
|
||||
| { kind: "windows"; path: string }
|
||||
|
||||
export function parseWslUncPath(input: string): WslPath | null {
|
||||
const normalized = input.trim().replace(/\//g, "\\")
|
||||
const match = normalized.match(WSL_UNC_PATH_REGEX)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const distro = match[1] ?? ""
|
||||
const remainder = match[2] ?? ""
|
||||
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
|
||||
|
||||
return {
|
||||
distro,
|
||||
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
|
||||
const wslFolder = parseWslUncPath(folder)
|
||||
if (wslFolder) {
|
||||
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
|
||||
}
|
||||
|
||||
const windowsFolder = normalizeWindowsPath(folder)
|
||||
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
|
||||
}
|
||||
|
||||
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||
const wslPath = parseWslUncPath(binaryPath)
|
||||
if (wslPath) {
|
||||
return buildWslSpawnSpec(wslPath, args, options)
|
||||
}
|
||||
|
||||
const extension = path.extname(binaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
// cmd.exe requires the full command as a single string.
|
||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||
|
||||
return {
|
||||
command: comspec,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
options: { windowsVerbatimArguments: true },
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||
// powershell.exe ships with Windows. (pwsh may not.)
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: binaryPath,
|
||||
args,
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||
if (process.platform !== "win32") {
|
||||
return {
|
||||
command: binaryPath,
|
||||
args,
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
return buildWindowsSpawnSpec(binaryPath, args, options)
|
||||
}
|
||||
|
||||
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
|
||||
return {
|
||||
command: "wsl.exe",
|
||||
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
|
||||
options: {},
|
||||
wsl: { distro },
|
||||
}
|
||||
}
|
||||
|
||||
export function probeBinaryVersion(binaryPath: string): {
|
||||
valid: boolean
|
||||
version?: string
|
||||
reported?: string
|
||||
error?: string
|
||||
} {
|
||||
if (!binaryPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||
const result = spawnSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { valid: false, error: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim()
|
||||
const stdout = result.stdout?.trim()
|
||||
const combined = stderr || stdout
|
||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||
return { valid: false, error }
|
||||
}
|
||||
|
||||
const stdoutLines = String(result.stdout ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
const stderrLines = String(result.stderr ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||
if (!reported) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const versionMatch = reported.match(VERSION_REGEX)
|
||||
const version = versionMatch?.[1]
|
||||
return { valid: true, version, reported }
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
|
||||
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
|
||||
if (options.cwd && !workingDirectory) {
|
||||
throw new Error(
|
||||
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
|
||||
)
|
||||
}
|
||||
|
||||
const wslArgs = ["--distribution", wslPath.distro]
|
||||
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
|
||||
|
||||
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
|
||||
wslArgs.push("--cd", workingDirectory.path)
|
||||
}
|
||||
|
||||
if (shouldWrapWithShell) {
|
||||
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
|
||||
wslArgs.push(
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
launchScript,
|
||||
"codenomad-wsl-launch",
|
||||
)
|
||||
if (workingDirectory) {
|
||||
wslArgs.push(workingDirectory.path)
|
||||
}
|
||||
wslArgs.push(
|
||||
wslPath.linuxPath,
|
||||
...args,
|
||||
)
|
||||
} else {
|
||||
wslArgs.push("--exec", wslPath.linuxPath, ...args)
|
||||
}
|
||||
|
||||
return {
|
||||
command: "wsl.exe",
|
||||
args: wslArgs,
|
||||
options: {},
|
||||
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
|
||||
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
|
||||
const steps: string[] = []
|
||||
|
||||
if (pidMarker) {
|
||||
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
|
||||
}
|
||||
|
||||
if (workingDirectory?.kind === "linux") {
|
||||
steps.push('cd "$1"')
|
||||
steps.push("shift")
|
||||
} else if (workingDirectory?.kind === "windows") {
|
||||
steps.push('cd "$(wslpath -au "$1")"')
|
||||
steps.push("shift")
|
||||
}
|
||||
|
||||
steps.push('exec "$@"')
|
||||
return steps.join(" && ")
|
||||
}
|
||||
|
||||
function normalizeWindowsPath(input: string): string | null {
|
||||
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
|
||||
if (!env) {
|
||||
return env
|
||||
}
|
||||
|
||||
const keysToPropagate = Array.from(
|
||||
new Set([
|
||||
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
|
||||
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
|
||||
]),
|
||||
)
|
||||
if (keysToPropagate.length === 0) {
|
||||
return env
|
||||
}
|
||||
|
||||
const next = { ...env }
|
||||
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
|
||||
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
|
||||
|
||||
for (const key of keysToPropagate) {
|
||||
const existingEntry = byName.get(key)
|
||||
if (existingEntry) {
|
||||
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
|
||||
continue
|
||||
}
|
||||
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
|
||||
}
|
||||
|
||||
next.WSLENV = Array.from(byName.values()).join(":")
|
||||
return next
|
||||
}
|
||||
|
||||
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
|
||||
if (!requiresPathTranslation) {
|
||||
return entry
|
||||
}
|
||||
|
||||
const [name, rawFlags = ""] = entry.split("/")
|
||||
if (rawFlags.includes("p")) {
|
||||
return entry
|
||||
}
|
||||
|
||||
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
|
||||
}
|
||||
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
99
packages/server/src/workspaces/worktree-directory.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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
|
||||
}
|
||||
370
packages/tauri-app/Cargo.lock
generated
370
packages/tauri-app/Cargo.lock
generated
@@ -213,6 +213,28 @@ 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"
|
||||
@@ -408,6 +430,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -444,6 +468,12 @@ 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"
|
||||
@@ -456,17 +486,28 @@ 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.13.3"
|
||||
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",
|
||||
@@ -476,8 +517,8 @@ dependencies = [
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"which",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -969,6 +1010,15 @@ 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"
|
||||
@@ -1139,6 +1189,12 @@ 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"
|
||||
@@ -1379,8 +1435,10 @@ 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]]
|
||||
@@ -1390,9 +1448,11 @@ 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]]
|
||||
@@ -1574,6 +1634,25 @@ 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"
|
||||
@@ -1699,6 +1778,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1710,6 +1790,23 @@ 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"
|
||||
@@ -1999,6 +2096,16 @@ 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"
|
||||
@@ -2157,6 +2264,12 @@ 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"
|
||||
@@ -2995,6 +3108,61 @@ 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"
|
||||
@@ -3212,6 +3380,50 @@ 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"
|
||||
@@ -3242,7 +3454,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3270,6 +3482,20 @@ 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"
|
||||
@@ -3311,6 +3537,44 @@ 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"
|
||||
@@ -3531,6 +3795,18 @@ 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"
|
||||
@@ -3792,6 +4068,12 @@ 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"
|
||||
@@ -3943,7 +4225,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -4367,6 +4649,21 @@ 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"
|
||||
@@ -4381,6 +4678,16 @@ 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"
|
||||
@@ -4691,6 +4998,12 @@ 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"
|
||||
@@ -4902,6 +5215,19 @@ 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"
|
||||
@@ -4937,6 +5263,16 @@ 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"
|
||||
@@ -4993,6 +5329,15 @@ 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"
|
||||
@@ -5286,6 +5631,15 @@ 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"
|
||||
@@ -5927,6 +6281,12 @@ 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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -14,6 +14,6 @@
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
"@tauri-apps/cli": "^2.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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,
|
||||
@@ -37,6 +38,12 @@ 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() {
|
||||
@@ -71,6 +78,15 @@ 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)) {
|
||||
@@ -98,7 +114,7 @@ function syncServerUiBundle() {
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,15 +127,19 @@ function ensureServerDevDependencies() {
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring server production dependencies...")
|
||||
execSync(serverInstallCommand, {
|
||||
console.log("[prebuild] pruning server to production dependencies...")
|
||||
execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
|
||||
cwd: serverRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (!fs.existsSync(braceExpansionPath)) {
|
||||
console.log("[prebuild] restoring missing server production dependencies...")
|
||||
execSync(serverInstallCommand, {
|
||||
cwd: serverRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUiDevDependencies() {
|
||||
@@ -142,6 +162,7 @@ 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",
|
||||
}
|
||||
|
||||
@@ -171,6 +192,47 @@ 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 })
|
||||
@@ -249,8 +311,10 @@ function copyUiLoadingAssets() {
|
||||
ensureUiDevDependencies()
|
||||
await ensureMonacoAssets()
|
||||
ensureRollupPlatformBinary()
|
||||
ensureServerDependencies()
|
||||
ensureEsbuildPlatformBinary()
|
||||
ensureServerBuild()
|
||||
ensureStandaloneServerBuild()
|
||||
ensureServerDependencies()
|
||||
ensureUiBuild()
|
||||
syncServerUiBundle()
|
||||
copyServerArtifacts()
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.13.3"
|
||||
version = "0.14.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
tauri-build = { version = "2.5.6", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
tauri = { version = "2.10.1", 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"
|
||||
@@ -28,4 +29,7 @@ url = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -2378,6 +2378,72 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
2807
packages/tauri-app/src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
packages/tauri-app/src-tauri/icons/linux/128x128.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/256x256.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/32x32.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/48x48.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/48x48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/512x512.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/64x64.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/64x64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Categories=
|
||||
Exec=codenomad-tauri
|
||||
StartupWMClass=codenomad-tauri
|
||||
Icon=codenomad-tauri
|
||||
Name=CodeNomad
|
||||
NoDisplay=true
|
||||
Terminal=false
|
||||
Type=Application
|
||||
449
packages/tauri-app/src-tauri/src/cert_manager.rs
Normal file
449
packages/tauri-app/src-tauri/src/cert_manager.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
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}"))
|
||||
}
|
||||
@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
#[cfg(windows)]
|
||||
use std::ffi::c_void;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
#[cfg(windows)]
|
||||
use std::mem::{size_of, zeroed};
|
||||
use std::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -16,14 +20,98 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:";
|
||||
|
||||
#[cfg(windows)]
|
||||
#[derive(Debug)]
|
||||
struct WindowsJobObject {
|
||||
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
|
||||
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
|
||||
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl WindowsJobObject {
|
||||
fn create() -> anyhow::Result<Self> {
|
||||
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
|
||||
if handle.is_null() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"CreateJobObjectW failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
let ok = unsafe {
|
||||
SetInformationJobObject(
|
||||
handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&mut info as *mut _ as *mut c_void,
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
unsafe {
|
||||
CloseHandle(handle);
|
||||
}
|
||||
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
|
||||
}
|
||||
|
||||
Ok(Self { handle })
|
||||
}
|
||||
|
||||
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
|
||||
let process_handle = child.as_raw_handle() as HANDLE;
|
||||
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"AssignProcessToJobObject failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for WindowsJobObject {
|
||||
fn drop(&mut self) {
|
||||
if !self.handle.is_null() {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe impl Send for WindowsJobObject {}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe impl Sync for WindowsJobObject {}
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
@@ -48,7 +136,11 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
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;
|
||||
#[cfg(windows)]
|
||||
@@ -124,7 +216,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||
fn exchange_bootstrap_token(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
cookie_name: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
@@ -159,11 +255,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
for line in lines {
|
||||
// handle case-insensitive header name
|
||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
}
|
||||
@@ -172,11 +268,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||
fn set_session_cookie(
|
||||
app: &AppHandle,
|
||||
base_url: &str,
|
||||
cookie_name: &str,
|
||||
session_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
||||
.domain(domain)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
@@ -190,6 +291,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_auth_cookie_name() -> String {
|
||||
let pid = std::process::id();
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -344,6 +455,8 @@ impl Default for CliStatus {
|
||||
pub struct CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
#[cfg(windows)]
|
||||
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
@@ -353,6 +466,8 @@ impl CliProcessManager {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
#[cfg(windows)]
|
||||
job: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(AtomicBool::new(false)),
|
||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
@@ -375,6 +490,8 @@ impl CliProcessManager {
|
||||
|
||||
let status_arc = self.status.clone();
|
||||
let child_arc = self.child.clone();
|
||||
#[cfg(windows)]
|
||||
let job_arc = self.job.clone();
|
||||
let ready_flag = self.ready.clone();
|
||||
let token_arc = self.bootstrap_token.clone();
|
||||
thread::spawn(move || {
|
||||
@@ -382,6 +499,8 @@ impl CliProcessManager {
|
||||
app.clone(),
|
||||
status_arc.clone(),
|
||||
child_arc,
|
||||
#[cfg(windows)]
|
||||
job_arc,
|
||||
ready_flag,
|
||||
token_arc,
|
||||
dev,
|
||||
@@ -401,11 +520,12 @@ impl CliProcessManager {
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
#[cfg(windows)]
|
||||
let _job = self.job.lock().take();
|
||||
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(windows)]
|
||||
let mut forced_tree_shutdown = false;
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
let pid = child.id() as i32;
|
||||
@@ -427,18 +547,16 @@ impl CliProcessManager {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
#[cfg(windows)]
|
||||
if !forced_tree_shutdown
|
||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
||||
{
|
||||
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
|
||||
log_line(&format!(
|
||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||
child.id()
|
||||
));
|
||||
forced_tree_shutdown = true;
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
@@ -457,11 +575,7 @@ impl CliProcessManager {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !forced_tree_shutdown
|
||||
&& !kill_process_tree_windows(child.id(), true)
|
||||
{
|
||||
let _ = child.kill();
|
||||
} else if forced_tree_shutdown {
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
@@ -472,6 +586,9 @@ impl CliProcessManager {
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(windows)]
|
||||
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
|
||||
}
|
||||
|
||||
let mut status = self.status.lock();
|
||||
@@ -492,6 +609,7 @@ impl CliProcessManager {
|
||||
app: AppHandle,
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child_holder: Arc<Mutex<Option<Child>>>,
|
||||
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||
dev: bool,
|
||||
@@ -503,44 +621,61 @@ impl CliProcessManager {
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev, &host);
|
||||
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
||||
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
}
|
||||
|
||||
let cwd = workspace_root();
|
||||
let cwd = launch_cwd();
|
||||
if let Some(ref c) = cwd {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let command_info = if supports_user_shell() {
|
||||
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("spawning directly with node");
|
||||
log_line(if resolution.runner == Runner::Standalone {
|
||||
"spawning directly with standalone executable"
|
||||
} else {
|
||||
"spawning directly with node"
|
||||
});
|
||||
ShellCommandType::Direct(DirectCommand {
|
||||
program: resolution.node_binary.clone(),
|
||||
program: if resolution.runner == Runner::Standalone {
|
||||
resolution.entry.clone()
|
||||
} else {
|
||||
resolution.node_binary.clone()
|
||||
},
|
||||
args: resolution.runner_args(&args),
|
||||
})
|
||||
};
|
||||
|
||||
if !supports_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);
|
||||
@@ -553,9 +688,11 @@ 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);
|
||||
@@ -568,6 +705,22 @@ impl CliProcessManager {
|
||||
|
||||
let pid = child.id();
|
||||
log_line(&format!("spawned pid={pid}"));
|
||||
#[cfg(windows)]
|
||||
match WindowsJobObject::create().and_then(|job| {
|
||||
job.assign_child(&child)?;
|
||||
Ok(job)
|
||||
}) {
|
||||
Ok(job) => {
|
||||
log_line(&format!("attached pid={pid} to Windows job object"));
|
||||
*job_holder.lock() = Some(job);
|
||||
}
|
||||
Err(err) => {
|
||||
log_line(&format!(
|
||||
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut locked = status.lock();
|
||||
locked.pid = Some(pid);
|
||||
@@ -584,6 +737,7 @@ impl CliProcessManager {
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let token_clone = bootstrap_token.clone();
|
||||
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
@@ -598,24 +752,41 @@ impl CliProcessManager {
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -623,6 +794,8 @@ impl CliProcessManager {
|
||||
let status_clone = status.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let child_holder_clone = child_holder.clone();
|
||||
#[cfg(windows)]
|
||||
let job_holder_clone = job_holder.clone();
|
||||
thread::spawn(move || {
|
||||
let timeout = Duration::from_secs(60);
|
||||
thread::sleep(timeout);
|
||||
@@ -677,6 +850,10 @@ impl CliProcessManager {
|
||||
// Drop the handle after the process exits so other callers
|
||||
// don't attempt to stop/kill a finished process.
|
||||
*guard = None;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = job_holder_clone.lock().take();
|
||||
}
|
||||
Some(status)
|
||||
}
|
||||
None => None,
|
||||
@@ -731,10 +908,11 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
let local_url_regex =
|
||||
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
loop {
|
||||
@@ -761,44 +939,32 @@ 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)))
|
||||
.map(|m| m.as_str().to_string())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
auth_cookie_name,
|
||||
url,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.to_lowercase().contains("http server listening") {
|
||||
if let Some(port) = http_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{port}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{}", port),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -811,6 +977,7 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
base_url: String,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
@@ -834,9 +1001,11 @@ impl CliProcessManager {
|
||||
if scheme.as_deref() != Some("http") {
|
||||
navigate_main(app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
if let Err(err) =
|
||||
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
||||
{
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
@@ -897,7 +1066,7 @@ struct CliEntry {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Runner {
|
||||
Node,
|
||||
Standalone,
|
||||
Tsx,
|
||||
}
|
||||
|
||||
@@ -918,30 +1087,34 @@ impl CliEntry {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = resolve_dist_entry(app) {
|
||||
if let Some(entry) = resolve_standalone_entry(app) {
|
||||
return Ok(Self {
|
||||
entry,
|
||||
runner: Runner::Node,
|
||||
runner: Runner::Standalone,
|
||||
runner_path: None,
|
||||
node_binary,
|
||||
node_binary: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
||||
"Unable to locate the packaged CodeNomad standalone server. Please rebuild the desktop bundle."
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--auth-cookie-name".to_string(),
|
||||
auth_cookie_name.to_string(),
|
||||
"--generate-token".to_string(),
|
||||
"--unrestricted-root".to_string(),
|
||||
];
|
||||
|
||||
if dev {
|
||||
// Dev: plain HTTP + Vite dev server proxy.
|
||||
// Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so
|
||||
// remote proxy sessions can still spin up secure local windows.
|
||||
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
@@ -958,7 +1131,7 @@ impl CliEntry {
|
||||
.unwrap_or_else(|| "info".to_string());
|
||||
|
||||
args.push("--https".to_string());
|
||||
args.push("false".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--http".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--http-port".to_string());
|
||||
@@ -978,6 +1151,10 @@ 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 {
|
||||
@@ -993,71 +1170,94 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let mut candidates = vec![
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref()
|
||||
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
std::env::current_exe().ok().and_then(|ex| {
|
||||
ex.parent()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||
}),
|
||||
];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
||||
}
|
||||
}
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("packages/server/src/index.ts")),
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
fn resolve_standalone_entry(_app: &AppHandle) -> Option<String> {
|
||||
let executable_name = if cfg!(windows) {
|
||||
"codenomad-server.exe"
|
||||
} else {
|
||||
"codenomad-server"
|
||||
};
|
||||
let base = workspace_root();
|
||||
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")),
|
||||
];
|
||||
let mut candidates = vec![base
|
||||
.as_ref()
|
||||
.map(|p| p.join("packages/server/dist").join(executable_name))];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
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")));
|
||||
candidates.push(Some(
|
||||
dir.join("resources/server/dist").join(executable_name),
|
||||
));
|
||||
|
||||
let resources = dir.join("../Resources");
|
||||
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("server/dist").join(executable_name)));
|
||||
candidates.push(Some(
|
||||
resources.join("resources/server/dist/server/index.js"),
|
||||
resources
|
||||
.join("resources/server/dist")
|
||||
.join(executable_name),
|
||||
));
|
||||
|
||||
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/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")));
|
||||
candidates.push(Some(root.join("server/dist").join(executable_name)));
|
||||
candidates.push(Some(
|
||||
root.join("resources/server/dist").join(executable_name),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1071,16 +1271,55 @@ fn build_shell_command_string(
|
||||
) -> anyhow::Result<ShellCommand> {
|
||||
let shell = default_shell();
|
||||
let mut quoted: Vec<String> = Vec::new();
|
||||
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);
|
||||
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);
|
||||
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() {
|
||||
|
||||
88
packages/tauri-app/src-tauri/src/linux_tls.rs
Normal file
88
packages/tauri-app/src-tauri/src/linux_tls.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
#![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::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
||||
};
|
||||
use tauri_plugin_global_shortcut::{
|
||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||
};
|
||||
@@ -30,9 +37,11 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||
const ZOOM_STEP: f64 = 0.2;
|
||||
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";
|
||||
@@ -41,6 +50,69 @@ pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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)]
|
||||
@@ -106,7 +178,7 @@ fn is_dev_mode() -> bool {
|
||||
|
||||
fn should_allow_internal(url: &Url) -> bool {
|
||||
match url.scheme() {
|
||||
"tauri" | "asset" | "file" => true,
|
||||
"tauri" | "asset" | "file" | "about" => 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.
|
||||
@@ -118,11 +190,32 @@ fn should_allow_internal(url: &Url) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
fn should_allow_window_origin<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
window_label: &str,
|
||||
url: &Url,
|
||||
) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let Ok(allowed) = state.remote_origins.lock() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(origin) = allowed.get(window_label) {
|
||||
return origin == &url.origin().ascii_serialization();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
let window_label = webview.label().to_string();
|
||||
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Err(err) = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
@@ -133,6 +226,162 @@ 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())?;
|
||||
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())
|
||||
);
|
||||
|
||||
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.set_title(&title);
|
||||
let _ = existing.show();
|
||||
let _ = existing.unminimize();
|
||||
let _ = existing.set_focus();
|
||||
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()
|
||||
};
|
||||
|
||||
#[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 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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()
|
||||
@@ -260,6 +509,8 @@ 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();
|
||||
@@ -286,10 +537,17 @@ fn main() {
|
||||
manager: CliProcessManager::new(),
|
||||
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());
|
||||
@@ -323,7 +581,9 @@ fn main() {
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop
|
||||
wake_lock_stop,
|
||||
needs_local_certificate_install,
|
||||
open_remote_window
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
@@ -455,11 +715,24 @@ fn main() {
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} => {
|
||||
// Ensure we have time to stop the CLI process before the app exits.
|
||||
// Let windows close normally. App shutdown is handled only after the
|
||||
// last window is actually gone so remote windows can outlive `main`.
|
||||
let _ = api;
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
..
|
||||
} => {
|
||||
if !app_handle.webview_windows().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the CLI only when the final window is gone and the app is
|
||||
// truly exiting.
|
||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
api.prevent_close();
|
||||
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
@@ -9,6 +9,7 @@
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
"app": {
|
||||
"enableGTKAppId": true,
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
@@ -41,6 +42,30 @@
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.13.3",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { SettingsScreen } from "./components/settings-screen"
|
||||
import { SideCarPickerDialog } from "./components/sidecar-picker-dialog"
|
||||
import { SideCarView } from "./components/sidecar-view"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { showAlertDialog } from "./stores/alerts"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
@@ -19,11 +22,10 @@ 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 { runtimeEnv } from "./lib/runtime-env"
|
||||
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
|
||||
import {
|
||||
createInstance,
|
||||
instances,
|
||||
activeInstanceId,
|
||||
setActiveInstanceId,
|
||||
stopInstance,
|
||||
getActiveInstance,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
} from "./stores/instances"
|
||||
@@ -53,6 +52,22 @@ import {
|
||||
|
||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||
import { openSettings } from "./stores/settings-screen"
|
||||
import {
|
||||
closeSidecarTab,
|
||||
ensureSidecarsLoaded,
|
||||
openSidecarTab,
|
||||
} from "./stores/sidecars"
|
||||
import {
|
||||
activeAppTab,
|
||||
activeAppTabId,
|
||||
appTabs,
|
||||
ensureActiveAppTab,
|
||||
getAdjacentAppTabId,
|
||||
getAppTabById,
|
||||
selectAppTab,
|
||||
selectInstanceTab,
|
||||
selectSidecarTab,
|
||||
} from "./stores/app-tabs"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -77,6 +92,7 @@ const App: Component = () => {
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
|
||||
|
||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||
@@ -121,7 +137,7 @@ const App: Component = () => {
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const shouldShow =
|
||||
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||
!isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||
})
|
||||
|
||||
@@ -206,8 +222,7 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
instances()
|
||||
hasInstances()
|
||||
appTabs()
|
||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||
})
|
||||
|
||||
@@ -219,7 +234,15 @@ const App: Component = () => {
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
createEffect(() => {
|
||||
appTabs()
|
||||
ensureActiveAppTab()
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => {
|
||||
const tab = activeAppTab()
|
||||
return tab?.kind === "instance" ? tab.instance : null
|
||||
})
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return null
|
||||
@@ -244,6 +267,7 @@ const App: Component = () => {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
selectInstanceTab(instanceId)
|
||||
setShowFolderSelection(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
@@ -270,8 +294,27 @@ const App: Component = () => {
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
if (hasInstances()) {
|
||||
setShowFolderSelection(true)
|
||||
setShowFolderSelection(true)
|
||||
}
|
||||
|
||||
function handleOpenSidecarPicker() {
|
||||
setSidecarPickerOpen(true)
|
||||
void ensureSidecarsLoaded()
|
||||
}
|
||||
|
||||
async function handleOpenSidecar(sidecarId: string) {
|
||||
try {
|
||||
const tab = await openSidecarTab(sidecarId)
|
||||
selectSidecarTab(tab.token)
|
||||
setShowFolderSelection(false)
|
||||
setSidecarPickerOpen(false)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
showAlertDialog(message, {
|
||||
variant: "error",
|
||||
title: t("sidecars.open.errorTitle"),
|
||||
})
|
||||
log.error("Failed to open SideCar", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +375,23 @@ const App: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseAppTab(tabId: string) {
|
||||
const tab = getAppTabById(tabId)
|
||||
if (!tab) return
|
||||
|
||||
const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId()
|
||||
|
||||
if (tab.kind === "instance") {
|
||||
await handleCloseInstance(tab.instance.id)
|
||||
} else {
|
||||
closeSidecarTab(tab.sidecarTab.token)
|
||||
}
|
||||
|
||||
if (!getAppTabById(tabId)) {
|
||||
ensureActiveAppTab(fallbackTabId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
||||
if (!instanceId || !sessionId || sessionId === "info") return
|
||||
await updateSessionAgent(instanceId, sessionId, agent)
|
||||
@@ -361,6 +421,7 @@ const App: Component = () => {
|
||||
setThinkingBlocksExpansion,
|
||||
setToolInputsVisibility,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
@@ -371,6 +432,7 @@ const App: Component = () => {
|
||||
useAppLifecycle({
|
||||
setEscapeInDebounce,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
@@ -382,7 +444,7 @@ const App: Component = () => {
|
||||
|
||||
// Listen for Tauri menu events
|
||||
onMount(() => {
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
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
|
||||
@@ -470,52 +532,60 @@ const App: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!hasInstances()}
|
||||
when={appTabs().length === 0}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
tabs={appTabs()}
|
||||
activeTabId={activeAppTabId()}
|
||||
onSelect={selectAppTab}
|
||||
onClose={(tabId) => void handleCloseAppTab(tabId)}
|
||||
onNew={handleNewInstanceRequest}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||
return (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-instance-id={instance.id}
|
||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||
data-instance-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
isActiveInstance={isActiveInstance()}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
<For each={appTabs()}>
|
||||
{(tab) => {
|
||||
const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
|
||||
return tab.kind === "instance" ? (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-instance-id={tab.instance.id}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-kind={tab.kind}
|
||||
data-tab-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={tab.instance}>
|
||||
<InstanceShell
|
||||
instance={tab.instance}
|
||||
isActiveInstance={isVisible()}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(tab.instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(tab.instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-kind={tab.kind}
|
||||
data-tab-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<SideCarView tab={tab.sidecarTab} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -525,6 +595,7 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
onOpenSidecar={handleOpenSidecarPicker}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -534,6 +605,7 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
onOpenSidecar={handleOpenSidecarPicker}
|
||||
onClose={() => {
|
||||
setShowFolderSelection(false)
|
||||
clearLaunchError()
|
||||
@@ -544,6 +616,7 @@ const App: Component = () => {
|
||||
</Show>
|
||||
|
||||
<SettingsScreen />
|
||||
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { createMemo, Show, createEffect } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
import { disableCache } from "@git-diff-view/core"
|
||||
@@ -20,6 +20,7 @@ interface ToolCallDiffViewerProps {
|
||||
filePath?: string
|
||||
theme: "light" | "dark"
|
||||
mode: DiffViewMode
|
||||
wrap?: boolean
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
@@ -31,11 +32,183 @@ type DiffData = {
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
type CaptureContext = {
|
||||
theme: ToolCallDiffViewerProps["theme"]
|
||||
mode: DiffViewMode
|
||||
diffText: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
|
||||
const computed = window.getComputedStyle(source)
|
||||
const probe = document.createElement("span")
|
||||
probe.textContent = text || ""
|
||||
probe.style.position = "absolute"
|
||||
probe.style.visibility = "hidden"
|
||||
probe.style.pointerEvents = "none"
|
||||
probe.style.display = "inline-block"
|
||||
probe.style.width = "auto"
|
||||
probe.style.maxWidth = "none"
|
||||
probe.style.whiteSpace = "nowrap"
|
||||
probe.style.fontFamily = computed.fontFamily
|
||||
probe.style.fontSize = computed.fontSize
|
||||
probe.style.fontWeight = computed.fontWeight
|
||||
probe.style.fontStyle = computed.fontStyle
|
||||
probe.style.letterSpacing = computed.letterSpacing
|
||||
probe.style.fontVariant = computed.fontVariant
|
||||
probe.style.textTransform = computed.textTransform
|
||||
probe.style.lineHeight = computed.lineHeight
|
||||
container.appendChild(probe)
|
||||
const width = Math.ceil(probe.getBoundingClientRect().width)
|
||||
probe.remove()
|
||||
return width
|
||||
}
|
||||
|
||||
function computeCompactWidth(
|
||||
container: HTMLElement,
|
||||
entries: Array<{ text: string; source: HTMLElement }>,
|
||||
maxWidthPx = 40,
|
||||
) {
|
||||
const measuredLabelWidthPx = entries.reduce((max, entry) => {
|
||||
return Math.max(max, measureTextWidth(container, entry.text, entry.source))
|
||||
}, 0)
|
||||
const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1)
|
||||
const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4)
|
||||
return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx))
|
||||
}
|
||||
|
||||
function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) {
|
||||
const tableWrapper = container.querySelector<HTMLElement>(".unified-diff-table-wrapper")
|
||||
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
|
||||
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
|
||||
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
|
||||
const hunkGutters = container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")
|
||||
const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = []
|
||||
|
||||
if (table) {
|
||||
if (wrap) {
|
||||
table.classList.add("table-fixed")
|
||||
table.style.tableLayout = "fixed"
|
||||
table.style.width = "100%"
|
||||
table.style.minWidth = "100%"
|
||||
} else {
|
||||
table.classList.remove("table-fixed")
|
||||
table.style.tableLayout = "auto"
|
||||
table.style.width = "max-content"
|
||||
table.style.minWidth = "100%"
|
||||
}
|
||||
}
|
||||
|
||||
gutterRows.forEach((gutter) => {
|
||||
const oldSpan = gutter.querySelector<HTMLElement>("[data-line-old-num]")
|
||||
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
|
||||
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
|
||||
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
|
||||
const currentLabel = gutter.querySelector<HTMLElement>(":scope > .tool-call-diff-compact-line-number")
|
||||
|
||||
const oldText = oldSpan?.textContent?.trim() ?? ""
|
||||
const newText = newSpan?.textContent?.trim() ?? ""
|
||||
const hasUsableNew = newText.length > 0 && newText !== "0"
|
||||
const hasUsableOld = oldText.length > 0 && oldText !== "0"
|
||||
const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText
|
||||
|
||||
if (flexWrapper) flexWrapper.style.display = "none"
|
||||
if (spacer) spacer.style.display = "none"
|
||||
if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" }
|
||||
if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" }
|
||||
|
||||
gutter.style.paddingLeft = "1px"
|
||||
gutter.style.paddingRight = "1px"
|
||||
gutter.style.textAlign = "left"
|
||||
|
||||
const label = currentLabel ?? document.createElement("span")
|
||||
label.className = "tool-call-diff-compact-line-number"
|
||||
label.textContent = visibleText
|
||||
label.setAttribute("aria-hidden", visibleText ? "false" : "true")
|
||||
if (!currentLabel) gutter.appendChild(label)
|
||||
|
||||
entries.push({ gutter, label, text: visibleText })
|
||||
})
|
||||
|
||||
const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label })))
|
||||
const gutterWidth = `${gutterWidthPx}px`
|
||||
const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px`
|
||||
|
||||
if (tableWrapper) {
|
||||
tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth)
|
||||
tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth)
|
||||
}
|
||||
if (numberCol) {
|
||||
numberCol.style.width = gutterWidth
|
||||
}
|
||||
|
||||
entries.forEach(({ gutter, label }) => {
|
||||
gutter.style.width = gutterWidth
|
||||
gutter.style.minWidth = gutterWidth
|
||||
gutter.style.maxWidth = gutterWidth
|
||||
label.style.width = "auto"
|
||||
label.style.maxWidth = "none"
|
||||
})
|
||||
|
||||
hunkGutters.forEach((gutter) => {
|
||||
gutter.style.width = gutterWidth
|
||||
gutter.style.minWidth = gutterWidth
|
||||
gutter.style.maxWidth = gutterWidth
|
||||
gutter.style.paddingLeft = "0"
|
||||
gutter.style.paddingRight = "0"
|
||||
})
|
||||
}
|
||||
|
||||
function applyCompactSplitGutter(container: HTMLElement) {
|
||||
const oldWrapper = container.querySelector<HTMLElement>(".old-diff-table-wrapper")
|
||||
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
|
||||
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
|
||||
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
|
||||
const numberSpans = numberCells
|
||||
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[data-line-num]") }))
|
||||
.filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span))
|
||||
|
||||
const gutterWidthPx = computeCompactWidth(
|
||||
container,
|
||||
numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })),
|
||||
64,
|
||||
)
|
||||
const gutterWidth = `${gutterWidthPx}px`
|
||||
|
||||
;[oldWrapper, newWrapper].forEach((wrapper) => {
|
||||
if (wrapper) {
|
||||
wrapper.style.setProperty("--diff-aside-width", gutterWidth)
|
||||
}
|
||||
})
|
||||
|
||||
numberCells.forEach((cell) => {
|
||||
cell.style.width = gutterWidth
|
||||
cell.style.minWidth = gutterWidth
|
||||
cell.style.maxWidth = gutterWidth
|
||||
cell.style.paddingLeft = "2px"
|
||||
cell.style.paddingRight = "2px"
|
||||
cell.style.textAlign = "left"
|
||||
cell.style.whiteSpace = "nowrap"
|
||||
cell.style.overflowWrap = "normal"
|
||||
cell.style.wordBreak = "normal"
|
||||
})
|
||||
|
||||
numberSpans.forEach(({ span }) => {
|
||||
span.style.whiteSpace = "nowrap"
|
||||
span.style.overflowWrap = "normal"
|
||||
span.style.wordBreak = "normal"
|
||||
})
|
||||
|
||||
hunkActions.forEach((cell) => {
|
||||
cell.style.width = gutterWidth
|
||||
cell.style.minWidth = gutterWidth
|
||||
cell.style.maxWidth = gutterWidth
|
||||
cell.style.paddingLeft = "0"
|
||||
cell.style.paddingRight = "0"
|
||||
})
|
||||
}
|
||||
|
||||
function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) {
|
||||
if (mode === "unified") {
|
||||
applyCompactUnifiedGutter(container, wrap)
|
||||
return
|
||||
}
|
||||
if (mode === "split") {
|
||||
applyCompactSplitGutter(container)
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
@@ -67,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
const contextKey = createMemo(() => {
|
||||
const data = diffData()
|
||||
if (!data) return ""
|
||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
||||
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const cachedHtml = props.cachedHtml
|
||||
if (cachedHtml) {
|
||||
if (diffContainerRef) {
|
||||
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||
}
|
||||
// When we are given cached HTML, we rely on the caller's cache
|
||||
// and simply notify once rendered.
|
||||
props.onRendered?.()
|
||||
@@ -83,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
if (!key) return
|
||||
if (!diffContainerRef) return
|
||||
if (lastCapturedKey === key) return
|
||||
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!diffContainerRef) return
|
||||
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||
const markup = diffContainerRef.innerHTML
|
||||
if (!markup) return
|
||||
lastCapturedKey = key
|
||||
@@ -95,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
html: markup,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
wrap: props.wrap,
|
||||
})
|
||||
}
|
||||
props.onRendered?.()
|
||||
@@ -122,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewWrap={Boolean(props.wrap)}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
@@ -131,7 +309,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div innerHTML={props.cachedHtml} />
|
||||
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -58,6 +58,16 @@ 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 }
|
||||
@@ -67,6 +77,8 @@ 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())
|
||||
@@ -75,12 +87,16 @@ 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)
|
||||
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const metadata = await loadDirectory()
|
||||
applyMetadata(metadata)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
await navigateTo()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -197,13 +209,22 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,31 +246,58 @@ 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(() => {
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata) {
|
||||
return ""
|
||||
return getAbsolutePathFromMetadata(currentMetadata())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const absolutePath = currentAbsolutePath()
|
||||
if (!pathInputDirty()) {
|
||||
setPathInput(absolutePath)
|
||||
}
|
||||
if (metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
})
|
||||
|
||||
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
||||
const 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
|
||||
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
|
||||
async function handleCreateFolder() {
|
||||
if (creatingFolder()) return
|
||||
const metadata = currentMetadata()
|
||||
const target = pathInput().trim()
|
||||
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||
|
||||
const name =
|
||||
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<div class="directory-browser-current">
|
||||
<div class="directory-browser-current-meta">
|
||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput()}
|
||||
onInput={(event) => {
|
||||
setPathInput(event.currentTarget.value)
|
||||
setPathInputDirty(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void handlePathSubmit()
|
||||
}
|
||||
}}
|
||||
spellcheck={false}
|
||||
class="selector-input directory-browser-current-path"
|
||||
/>
|
||||
</div>
|
||||
<div class="directory-browser-current-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
|
||||
onClick={() => void handleSelectCurrent()}
|
||||
>
|
||||
{t("directoryBrowser.selectCurrent")}
|
||||
</button>
|
||||
|
||||
@@ -1,18 +1,69 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { Show, 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"
|
||||
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||
import { useTheme } from "../../lib/theme"
|
||||
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
|
||||
|
||||
interface MonacoDiffViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
before: string
|
||||
after: string
|
||||
patch?: string
|
||||
before?: string
|
||||
after?: string
|
||||
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) {
|
||||
@@ -21,7 +72,22 @@ 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) {
|
||||
return parsePatchToBeforeAfter(props.patch)
|
||||
}
|
||||
return {
|
||||
before: props.before ?? "",
|
||||
after: props.after ?? "",
|
||||
}
|
||||
})
|
||||
|
||||
const disposeEditor = () => {
|
||||
try {
|
||||
@@ -37,6 +103,90 @@ 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 () => {
|
||||
@@ -69,10 +219,17 @@ 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()
|
||||
})
|
||||
@@ -83,15 +240,101 @@ 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 }
|
||||
@@ -100,26 +343,41 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
})
|
||||
|
||||
try {
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({
|
||||
wordWrap,
|
||||
lineNumbersMinChars: originalLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({
|
||||
wordWrap,
|
||||
lineNumbersMinChars: modifiedLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
syncSplitLayoutVariables({
|
||||
viewMode,
|
||||
originalLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||
const { before, after } = resolvedContent()
|
||||
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
||||
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
||||
|
||||
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
|
||||
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
|
||||
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
|
||||
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
|
||||
diffEditor.setModel({ original, modified })
|
||||
|
||||
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||
@@ -132,5 +390,46 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
})
|
||||
})
|
||||
|
||||
return <div class="monaco-viewer" ref={host} />
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Select } from "@kobalte/core/select"
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
@@ -14,26 +15,49 @@ import { useI18n, type Locale } from "../lib/i18n"
|
||||
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
|
||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
|
||||
type HomeTab = "local" | "servers"
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
onOpenSidecar?: () => void
|
||||
isLoading?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
||||
const {
|
||||
recentFolders,
|
||||
removeRecentFolder,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
serverSettings,
|
||||
remoteServers,
|
||||
saveRemoteServerProfile,
|
||||
markRemoteServerConnected,
|
||||
removeRemoteServerProfile,
|
||||
} = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
|
||||
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
|
||||
const [serverName, setServerName] = createSignal("")
|
||||
const [serverUrl, setServerUrl] = createSignal("")
|
||||
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
|
||||
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
type LanguageOption = { value: Locale; label: string }
|
||||
@@ -49,9 +73,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
]
|
||||
|
||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||
|
||||
|
||||
const folders = () => recentFolders()
|
||||
const serverList = () => remoteServers()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
|
||||
|
||||
function getActiveListLength() {
|
||||
return activeTab() === "local" ? folders().length : serverList().length
|
||||
}
|
||||
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
@@ -64,7 +94,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
function scrollToIndex(index: number) {
|
||||
const container = recentListRef
|
||||
if (!container) return
|
||||
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
|
||||
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
|
||||
if (!element) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
@@ -94,17 +124,7 @@ 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",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
]
|
||||
const blockedKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", "Enter"]
|
||||
|
||||
if (isLoading()) {
|
||||
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
|
||||
@@ -113,19 +133,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
const folderList = folders()
|
||||
|
||||
if (isBrowseShortcut) {
|
||||
e.preventDefault()
|
||||
void handleBrowse()
|
||||
return
|
||||
}
|
||||
|
||||
if (folderList.length === 0) return
|
||||
const listLength = getActiveListLength()
|
||||
if (listLength === 0) return
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
||||
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -138,7 +157,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
} else if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -156,36 +175,57 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = folderList.length - 1
|
||||
const newIndex = listLength - 1
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleEnterKey()
|
||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault()
|
||||
if (folderList.length > 0 && focusMode() === "recent") {
|
||||
const folder = folderList[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleEnterKey() {
|
||||
if (isLoading()) return
|
||||
const folderList = folders()
|
||||
const index = selectedIndex()
|
||||
|
||||
const folder = folderList[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
if (activeTab() === "local") {
|
||||
const folder = folders()[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const server = serverList()[index]
|
||||
if (server) {
|
||||
void handleConnectSavedServer(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
activeTab()
|
||||
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
|
||||
setActiveTab("local")
|
||||
return
|
||||
}
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("recent")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const length = getActiveListLength()
|
||||
if (length === 0) {
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedIndex() >= length) {
|
||||
setSelectedIndex(length - 1)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
@@ -236,10 +276,113 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
function resetServerDialog() {
|
||||
setServerName("")
|
||||
setServerUrl("")
|
||||
setSkipTlsVerify(false)
|
||||
setServerDialogError(null)
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
|
||||
}
|
||||
|
||||
const probe = await serverApi.probeRemoteServer({
|
||||
baseUrl: trimmedUrl,
|
||||
skipTlsVerify: input.skipTlsVerify,
|
||||
})
|
||||
|
||||
if (!probe.ok) {
|
||||
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
|
||||
}
|
||||
|
||||
const profile = await saveRemoteServerProfile({
|
||||
id: input.id,
|
||||
name: trimmedName,
|
||||
baseUrl: probe.normalizedUrl,
|
||||
skipTlsVerify: input.skipTlsVerify,
|
||||
})
|
||||
|
||||
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 markRemoteServerConnected(profile.id)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
async function handleSaveServer(openWindow: boolean) {
|
||||
if (isSavingServer()) return
|
||||
setIsSavingServer(true)
|
||||
setServerDialogError(null)
|
||||
try {
|
||||
await probeAndOpenServer(
|
||||
{
|
||||
name: serverName(),
|
||||
baseUrl: serverUrl(),
|
||||
skipTlsVerify: skipTlsVerify(),
|
||||
},
|
||||
openWindow,
|
||||
)
|
||||
setIsServerDialogOpen(false)
|
||||
resetServerDialog()
|
||||
} catch (error) {
|
||||
setServerDialogError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setIsSavingServer(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnectSavedServer(id: string) {
|
||||
if (!canUseRemoteServerWindows()) return
|
||||
const target = remoteServers().find((entry) => entry.id === id)
|
||||
if (!target || connectingServerId()) return
|
||||
setConnectingServerId(id)
|
||||
try {
|
||||
await probeAndOpenServer(target, true)
|
||||
} catch (error) {
|
||||
showAlertDialog(error instanceof Error ? error.message : String(error), {
|
||||
title: t("folderSelection.servers.errorTitle"),
|
||||
variant: "warning",
|
||||
})
|
||||
} finally {
|
||||
setConnectingServerId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
if (nativeDialogsAvailable) {
|
||||
if (supportsNativeDialogsInCurrentWindow()) {
|
||||
const fallbackPath = folders()[0]?.path
|
||||
const selected = await openNativeFolderDialog({
|
||||
title: t("folderSelection.dialog.title"),
|
||||
@@ -396,15 +539,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
<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={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>
|
||||
<Show when={props.onClose}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -476,90 +621,227 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||
{/* Right column: recent folders */}
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
||||
<p class="panel-subtitle">
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<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`}>
|
||||
<button
|
||||
type="button"
|
||||
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "local",
|
||||
"text-muted hover:text-secondary": activeTab() !== "local",
|
||||
}}
|
||||
style={{
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("local")}
|
||||
>
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
{t("folderSelection.recent.title")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</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",
|
||||
}}
|
||||
style={{
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("servers")}
|
||||
>
|
||||
<div
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.tabs.servers")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||
</p>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={activeTab() === "local"}
|
||||
fallback={
|
||||
<Show
|
||||
when={canUseRemoteServerWindows() && 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>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={remoteServers()}>
|
||||
{(server, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-list-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
onClick={() => void handleConnectSavedServer(server.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd">↵</kbd></Show>}>
|
||||
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeRemoteServerProfile(server.id)}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.servers.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-list-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -567,11 +849,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
||||
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="panel-body flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
@@ -588,6 +870,29 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onOpenSidecar?.()}
|
||||
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
<span>{t("folderSelection.sidecars.button")}</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* OpenCode settings section */}
|
||||
@@ -623,10 +928,6 @@ 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" />
|
||||
@@ -663,6 +964,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">
|
||||
{t("folderSelection.servers.dialog.title")}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
{t("folderSelection.servers.dialog.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||
<span>{t("folderSelection.servers.dialog.name")}</span>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
value={serverName()}
|
||||
onInput={(event) => setServerName(event.currentTarget.value)}
|
||||
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||
<span>{t("folderSelection.servers.dialog.url")}</span>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
value={serverUrl()}
|
||||
onInput={(event) => setServerUrl(event.currentTarget.value)}
|
||||
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipTlsVerify()}
|
||||
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
|
||||
</label>
|
||||
|
||||
<Show when={serverDialogError()}>
|
||||
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
|
||||
{t("folderSelection.servers.dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto px-4"
|
||||
disabled={isSavingServer()}
|
||||
onClick={() => void handleSaveServer(false)}
|
||||
>
|
||||
{t("folderSelection.servers.dialog.save")}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto px-4"
|
||||
disabled={isSavingServer()}
|
||||
onClick={() => void handleSaveServer(true)}
|
||||
>
|
||||
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
{t("folderSelection.servers.dialog.connecting")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
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"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
tabs: AppTabRecord[]
|
||||
activeTabId: string | null
|
||||
onSelect: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
|
||||
@@ -42,15 +43,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
/>
|
||||
)}
|
||||
<For each={props.tabs}>
|
||||
{(tab) =>
|
||||
tab.kind === "instance" ? (
|
||||
<InstanceTab
|
||||
instance={tab.instance}
|
||||
active={tab.id === props.activeTabId}
|
||||
onSelect={() => props.onSelect(tab.id)}
|
||||
onClose={() => props.onClose(tab.id)}
|
||||
/>
|
||||
) : (
|
||||
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
|
||||
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
|
||||
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
|
||||
</button>
|
||||
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button"
|
||||
@@ -62,7 +73,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-strip-spacer" />
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<Show when={props.tabs.length > 1}>
|
||||
<div class="tab-shortcuts">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
@@ -89,14 +100,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,9 +171,6 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,29 +184,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
if (index >= sessions.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleSessionDelete(sessions[index].id)
|
||||
|
||||
const updatedSessions = parentSessions()
|
||||
if (updatedSessions.length === 0) {
|
||||
setFocusMode("new-session")
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
||||
setSelectedIndex(nextIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(nextIndex)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
@@ -562,10 +536,6 @@ 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>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ 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 {
|
||||
@@ -105,6 +106,7 @@ 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)
|
||||
@@ -268,6 +270,19 @@ 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()
|
||||
|
||||
@@ -342,7 +357,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const pill = activeSessionStatusPill()
|
||||
if (!pill) return null
|
||||
return (
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
|
||||
<span
|
||||
class={`status-indicator session-status session-status-list ${pill.className} notranslate`}
|
||||
title={pill.title}
|
||||
translate="no"
|
||||
>
|
||||
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{pill.text}
|
||||
</span>
|
||||
@@ -594,6 +613,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onCloseRightDrawer={closeRightDrawer}
|
||||
onPinRightDrawer={pinRightDrawer}
|
||||
onUnpinRightDrawer={unpinRightDrawer}
|
||||
promptInputApi={activePromptInputApi}
|
||||
setContentEl={setRightDrawerContentEl}
|
||||
/>
|
||||
</Box>
|
||||
@@ -656,6 +676,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onCloseRightDrawer={closeRightDrawer}
|
||||
onPinRightDrawer={pinRightDrawer}
|
||||
onUnpinRightDrawer={unpinRightDrawer}
|
||||
promptInputApi={activePromptInputApi}
|
||||
setContentEl={setRightDrawerContentEl}
|
||||
/>
|
||||
</Drawer>
|
||||
@@ -892,6 +913,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isPhoneLayout={isPhoneLayout()}
|
||||
compactPromptLayout={compactPromptLayout()}
|
||||
registerSessionPromptApi={registerSessionPromptApi}
|
||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||
onSidebarToggle={() => setLeftOpen(true)}
|
||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type Component,
|
||||
} from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileContent, FileNode } 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,16 +19,23 @@ 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, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||
import {
|
||||
getDefaultWorktreeSlug,
|
||||
getGitRepoStatus,
|
||||
getOrCreateWorktreeClient,
|
||||
getWorktreeSlugForSession,
|
||||
getWorktrees,
|
||||
} 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,
|
||||
@@ -41,7 +48,11 @@ 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,
|
||||
@@ -82,6 +93,7 @@ interface RightPanelProps {
|
||||
onCloseRightDrawer: () => void
|
||||
onPinRightDrawer: () => void
|
||||
onUnpinRightDrawer: () => void
|
||||
promptInputApi: Accessor<PromptInputApi | null>
|
||||
|
||||
setContentEl: (el: HTMLElement | null) => void
|
||||
}
|
||||
@@ -133,6 +145,8 @@ 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"))
|
||||
|
||||
@@ -149,11 +163,28 @@ 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()
|
||||
@@ -185,6 +216,12 @@ 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(() => {
|
||||
@@ -339,34 +376,56 @@ 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, 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
|
||||
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),
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
// Reset tab state when worktree context changes.
|
||||
worktreeSlugForViewer()
|
||||
setBrowserPath(".")
|
||||
setBrowserEntries(null)
|
||||
@@ -375,111 +434,8 @@ 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
|
||||
@@ -680,21 +636,6 @@ 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) {
|
||||
@@ -911,12 +852,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
entries={gitStatusEntries}
|
||||
statusLoading={gitStatusLoading}
|
||||
statusError={gitStatusError}
|
||||
selectedPath={gitSelectedPath}
|
||||
selectedItemId={gitSelectedItemId}
|
||||
selectedBulkItemIds={gitBulkSelectedItemIds}
|
||||
selectedLoading={gitSelectedLoading}
|
||||
selectedError={gitSelectedError}
|
||||
selectedBefore={gitSelectedBefore}
|
||||
selectedAfter={gitSelectedAfter}
|
||||
mostChangedPath={gitMostChangedPath}
|
||||
mostChangedItemId={gitMostChangedItemId}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
@@ -924,8 +866,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onOpenFile={(path: string) => void openGitFile(path)}
|
||||
onRowClick={handleGitRowClick}
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -115,23 +115,22 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
patch={String((file() as any).patch || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
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 {
|
||||
For,
|
||||
Show,
|
||||
Suspense,
|
||||
createMemo,
|
||||
lazy,
|
||||
type Accessor,
|
||||
type Component,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
import { ChevronDown, ChevronRight, GitBranch, RefreshCw } from "lucide-solid"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, GitChangeEntry, GitChangeListItem } from "../types"
|
||||
import { buildGitChangeListItems } from "../git-changes-model"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
@@ -16,16 +25,17 @@ interface GitChangesTabProps {
|
||||
|
||||
activeSessionId: Accessor<string | null>
|
||||
|
||||
entries: Accessor<GitFileStatus[] | null>
|
||||
entries: Accessor<GitChangeEntry[] | null>
|
||||
statusLoading: Accessor<boolean>
|
||||
statusError: Accessor<string | null>
|
||||
|
||||
selectedPath: Accessor<string | null>
|
||||
selectedItemId: Accessor<string | null>
|
||||
selectedBulkItemIds: Accessor<Set<string>>
|
||||
selectedLoading: Accessor<boolean>
|
||||
selectedError: Accessor<string | null>
|
||||
selectedBefore: Accessor<string | null>
|
||||
selectedAfter: Accessor<string | null>
|
||||
mostChangedPath: Accessor<string | null>
|
||||
mostChangedItemId: Accessor<string | null>
|
||||
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
@@ -36,8 +46,21 @@ interface GitChangesTabProps {
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||
|
||||
onOpenFile: (path: string) => void
|
||||
onRowClick: (item: GitChangeListItem, event: MouseEvent) => 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
|
||||
@@ -52,48 +75,54 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||
|
||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
||||
const sorted = createMemo<GitChangeEntry[]>(() => {
|
||||
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 sorted().reduce(
|
||||
return listItems().reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 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 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 selectedEntry = createMemo<GitChangeEntry | null>(() => {
|
||||
const list = listItems()
|
||||
const selectedId = props.selectedItemId()
|
||||
const fallbackId = props.mostChangedItemId()
|
||||
const found =
|
||||
list.find((item) => item.path === selectedPath) ||
|
||||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
|
||||
return found ?? null
|
||||
list.find((item) => item.id === selectedId) ||
|
||||
(fallbackId ? list.find((item) => item.id === fallbackId) : undefined)
|
||||
return found?.entry ?? 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 (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||
if (listItems().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 sortedList = sorted()
|
||||
const nonDeletedList = nonDeleted()
|
||||
const allItems = listItems()
|
||||
const stagedList = stagedItems()
|
||||
const unstagedList = unstagedItems()
|
||||
|
||||
const renderViewer = () => (
|
||||
<div class="file-viewer-panel flex-1">
|
||||
@@ -109,7 +138,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
selected &&
|
||||
props.selectedBefore() !== null &&
|
||||
props.selectedAfter() !== null &&
|
||||
selected.status !== "deleted"
|
||||
true
|
||||
? {
|
||||
path: selected.path,
|
||||
before: props.selectedBefore() as string,
|
||||
@@ -139,6 +168,14 @@ 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>
|
||||
)}
|
||||
@@ -163,66 +200,149 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
|
||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||
|
||||
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>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</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>
|
||||
)
|
||||
|
||||
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>
|
||||
</>
|
||||
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>
|
||||
</div>
|
||||
<For each={stagedList}>{(item) => renderListItem(item)}</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{renderSection(
|
||||
props.t("instanceShell.gitChanges.sections.unstaged"),
|
||||
unstagedList,
|
||||
props.unstagedOpen(),
|
||||
props.onToggleUnstagedOpen,
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -264,9 +384,10 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||
/>
|
||||
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
onToggleList={props.onToggleList}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core"
|
||||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
import Switch from "@suid/material/Switch"
|
||||
|
||||
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
|
||||
import type { Instance } from "../../../../../types/instance"
|
||||
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
<div class="status-process-header">
|
||||
<span class="status-process-title">{process.title}</span>
|
||||
<div class="status-process-meta">
|
||||
<span
|
||||
classList={{
|
||||
"text-success": Boolean(process.notifyEnabled),
|
||||
"text-tertiary": !process.notifyEnabled,
|
||||
}}
|
||||
aria-label={props.t(
|
||||
process.notifyEnabled
|
||||
? "instanceShell.backgroundProcesses.notify.enabled"
|
||||
: "instanceShell.backgroundProcesses.notify.disabled",
|
||||
)}
|
||||
title={props.t(
|
||||
process.notifyEnabled
|
||||
? "instanceShell.backgroundProcesses.notify.enabled"
|
||||
: "instanceShell.backgroundProcesses.notify.disabled",
|
||||
)}
|
||||
>
|
||||
<BellRing class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||
<span>
|
||||
|
||||
@@ -5,3 +5,40 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
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"),
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ 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"
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
|
||||
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
|
||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { ClientPart, MessageInfo } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
|
||||
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
import { selectInstanceTab } from "../stores/app-tabs"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
import { createFollowScroll } from "../lib/follow-scroll"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
|
||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
@@ -130,7 +132,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
|
||||
}
|
||||
|
||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||
setActiveInstanceId(location.instanceId)
|
||||
selectInstanceTab(location.instanceId)
|
||||
const parentToActivate = location.parentId ?? location.sessionId
|
||||
setActiveParentSession(location.instanceId, parentToActivate)
|
||||
if (location.parentId) {
|
||||
@@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
|
||||
return type === "text" || type === "file"
|
||||
}
|
||||
|
||||
function isVisibleContentPart(part: ClientPart): boolean {
|
||||
if (!part || !isContentPartType((part as any).type)) return false
|
||||
if (isHiddenSyntheticTextPart(part)) return false
|
||||
return partHasRenderableText(part)
|
||||
}
|
||||
|
||||
function MessageContentItem(props: MessageContentItemProps) {
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
@@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
return resolved
|
||||
})
|
||||
|
||||
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
|
||||
|
||||
const showAgentMeta = createMemo(() => {
|
||||
const current = record()
|
||||
if (!current) return false
|
||||
if (current.role !== "assistant") return false
|
||||
|
||||
const currentParts = parts()
|
||||
if (!currentParts.some((part) => partHasRenderableText(part))) {
|
||||
if (visibleParts().length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
if (!isSupportedPartType(part)) continue
|
||||
|
||||
if (!isContentPartType((part as any).type)) continue
|
||||
if (partHasRenderableText(part)) {
|
||||
return false
|
||||
if (isVisibleContentPart(part)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
@@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
<MessageItem
|
||||
record={resolvedRecord()}
|
||||
messageInfo={messageInfo()}
|
||||
parts={parts()}
|
||||
parts={visibleParts()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={isQueued()}
|
||||
@@ -619,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const lastAssistantIdx = props.lastAssistantIndex()
|
||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||
|
||||
// Intentionally untracked: messageInfoVersion updates should not trigger
|
||||
// a full message block rebuild; record revision is the invalidation key.
|
||||
const info = untrack(messageInfo)
|
||||
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
|
||||
|
||||
const cacheSignature = [
|
||||
current.id,
|
||||
current.revision,
|
||||
messageInfoVersion,
|
||||
isQueued ? 1 : 0,
|
||||
props.showThinking() ? 1 : 0,
|
||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||
@@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
return cachedBlock.block
|
||||
}
|
||||
|
||||
// Only capture info after cache check fails - ensures fresh data on version bump
|
||||
const info = untrack(messageInfo)
|
||||
|
||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
||||
const items: MessageBlockItem[] = []
|
||||
const blockContentKeys: string[] = []
|
||||
@@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
data-message-id={resolvedBlock().record.id}
|
||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||
>
|
||||
<For each={resolvedBlock().items}>
|
||||
<Index each={resolvedBlock().items}>
|
||||
{(item, index) => (
|
||||
<Switch>
|
||||
<Match when={item.type === "content"}>
|
||||
<Match when={item().type === "content"}>
|
||||
<MessageContentItem
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageId={(item as ContentDisplayItem).messageId}
|
||||
startPartId={(item as ContentDisplayItem).startPartId}
|
||||
messageId={(item() as ContentDisplayItem).messageId}
|
||||
startPartId={(item() as ContentDisplayItem).startPartId}
|
||||
messageIndex={props.messageIndex}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showDeleteMessage={index() === 0}
|
||||
showDeleteMessage={index === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
@@ -825,18 +837,18 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "tool"}>
|
||||
<Match when={item().type === "tool"}>
|
||||
{(() => {
|
||||
const toolItem = item as ToolDisplayItem
|
||||
const toolItem = item() as ToolDisplayItem
|
||||
return (
|
||||
<div class="tool-call-message" data-key={toolItem.key}>
|
||||
<ToolCallItem
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageId={toolItem.messageId}
|
||||
partId={toolItem.partId}
|
||||
showDeleteMessage={index() === 0}
|
||||
<ToolCallItem
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageId={toolItem.messageId}
|
||||
partId={toolItem.partId}
|
||||
showDeleteMessage={index === 0}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
@@ -849,13 +861,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={item.type === "step-start"}>
|
||||
<Match when={item().type === "step-start"}>
|
||||
<StepCard
|
||||
kind="start"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
part={(item() as StepDisplayItem).part}
|
||||
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||
showAgentMeta
|
||||
showDeleteMessage={index() === 0}
|
||||
showDeleteMessage={index === 0}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={props.messageId}
|
||||
@@ -865,14 +877,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
<Match when={item().type === "step-finish"}>
|
||||
<StepCard
|
||||
kind="finish"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
part={(item() as StepDisplayItem).part}
|
||||
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||
showUsage={props.showUsageMetrics()}
|
||||
borderColor={(item as StepDisplayItem).accentColor}
|
||||
showDeleteMessage={index() === 0}
|
||||
borderColor={(item() as StepDisplayItem).accentColor}
|
||||
showDeleteMessage={index === 0}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={props.messageId}
|
||||
@@ -882,31 +894,31 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
<Match when={item().type === "compaction"}>
|
||||
<CompactionCard
|
||||
part={(item as CompactionDisplayItem).part}
|
||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
||||
part={(item() as CompactionDisplayItem).part}
|
||||
messageInfo={(item() as CompactionDisplayItem).messageInfo}
|
||||
borderColor={(item() as CompactionDisplayItem).accentColor}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as CompactionDisplayItem).messageId}
|
||||
showDeleteMessage={index() === 0}
|
||||
messageId={(item() as CompactionDisplayItem).messageId}
|
||||
showDeleteMessage={index === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
<Match when={item().type === "reasoning"}>
|
||||
<ReasoningCard
|
||||
part={(item as ReasoningDisplayItem).part}
|
||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
||||
part={(item() as ReasoningDisplayItem).part}
|
||||
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as ReasoningDisplayItem).messageId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
showDeleteMessage={index() === 0}
|
||||
messageId={(item() as ReasoningDisplayItem).messageId}
|
||||
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
|
||||
showDeleteMessage={index === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
@@ -916,7 +928,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</Index>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) {
|
||||
return null
|
||||
}
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant" || !info.tokens) {
|
||||
const part = props.part as any
|
||||
|
||||
// step-finish parts have tokens embedded; also check messageInfo
|
||||
const partTokens = part?.tokens
|
||||
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
|
||||
const tokens = partTokens ?? infoTokens
|
||||
if (!tokens) {
|
||||
return null
|
||||
}
|
||||
const tokens = info.tokens
|
||||
|
||||
return {
|
||||
input: tokens.input ?? 0,
|
||||
output: tokens.output ?? 0,
|
||||
reasoning: tokens.reasoning ?? 0,
|
||||
cacheRead: tokens.cache?.read ?? 0,
|
||||
cacheWrite: tokens.cache?.write ?? 0,
|
||||
cost: info.cost ?? 0,
|
||||
cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1293,14 +1311,23 @@ interface ReasoningCardProps {
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
function ReasoningStreamOutput(props: {
|
||||
text: Accessor<string>
|
||||
scrollTopSnapshot: Accessor<number>
|
||||
setScrollTopSnapshot: (next: number) => void
|
||||
onContentRendered?: () => void
|
||||
ariaLabel: string
|
||||
}) {
|
||||
let preRef: HTMLPreElement | undefined
|
||||
let pendingRenderNotificationFrame: number | null = null
|
||||
|
||||
const followScroll = createFollowScroll({
|
||||
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
|
||||
sentinelClassName: "reasoning-scroll-sentinel",
|
||||
})
|
||||
|
||||
const notifyContentRendered = () => {
|
||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
@@ -1312,6 +1339,15 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const nextText = props.text()
|
||||
if (preRef && preRef.textContent !== nextText) {
|
||||
preRef.textContent = nextText
|
||||
}
|
||||
followScroll.restoreAfterRender()
|
||||
notifyContentRendered()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||
@@ -1319,6 +1355,37 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={followScroll.registerContainer}
|
||||
class="message-reasoning-output"
|
||||
role="region"
|
||||
aria-label={props.ariaLabel}
|
||||
onScroll={followScroll.handleScroll}
|
||||
>
|
||||
<pre
|
||||
ref={(element) => {
|
||||
preRef = element || undefined
|
||||
if (preRef) {
|
||||
preRef.textContent = props.text() || ""
|
||||
}
|
||||
}}
|
||||
class="message-reasoning-text"
|
||||
dir="auto"
|
||||
/>
|
||||
{followScroll.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
})
|
||||
@@ -1393,12 +1460,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
createEffect(() => {
|
||||
if (!expanded()) return
|
||||
reasoningText()
|
||||
notifyContentRendered()
|
||||
})
|
||||
|
||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||
|
||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||
@@ -1553,9 +1614,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
<Show when={expanded()}>
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
||||
</div>
|
||||
<ReasoningStreamOutput
|
||||
text={reasoningText}
|
||||
scrollTopSnapshot={scrollTopSnapshot}
|
||||
setScrollTopSnapshot={setScrollTopSnapshot}
|
||||
onContentRendered={props.onContentRendered}
|
||||
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
||||
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
const getRawContent = () => {
|
||||
return props.parts
|
||||
.filter(part => part.type === "text")
|
||||
.map(part => (part as { text?: string }).text || "")
|
||||
.filter(text => text.trim().length > 0)
|
||||
.filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
|
||||
.map((part) => (part as { text?: string }).text || "")
|
||||
.filter((text) => text.trim().length > 0)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent() && !isGenerating()) {
|
||||
if (!hasContent() && !isGenerating()) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
const shouldHideTextPart = () => {
|
||||
const part = props.part
|
||||
if (!part || part.type !== "text") return false
|
||||
|
||||
const isSynthetic = Boolean((part as any).synthetic)
|
||||
if (!isSynthetic) return false
|
||||
|
||||
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
|
||||
if (props.messageType === "user") {
|
||||
const primaryId = props.primaryUserTextPartId
|
||||
if (!primaryId) return false
|
||||
return part.id !== primaryId
|
||||
}
|
||||
|
||||
// Hide synthetic assistant text.
|
||||
return true
|
||||
return Boolean((part as any).synthetic)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
||||
import { MoreHorizontal, Trash, X } from "lucide-solid"
|
||||
import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlock from "./message-block"
|
||||
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
||||
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
export interface MessageSectionProps {
|
||||
@@ -40,12 +42,40 @@ export interface MessageSectionProps {
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
const { preferences } = useConfig()
|
||||
const { preferences, updatePreferences } = useConfig()
|
||||
const { t } = useI18n()
|
||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
|
||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||
const visibleMessageIds = createMemo(() => {
|
||||
const resolvedStore = store()
|
||||
return messageIds().filter((messageId) => {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return false
|
||||
|
||||
if (buildTimelineSegments(props.instanceId, record, t).length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (record.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
const info = resolvedStore.getMessageInfo(messageId)
|
||||
if (!info || info.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (info.error) {
|
||||
return true
|
||||
}
|
||||
|
||||
const timeInfo = info.time as { created: number; end?: number } | undefined
|
||||
return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0))
|
||||
})
|
||||
})
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: props.instanceId,
|
||||
@@ -129,6 +159,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
return map
|
||||
})
|
||||
|
||||
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
|
||||
|
||||
const lastCompactionIndex = createMemo(() => {
|
||||
// Depend on a single session revision signal (not every message/part read)
|
||||
// to keep reactive overhead small.
|
||||
@@ -315,15 +347,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
}
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
for (let index = ids.length - 1; index >= 0; index--) {
|
||||
const record = resolvedStore.getMessage(ids[index])
|
||||
if (record?.role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
const messageId = lastAssistantMessageId()
|
||||
if (!messageId) return -1
|
||||
return messageIndexById().get(messageId) ?? -1
|
||||
})
|
||||
|
||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||
@@ -571,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||
|
||||
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
|
||||
// Only preferences should force a follow-token re-anchor. Message/session
|
||||
// revision churn at the end of a turn (message.updated, session.idle, etc.)
|
||||
// should not trigger an immediate scroll-to-bottom.
|
||||
const followToken = createMemo(() => preferenceSignature())
|
||||
|
||||
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
||||
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
||||
@@ -601,6 +630,42 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||
|
||||
const lastVisibleMessageId = createMemo(() => {
|
||||
const ids = visibleMessageIds()
|
||||
return ids[ids.length - 1] ?? null
|
||||
})
|
||||
|
||||
const autoPinHoldTargetKey = createMemo(() => {
|
||||
if (!holdLongAssistantRepliesEnabled()) return null
|
||||
const messageId = lastVisibleMessageId()
|
||||
return isStreamingAssistantTextMessage(messageId) ? messageId : null
|
||||
})
|
||||
|
||||
function toggleHoldLongAssistantReplies() {
|
||||
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
|
||||
}
|
||||
|
||||
function isStreamingAssistantTextMessage(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) => {
|
||||
if ((part as any)?.type !== "text") return false
|
||||
if (partHasRenderableText(part)) return true
|
||||
return typeof (part as { text?: unknown }).text === "string"
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const api = listApi()
|
||||
if (!api) return
|
||||
@@ -615,7 +680,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const api = listApi()
|
||||
if (!element || !api) return
|
||||
if (props.loading) return
|
||||
if (messageIds().length === 0) return
|
||||
if (visibleMessageIds().length === 0) return
|
||||
if (didRestoreScroll()) return
|
||||
|
||||
scrollCache.restore(element, {
|
||||
@@ -734,88 +799,93 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const loading = Boolean(props.loading)
|
||||
const ids = messageIds()
|
||||
|
||||
if (loading) {
|
||||
handleClearTimelineSelection()
|
||||
previousTimelineIds = []
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
pendingTimelineMessagePartUpdates.clear()
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length < previousTimelineIds.length) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === previousTimelineIds.length) {
|
||||
let changedIndex = -1
|
||||
let changeCount = 0
|
||||
for (let index = 0; index < ids.length; index++) {
|
||||
if (ids[index] !== previousTimelineIds[index]) {
|
||||
changedIndex = index
|
||||
changeCount += 1
|
||||
if (changeCount > 1) break
|
||||
// Wrap all iteration of the store-proxied `ids` array in untrack()
|
||||
// to prevent O(n) per-element reactive subscriptions. The effect
|
||||
// only needs to re-run when `messageIds` (memo) changes.
|
||||
untrack(() => {
|
||||
if (loading) {
|
||||
handleClearTimelineSelection()
|
||||
previousTimelineIds = []
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
pendingTimelineMessagePartUpdates.clear()
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (changeCount === 1 && changedIndex >= 0) {
|
||||
const oldId = previousTimelineIds[changedIndex]
|
||||
const newId = ids[changedIndex]
|
||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||
seenTimelineMessageIds.delete(oldId)
|
||||
seenTimelineMessageIds.add(newId)
|
||||
setTimelineSegments((prev) => {
|
||||
const next = prev.map((segment) => {
|
||||
if (segment.messageId !== oldId) return segment
|
||||
const updatedId = segment.id.replace(oldId, newId)
|
||||
return { ...segment, messageId: newId, id: updatedId }
|
||||
})
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
|
||||
// Keep part count tracking in sync with id replacement.
|
||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||
if (existingPartCount !== undefined) {
|
||||
timelinePartCountsByMessageId.delete(oldId)
|
||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = [...ids]
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length < previousTimelineIds.length) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = [...ids]
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === previousTimelineIds.length) {
|
||||
let changedIndex = -1
|
||||
let changeCount = 0
|
||||
for (let index = 0; index < ids.length; index++) {
|
||||
if (ids[index] !== previousTimelineIds[index]) {
|
||||
changedIndex = index
|
||||
changeCount += 1
|
||||
if (changeCount > 1) break
|
||||
}
|
||||
}
|
||||
if (changeCount === 1 && changedIndex >= 0) {
|
||||
const oldId = previousTimelineIds[changedIndex]
|
||||
const newId = ids[changedIndex]
|
||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||
seenTimelineMessageIds.delete(oldId)
|
||||
seenTimelineMessageIds.add(newId)
|
||||
setTimelineSegments((prev) => {
|
||||
const next = prev.map((segment) => {
|
||||
if (segment.messageId !== oldId) return segment
|
||||
const updatedId = segment.id.replace(oldId, newId)
|
||||
return { ...segment, messageId: newId, id: updatedId }
|
||||
})
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
// Keep part count tracking in sync with id replacement.
|
||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||
if (existingPartCount !== undefined) {
|
||||
timelinePartCountsByMessageId.delete(oldId)
|
||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||
}
|
||||
|
||||
previousTimelineIds = [...ids]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newIds: string[] = []
|
||||
ids.forEach((id) => {
|
||||
if (!seenTimelineMessageIds.has(id)) {
|
||||
newIds.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
if (newIds.length > 0) {
|
||||
newIds.forEach((id) => {
|
||||
seenTimelineMessageIds.add(id)
|
||||
appendTimelineForMessage(id)
|
||||
const newIds: string[] = []
|
||||
ids.forEach((id) => {
|
||||
if (!seenTimelineMessageIds.has(id)) {
|
||||
newIds.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
if (newIds.length > 0) {
|
||||
newIds.forEach((id) => {
|
||||
seenTimelineMessageIds.add(id)
|
||||
appendTimelineForMessage(id)
|
||||
})
|
||||
}
|
||||
|
||||
previousTimelineIds = [...ids]
|
||||
})
|
||||
})
|
||||
|
||||
function clearPendingTimelinePartUpdateFrame() {
|
||||
@@ -886,36 +956,49 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
createEffect(() => {
|
||||
if (props.loading) return
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
// Also re-run when sessionRevision bumps (covers part additions within
|
||||
// existing messages) but read individual records inside untrack() to
|
||||
// avoid creating O(n) fine-grained subscriptions.
|
||||
sessionRevision()
|
||||
|
||||
let hasChanges = false
|
||||
for (const messageId of ids) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
const partCount = record?.partIds.length ?? 0
|
||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||
// Wrap the iteration in untrack() so that accessing individual elements
|
||||
// of the store-proxied `ids` array does not create O(n) per-element
|
||||
// reactive subscriptions. We only need to re-run when the memo
|
||||
// (messageIds) or sessionRevision changes — not per-element.
|
||||
untrack(() => {
|
||||
const resolvedStore = store()
|
||||
const idsSet = new Set(ids)
|
||||
let hasChanges = false
|
||||
|
||||
if (previousCount === undefined) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
continue
|
||||
for (const messageId of ids) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
const partCount = record?.partIds.length ?? 0
|
||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||
|
||||
if (previousCount === undefined) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousCount !== partCount) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
pendingTimelineMessagePartUpdates.add(messageId)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
if (previousCount !== partCount) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
pendingTimelineMessagePartUpdates.add(messageId)
|
||||
hasChanges = true
|
||||
// Drop tracking for ids that are no longer present.
|
||||
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
|
||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||
if (!idsSet.has(trackedId)) {
|
||||
timelinePartCountsByMessageId.delete(trackedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop tracking for ids that are no longer present.
|
||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||
if (!ids.includes(trackedId)) {
|
||||
timelinePartCountsByMessageId.delete(trackedId)
|
||||
if (hasChanges) {
|
||||
scheduleTimelinePartUpdateFlush()
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
scheduleTimelinePartUpdateFlush()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -989,7 +1072,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
data-scroll-buttons={scrollButtonsCount()}
|
||||
>
|
||||
<VirtualFollowList
|
||||
items={messageIds}
|
||||
items={visibleMessageIds}
|
||||
getKey={(messageId) => messageId}
|
||||
getAnchorId={getMessageAnchorId}
|
||||
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
||||
@@ -1003,6 +1086,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
initialAutoScroll={initialAutoScroll}
|
||||
resetKey={() => props.sessionId}
|
||||
followToken={followToken}
|
||||
autoPinHoldTargetKey={autoPinHoldTargetKey}
|
||||
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
|
||||
resolveAutoPinHoldElement={(itemWrapper, key) => {
|
||||
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
|
||||
return candidates[candidates.length - 1] ?? null
|
||||
}}
|
||||
onScroll={() => {
|
||||
clearQuoteSelection()
|
||||
scrollCache.persist(streamElement())
|
||||
@@ -1033,9 +1122,55 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
||||
registerApi={(api) => setListApi(api)}
|
||||
registerState={(state) => setListState(state)}
|
||||
renderControls={(state, api) => (
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
|
||||
onClick={toggleHoldLongAssistantReplies}
|
||||
aria-label={
|
||||
holdLongAssistantRepliesEnabled()
|
||||
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||
}
|
||||
title={
|
||||
holdLongAssistantRepliesEnabled()
|
||||
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||
}
|
||||
>
|
||||
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<Show when={state.showScrollTopButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => api.scrollToTop()}
|
||||
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↑
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={state.showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => api.scrollToBottom()}
|
||||
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
renderBeforeItems={() => (
|
||||
<>
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<Show when={!props.loading && visibleMessageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
import { Portal } from "solid-js/web"
|
||||
import MessagePreview from "./message-preview"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import { isHiddenSyntheticTextPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
@@ -53,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
|
||||
const LONG_PRESS_MS = 500
|
||||
const JITTER_THRESHOLD = 10
|
||||
const ABSOLUTE_TOKEN_CAP = 10000
|
||||
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
@@ -65,6 +69,13 @@ interface PendingSegment {
|
||||
hasPrimaryText: boolean
|
||||
}
|
||||
|
||||
interface TimelineSegmentState {
|
||||
deleteHovered: boolean
|
||||
deleteSelected: boolean
|
||||
hasActivePermission: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
function truncateText(value: string): string {
|
||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||
return value
|
||||
@@ -105,6 +116,7 @@ function collectReasoningText(part: ClientPart): string {
|
||||
|
||||
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||
if (!part) return ""
|
||||
if (isHiddenSyntheticTextPart(part)) return ""
|
||||
if (typeof (part as any).text === "string") {
|
||||
return (part as any).text as string
|
||||
}
|
||||
@@ -349,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const clearHoverPreview = () => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
@@ -356,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||
closeTimer = window.setTimeout(() => {
|
||||
closeTimer = null
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
clearHoverPreview()
|
||||
}, 160)
|
||||
}
|
||||
|
||||
@@ -397,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
clearHoverPreview()
|
||||
})
|
||||
|
||||
// --- Selection & histogram rib state ---
|
||||
@@ -416,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let xrayOverlayRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -447,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (renderVirtualizedTimeline()) {
|
||||
if (hoveredSegment()) {
|
||||
clearHoverPreview()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isSelectionActive()) return
|
||||
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||
@@ -475,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
|
||||
|
||||
createEffect(on(renderVirtualizedTimeline, () => {
|
||||
clearHoverPreview()
|
||||
}))
|
||||
|
||||
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
||||
|
||||
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
||||
@@ -577,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
wasLongPress = true
|
||||
|
||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
@@ -629,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
|
||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||
if (!activeId) return
|
||||
const element = buttonRefs.get(activeId)
|
||||
if (!element) return
|
||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||
if (renderVirtualizedTimeline()) {
|
||||
const index = segmentIndexById().get(activeId)
|
||||
if (index !== undefined) {
|
||||
virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const element = buttonRefs.get(activeId)
|
||||
if (!element) return
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}, 120) : null
|
||||
onCleanup(() => {
|
||||
@@ -682,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
return map
|
||||
})
|
||||
|
||||
const segmentIndexById = createMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
|
||||
return map
|
||||
})
|
||||
|
||||
const segmentStates = createMemo(() => {
|
||||
const hover = deleteHover()
|
||||
const selectedMessages = props.selectedMessageIds?.()
|
||||
const expandedMessages = props.expandedMessageIds?.()
|
||||
const resolvedStore = store()
|
||||
const indexMap = messageIdToSessionIndex()
|
||||
const selectionActive = isSelectionActive()
|
||||
const result = new Map<string, TimelineSegmentState>()
|
||||
|
||||
for (const segment of props.segments) {
|
||||
let deleteHovered = false
|
||||
if (hover.kind === "message") {
|
||||
deleteHovered = hover.messageId === segment.messageId
|
||||
} else if (hover.kind === "deleteUpTo") {
|
||||
const targetIndex = indexMap.get(hover.messageId)
|
||||
const segmentIndex = indexMap.get(segment.messageId)
|
||||
deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
const deleteSelected = selectedMessages?.has(segment.messageId) ?? false
|
||||
|
||||
let hasActivePermission = false
|
||||
if (segment.type === "tool") {
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
for (const partId of partIds) {
|
||||
const permissionState = resolvedStore.getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) {
|
||||
hasActivePermission = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hidden = segment.type === "tool" && !(
|
||||
showTools()
|
||||
|| expandedMessages?.has(segment.messageId)
|
||||
|| selectionActive
|
||||
|| props.activeSegmentId === segment.id
|
||||
|| hasActivePermission
|
||||
|| deleteHovered
|
||||
|| deleteSelected
|
||||
)
|
||||
|
||||
result.set(segment.id, {
|
||||
deleteHovered,
|
||||
deleteSelected,
|
||||
hasActivePermission,
|
||||
hidden,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const segmentStateFor = (segmentId: string): TimelineSegmentState => {
|
||||
return segmentStates().get(segmentId) ?? {
|
||||
deleteHovered: false,
|
||||
deleteSelected: false,
|
||||
hasActivePermission: false,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
const segmentSpacerHeights = createMemo(() => {
|
||||
const states = segmentStates()
|
||||
const result = new Map<string, string>()
|
||||
let previousVisible: TimelineSegment | null = null
|
||||
|
||||
for (let index = 0; index < props.segments.length; index += 1) {
|
||||
const segment = props.segments[index]
|
||||
const state = states.get(segment.id)
|
||||
|
||||
if (state?.hidden) {
|
||||
result.set(segment.id, "0")
|
||||
continue
|
||||
}
|
||||
|
||||
if (!previousVisible) {
|
||||
result.set(segment.id, "0")
|
||||
previousVisible = segment
|
||||
continue
|
||||
}
|
||||
|
||||
const previousRaw = index > 0 ? props.segments[index - 1] : null
|
||||
const startsVisibleToolGroup = segment.type === "tool"
|
||||
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
|
||||
const startsCollapsedToolGroup = segment.type === "assistant"
|
||||
&& previousVisible.messageId !== segment.messageId
|
||||
&& messagesWithTools().has(segment.messageId)
|
||||
&& previousRaw?.type === "tool"
|
||||
&& previousRaw.messageId === segment.messageId
|
||||
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
|
||||
&& previousVisible.type === "assistant"
|
||||
&& messagesWithTools().has(previousVisible.messageId)
|
||||
|
||||
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
|
||||
result.set(
|
||||
segment.id,
|
||||
gapUnits === 1
|
||||
? "var(--message-timeline-segment-gap)"
|
||||
: "calc(var(--message-timeline-segment-gap) * 2)",
|
||||
)
|
||||
|
||||
previousVisible = segment
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-timeline-container">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
ref={(element) => {
|
||||
scrollContainerRef = element
|
||||
setScrollElement(element)
|
||||
}}
|
||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||
role="navigation"
|
||||
aria-label={t("messageTimeline.ariaLabel")}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<For each={props.segments}>
|
||||
{(segment, segIndex) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
<Show
|
||||
when={renderVirtualizedTimeline()}
|
||||
fallback={(
|
||||
<For each={props.segments}>
|
||||
{(segment, segIndex) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeSegmentId === segment.id
|
||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||
const state = () => segmentStateFor(segment.id)
|
||||
const isDeleteHovered = () => state().deleteHovered
|
||||
const isDeleteSelected = () => state().deleteSelected
|
||||
const hasActivePermission = () => state().hasActivePermission
|
||||
const isHidden = () => state().hidden
|
||||
|
||||
const groupRole = (): "child" | "parent" | "none" => {
|
||||
if (segment.type === "tool") return "child"
|
||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||
return "none"
|
||||
}
|
||||
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
}
|
||||
if (segment.type === "compaction") {
|
||||
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-timeline-item">
|
||||
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||
let anchorOffset: number | null = null
|
||||
if (stableBtn && scrollContainerRef) {
|
||||
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||
const desired = stableBtn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)}
|
||||
>
|
||||
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
|
||||
{(segment, index) => {
|
||||
const segIndex = () => index()
|
||||
const isActive = () => props.activeSegmentId === segment.id
|
||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||
|
||||
const isDeleteHovered = () => {
|
||||
const hover = deleteHover() as DeleteHoverState
|
||||
if (hover.kind === "message") {
|
||||
return hover.messageId === segment.messageId
|
||||
}
|
||||
|
||||
if (hover.kind === "deleteUpTo") {
|
||||
const indexMap = messageIdToSessionIndex()
|
||||
const targetIndex = indexMap.get(hover.messageId)
|
||||
if (targetIndex === undefined) return false
|
||||
const segmentIndex = indexMap.get(segment.messageId)
|
||||
if (segmentIndex === undefined) return false
|
||||
return segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const isDeleteSelected = () => {
|
||||
const selected = props.selectedMessageIds?.()
|
||||
if (!selected) return false
|
||||
return selected.has(segment.messageId)
|
||||
}
|
||||
|
||||
const hasActivePermission = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
if (partIds.length === 0) return false
|
||||
for (const partId of partIds) {
|
||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
||||
const isHidden = () =>
|
||||
segment.type === "tool" &&
|
||||
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
|
||||
const state = () => segmentStateFor(segment.id)
|
||||
const isDeleteHovered = () => state().deleteHovered
|
||||
const isDeleteSelected = () => state().deleteSelected
|
||||
const hasActivePermission = () => state().hasActivePermission
|
||||
const isHidden = () => state().hidden
|
||||
|
||||
// Group visual indicators: tools belong to the same message as their
|
||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||
@@ -744,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||
return "none"
|
||||
}
|
||||
const isGroupStart = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const idx = segIndex()
|
||||
const prev = idx > 0 ? props.segments[idx - 1] : null
|
||||
// First tool in the message's run: either nothing before, or previous
|
||||
// segment is from a different message or is not a tool.
|
||||
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
|
||||
}
|
||||
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
if (hasActivePermission()) {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
@@ -765,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
|
||||
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Capture scroll anchor before selection changes may toggle
|
||||
// tool segment visibility, which shifts timeline layout.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
// In selection mode, plain click scrolls to the message
|
||||
// instead of clearing. Selection is cleared by clicking
|
||||
// anywhere inside the chat container or pressing Esc.
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
// Restore scroll anchor: keep the clicked badge at the same
|
||||
// visual position after hidden tools appear or disappear.
|
||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
||||
const desired = btn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
return (
|
||||
<div class="message-timeline-item">
|
||||
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||
<button
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||
let anchorOffset: number | null = null
|
||||
if (stableBtn && scrollContainerRef) {
|
||||
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||
const desired = stableBtn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Virtualizer>
|
||||
</Show>
|
||||
<Show when={previewData()}>
|
||||
{(data) => {
|
||||
onCleanup(() => setTooltipElement(null))
|
||||
return (
|
||||
<div
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
/>
|
||||
</div>
|
||||
<Portal>
|
||||
<div
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
@@ -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, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
@@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
|
||||
@@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
async function handleBrowseBinary() {
|
||||
if (props.disabled) return
|
||||
setValidationError(null)
|
||||
if (nativeDialogsAvailable) {
|
||||
if (supportsNativeDialogsInCurrentWindow()) {
|
||||
const selected = await openNativeFileDialog({
|
||||
title: t("opencodeBinarySelector.dialog.title"),
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user