Compare commits
110 Commits
v0.13.3-de
...
no-more-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
105714778b | ||
|
|
c9eea8c003 | ||
|
|
25512e8dc1 | ||
|
|
f56d63d166 | ||
|
|
8173030b1a | ||
|
|
73a97e64ba | ||
|
|
a5f38ee625 | ||
|
|
ca880451e7 | ||
|
|
4af8cc08b9 | ||
|
|
b60d86116a | ||
|
|
76f14e2189 | ||
|
|
9ecd5131a6 | ||
|
|
95f47ebbe4 | ||
|
|
6c50564df6 | ||
|
|
166edd2e30 | ||
|
|
79dbbd4cb4 | ||
|
|
1c2ec1558e | ||
|
|
3b08bc3262 | ||
|
|
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).
|
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -212,7 +212,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
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
|
[ -f "$file" ] || continue
|
||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
@@ -313,7 +313,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
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
|
[ -f "$file" ] || continue
|
||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
@@ -324,7 +324,9 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
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 }}
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
@@ -370,7 +372,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
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
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -454,7 +456,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
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
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -540,7 +542,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
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
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -612,6 +614,7 @@ jobs:
|
|||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
xdg-utils \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libglib2.0-dev \
|
libglib2.0-dev \
|
||||||
libwebkit2gtk-4.1-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
@@ -640,6 +643,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
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
|
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
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
@@ -739,6 +743,7 @@ jobs:
|
|||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
xdg-utils \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
g++-aarch64-linux-gnu \
|
g++-aarch64-linux-gnu \
|
||||||
libgtk-3-dev:arm64 \
|
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:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
- ready_for_review
|
- ready_for_review
|
||||||
@@ -19,7 +20,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
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 }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
@@ -37,7 +38,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
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:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
PUBLISH_NPM_VERSION: 11.5.1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -59,8 +60,15 @@ jobs:
|
|||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
- name: Ensure npm >=11.5.1
|
- name: Prepare pinned npm CLI
|
||||||
run: npm install -g npm@latest
|
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
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
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:
|
pull_request:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
- ready_for_review
|
- ready_for_review
|
||||||
@@ -23,7 +24,7 @@ jobs:
|
|||||||
allowed: ${{ steps.auth.outputs.allowed }}
|
allowed: ${{ steps.auth.outputs.allowed }}
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
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 }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check PR authorization
|
- name: Check PR authorization
|
||||||
@@ -37,11 +38,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
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
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
|||||||
2
.github/workflows/release-ui.yml
vendored
2
.github/workflows/release-ui.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-ui:
|
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:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
- edited
|
||||||
- reopened
|
- reopened
|
||||||
- synchronize
|
- synchronize
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
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 }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
steps:
|
steps:
|
||||||
@@ -27,7 +28,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
|
||||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -50,5 +51,5 @@ jobs:
|
|||||||
- name: Fail unauthorized PR
|
- name: Fail unauthorized PR
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
run: |
|
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
|
exit 1
|
||||||
|
|||||||
2
.github/workflows/reusable-release.yml
vendored
2
.github/workflows/reusable-release.yml
vendored
@@ -39,7 +39,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-release:
|
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**
|
- **🎙️ Voice Input & Speech**
|
||||||
- **🌳 Git Worktrees**
|
- **🌳 Git Worktrees**
|
||||||
- **💬 Rich Message Experience**
|
- **💬 Rich Message Experience**
|
||||||
|
- **🧩 SideCars**
|
||||||
- **⌨️ Command Palette**
|
- **⌨️ Command Palette**
|
||||||
- **📁 File System Browser**
|
- **📁 File System Browser**
|
||||||
- **🔐 Authentication & Security**
|
- **🔐 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
|
## Requirements
|
||||||
|
|
||||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
- **[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",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -30,5 +30,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"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"
|
"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() }),
|
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(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
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 { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import https from "node:https"
|
import https from "node:https"
|
||||||
import { existsSync } from "fs"
|
import { existsSync, mkdirSync } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
@@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename)
|
|||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
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()
|
const cliManager = new CliProcessManager()
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
@@ -21,6 +46,8 @@ let pendingCliUrl: string | null = null
|
|||||||
let pendingBootstrapToken: string | null = null
|
let pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
app.commandLine.appendSwitch("disable-spell-checking")
|
app.commandLine.appendSwitch("disable-spell-checking")
|
||||||
@@ -91,10 +118,17 @@ function loadLoadingScreen(window: BrowserWindow) {
|
|||||||
loader.catch((error) => {
|
loader.catch((error) => {
|
||||||
console.error("[cli] failed to load loading screen:", 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>()
|
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]
|
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||||
for (const candidate of rendererCandidates) {
|
for (const candidate of rendererCandidates) {
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
@@ -109,13 +143,13 @@ function getAllowedRendererOrigins(): string[] {
|
|||||||
return Array.from(origins)
|
return Array.from(origins)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldOpenExternally(url: string): boolean {
|
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const allowedOrigins = getAllowedRendererOrigins()
|
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||||
return !allowedOrigins.includes(parsed.origin)
|
return !allowedOrigins.includes(parsed.origin)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -128,7 +162,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
handleExternal(url)
|
handleExternal(url)
|
||||||
return { action: "deny" }
|
return { action: "deny" }
|
||||||
}
|
}
|
||||||
@@ -136,13 +170,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
window.webContents.on("will-navigate", (event, url) => {
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
if (shouldOpenExternally(url)) {
|
if (shouldOpenExternally(url, window)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleExternal(url)
|
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
|
let cachedPreloadPath: string | null = null
|
||||||
function getPreloadPath() {
|
function getPreloadPath() {
|
||||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||||
@@ -207,36 +282,37 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
setupNavigationGuards(mainWindow)
|
const window = mainWindow
|
||||||
|
|
||||||
|
setupNavigationGuards(window)
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
loadLoadingScreen(mainWindow)
|
clearWindowAllowedOrigin(window)
|
||||||
|
const loadingReady = loadLoadingScreen(window)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
window.webContents.openDevTools({ mode: "detach" })
|
||||||
}
|
}
|
||||||
|
|
||||||
createApplicationMenu(mainWindow)
|
createApplicationMenu(window)
|
||||||
setupCliIPC(mainWindow, cliManager)
|
setupCliIPC(window, cliManager)
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
window.on("closed", () => {
|
||||||
destroyPreloadingView()
|
destroyPreloadingView()
|
||||||
|
clearWindowAllowedOrigin(window)
|
||||||
|
clearWindowInsecureOrigin(window)
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (pendingCliUrl) {
|
return loadingReady
|
||||||
const url = pendingCliUrl
|
|
||||||
pendingCliUrl = null
|
|
||||||
startCliPreload(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoadingScreen(force = false) {
|
function showLoadingScreen(force = false) {
|
||||||
@@ -322,13 +398,68 @@ function finalizeCliSwap(url: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const window = mainWindow
|
||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
currentCliUrl = url
|
currentCliUrl = url
|
||||||
|
setWindowAllowedOrigin(window, url)
|
||||||
pendingCliUrl = null
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
let bootstrapExchangeInFlight = false
|
||||||
|
|
||||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||||
@@ -351,6 +482,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||||
|
const sessionCookieName = cliManager.getAuthCookieName()
|
||||||
const target = new URL("/api/auth/token", baseUrl)
|
const target = new URL("/api/auth/token", baseUrl)
|
||||||
const body = JSON.stringify({ token })
|
const body = JSON.stringify({ token })
|
||||||
|
|
||||||
@@ -381,14 +513,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await session.defaultSession.cookies.set({
|
await session.defaultSession.cookies.set({
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
name: SESSION_COOKIE_NAME,
|
name: sessionCookieName,
|
||||||
value: sessionId,
|
value: sessionId,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -486,7 +618,8 @@ app.whenReady().then(() => {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
startCli()
|
const loadingReady = createWindow()
|
||||||
|
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
@@ -503,7 +636,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", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mainFilename = fileURLToPath(import.meta.url)
|
|||||||
const mainDirname = path.dirname(mainFilename)
|
const mainDirname = path.dirname(mainFilename)
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
@@ -37,7 +38,7 @@ interface StartOptions {
|
|||||||
|
|
||||||
interface CliEntryResolution {
|
interface CliEntryResolution {
|
||||||
entry: string
|
entry: string
|
||||||
runner: "node" | "tsx"
|
runner: "node" | "tsx" | "standalone"
|
||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
private bootstrapToken: string | null = null
|
private bootstrapToken: string | null = null
|
||||||
|
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
private requestedStop = false
|
private requestedStop = false
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
@@ -139,21 +141,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
this.bootstrapToken = null
|
this.bootstrapToken = null
|
||||||
|
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
|
|
||||||
let child: ManagedChild
|
let child: ManagedChild
|
||||||
|
|
||||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
|
||||||
const runtimePath = this.resolveShellNodeCommand()
|
|
||||||
const entryPath = this.resolveBundledProdEntry()
|
|
||||||
const supervisorPath = this.resolveCliSupervisorPath()
|
const supervisorPath = this.resolveCliSupervisorPath()
|
||||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
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({
|
const supervisorPayload = JSON.stringify({
|
||||||
command: shellCommand.command,
|
command: shellCommand.command,
|
||||||
args: shellCommand.args,
|
args: shellCommand.args,
|
||||||
@@ -161,28 +164,33 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.info(
|
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] utility supervisor: ${supervisorPath}`)
|
||||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||||
|
|
||||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||||
env: shellEnv,
|
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
serviceName: "CodeNomad CLI Supervisor",
|
serviceName: "CodeNomad CLI Supervisor",
|
||||||
})
|
})
|
||||||
this.childLaunchMode = "utility"
|
this.childLaunchMode = "utility"
|
||||||
} else {
|
} else {
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
|
||||||
console.info(
|
console.info(
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
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()
|
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)
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
@@ -436,6 +444,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAuthCookieName(): string {
|
||||||
|
return this.authCookieName
|
||||||
|
}
|
||||||
|
|
||||||
private resolveListeningMode(): ListeningMode {
|
private resolveListeningMode(): ListeningMode {
|
||||||
return readListeningModeFromConfig()
|
return readListeningModeFromConfig()
|
||||||
}
|
}
|
||||||
@@ -532,7 +544,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
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) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
@@ -556,6 +568,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||||
|
if (cliEntry.runner === "standalone") {
|
||||||
|
return this.buildExecutableCommand(cliEntry.entry, args)
|
||||||
|
}
|
||||||
|
|
||||||
const parts = [JSON.stringify(process.execPath)]
|
const parts = [JSON.stringify(process.execPath)]
|
||||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||||
@@ -570,6 +586,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
|
if (cliEntry.runner === "standalone") {
|
||||||
|
return { command: cliEntry.entry, args }
|
||||||
|
}
|
||||||
|
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
}
|
}
|
||||||
@@ -586,9 +606,8 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
const devEntry = this.resolveDevEntry()
|
const devEntry = this.resolveDevEntry()
|
||||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
const distEntry = this.resolveProdEntry()
|
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
|
||||||
return { entry: distEntry, runner: "node" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTsx(): string | null {
|
private resolveTsx(): string | null {
|
||||||
@@ -628,20 +647,25 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProdEntry(): string {
|
private resolveStandaloneProdEntry(): string {
|
||||||
try {
|
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
|
||||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
const candidates = [
|
||||||
if (existsSync(entry)) {
|
path.join(process.resourcesPath, "server", "dist", executableName),
|
||||||
return entry
|
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 {
|
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
|
||||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveCliSupervisorPath(): string {
|
private resolveCliSupervisorPath(): string {
|
||||||
@@ -659,26 +683,6 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
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 {
|
private describeUtilityProcessError(error: unknown): string {
|
||||||
if (error instanceof Error && error.message) {
|
if (error instanceof Error && error.message) {
|
||||||
return error.message
|
return error.message
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const electronAPI = {
|
|||||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
|
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "ai.opencode.client",
|
"appId": "ai.neuralnomads.codenomad.client",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release",
|
"output": "release",
|
||||||
@@ -147,6 +147,13 @@
|
|||||||
"x64",
|
"x64",
|
||||||
"arm64"
|
"arm64"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "AppImage",
|
||||||
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { spawn } from "child_process"
|
import { spawn } from "child_process"
|
||||||
import { existsSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import path, { join } from "path"
|
import path, { join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
@@ -14,6 +14,46 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
|||||||
const nodeModulesPath = join(appDir, "node_modules")
|
const nodeModulesPath = join(appDir, "node_modules")
|
||||||
const workspaceNodeModulesPath = join(workspaceRoot, "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 = {
|
const platforms = {
|
||||||
mac: {
|
mac: {
|
||||||
args: ["--mac", "--x64", "--arm64"],
|
args: ["--mac", "--x64", "--arm64"],
|
||||||
@@ -105,6 +145,8 @@ async function build(platform) {
|
|||||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ensureEsbuildPlatformBinary()
|
||||||
|
|
||||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const npmNodeExecPath = process.env.npm_node_execpath
|
|||||||
|
|
||||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "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) {
|
function log(message) {
|
||||||
console.log(`[prepare-resources] ${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() {
|
function ensureServerDependencies() {
|
||||||
if (fs.existsSync(serverDepsMarker)) {
|
if (fs.existsSync(serverDepsMarker)) {
|
||||||
return
|
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() {
|
function copyServerArtifacts() {
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
@@ -121,7 +195,9 @@ function stripNodeModuleBins() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
|
ensureStandaloneServerBuild()
|
||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
|
ensureEsbuildPlatformBinary()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.3.7"
|
"@opencode-ai/plugin": "1.14.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type BackgroundProcess = {
|
|||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BackgroundProcessNotificationRequest = {
|
||||||
|
sessionID: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
type BackgroundProcessOptions = {
|
type BackgroundProcessOptions = {
|
||||||
baseDir: string
|
baseDir: string
|
||||||
}
|
}
|
||||||
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
|
|||||||
args: {
|
args: {
|
||||||
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
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"),
|
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)
|
assertCommandWithinBase(args.command, options.baseDir)
|
||||||
|
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
|
||||||
|
? {
|
||||||
|
sessionID: context.sessionID,
|
||||||
|
directory: context.directory,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
const process = await request<BackgroundProcess>("", {
|
const process = await request<BackgroundProcess>("", {
|
||||||
method: "POST",
|
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}`
|
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",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"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": "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",
|
"build:ui": "npm run build --prefix ../ui",
|
||||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||||
@@ -25,16 +26,16 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^12.6.2",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^9.1.1",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^5.8.5",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"openai": "^6.27.0",
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^8.1.0",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/yauzl": "^2.10.0",
|
"@types/yauzl": "^2.10.0",
|
||||||
|
"bun": "^1.3.13",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"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
|
#!/usr/bin/env node
|
||||||
import { spawnSync } from "child_process"
|
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 path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
@@ -14,6 +14,67 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
|
|||||||
const npmExecPath = process.env.npm_execpath
|
const npmExecPath = process.env.npm_execpath
|
||||||
const npmNodeExecPath = process.env.npm_node_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)) {
|
if (!existsSync(sourceDir)) {
|
||||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
@@ -58,4 +119,14 @@ rmSync(targetDir, { recursive: true, force: true })
|
|||||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||||
cpSync(sourceDir, 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}`)
|
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||||
|
|||||||
@@ -81,6 +81,55 @@ export interface WorktreeMap {
|
|||||||
parentSessionWorktreeSlug: Record<string, string>
|
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 type LogLevel = "debug" | "info" | "warn" | "error"
|
||||||
|
|
||||||
export interface WorkspaceLogEntry {
|
export interface WorkspaceLogEntry {
|
||||||
@@ -170,6 +219,24 @@ export interface InstanceStreamEvent {
|
|||||||
[key: string]: unknown
|
[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 {
|
export interface BinaryRecord {
|
||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
@@ -244,12 +311,50 @@ export interface VoiceModeStateResponse {
|
|||||||
enabled: boolean
|
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 =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
|
| "sidecar.updated"
|
||||||
|
| "sidecar.removed"
|
||||||
| "storage.configChanged"
|
| "storage.configChanged"
|
||||||
| "storage.stateChanged"
|
| "storage.stateChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
@@ -262,6 +367,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
|
| { type: "sidecar.updated"; sidecar: SideCar }
|
||||||
|
| { type: "sidecar.removed"; sidecarId: string }
|
||||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
@@ -328,6 +435,8 @@ export interface ServerMeta {
|
|||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||||
|
|
||||||
|
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
|
||||||
|
|
||||||
export interface BackgroundProcess {
|
export interface BackgroundProcess {
|
||||||
id: string
|
id: string
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
@@ -340,6 +449,8 @@ export interface BackgroundProcess {
|
|||||||
stoppedAt?: string
|
stoppedAt?: string
|
||||||
exitCode?: number
|
exitCode?: number
|
||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
|
terminalReason?: BackgroundProcessTerminalReason
|
||||||
|
notifyEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundProcessListResponse {
|
export interface BackgroundProcessListResponse {
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
|||||||
password?: string
|
password?: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
|
cookieName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
private readonly authStore: AuthStore | null
|
private readonly authStore: AuthStore | null
|
||||||
private readonly tokenManager: TokenManager | null
|
private readonly tokenManager: TokenManager | null
|
||||||
private readonly sessionManager = new SessionManager()
|
private readonly sessionManager = new SessionManager()
|
||||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
private readonly cookieName: string
|
||||||
private readonly authEnabled: boolean
|
private readonly authEnabled: boolean
|
||||||
|
|
||||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||||
|
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||||
|
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
@@ -102,13 +104,18 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
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) {
|
if (!this.authEnabled) {
|
||||||
// When auth is disabled, treat all requests as authenticated.
|
// When auth is disabled, treat all requests as authenticated.
|
||||||
// We still return a stable username so callers can display it.
|
// We still return a stable username so callers can display it.
|
||||||
return { username: this.init.username, sessionId: "auth-disabled" }
|
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 sessionId = cookies[this.cookieName]
|
||||||
const session = this.sessionManager.getSession(sessionId)
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
if (!session) return null
|
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) {
|
function resolveAuthFilePath(configPath: string) {
|
||||||
const resolvedConfigPath = resolvePath(configPath)
|
const resolvedConfigPath = resolvePath(configPath)
|
||||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
|
|||||||
import type { EventBus } from "../events/bus"
|
import type { EventBus } from "../events/bus"
|
||||||
import type { WorkspaceManager } from "../workspaces/manager"
|
import type { WorkspaceManager } from "../workspaces/manager"
|
||||||
import type { Logger } from "../logger"
|
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 ROOT_DIR = ".codenomad/background_processes"
|
||||||
const INDEX_FILE = "index.json"
|
const INDEX_FILE = "index.json"
|
||||||
@@ -27,6 +27,31 @@ interface RunningProcess {
|
|||||||
outputPath: string
|
outputPath: string
|
||||||
exitPromise: Promise<void>
|
exitPromise: Promise<void>
|
||||||
workspaceId: string
|
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 {
|
export class BackgroundProcessManager {
|
||||||
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
|
|||||||
const records = await this.readIndex(workspaceId)
|
const records = await this.readIndex(workspaceId)
|
||||||
const enriched = await Promise.all(
|
const enriched = await Promise.all(
|
||||||
records.map(async (record) => ({
|
records.map(async (record) => ({
|
||||||
...record,
|
...this.toPublicProcess(record),
|
||||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
return enriched
|
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)
|
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new Error("Workspace not found")
|
throw new Error("Workspace not found")
|
||||||
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
|
|||||||
this.killProcessTree(child, "SIGTERM")
|
this.killProcessTree(child, "SIGTERM")
|
||||||
})
|
})
|
||||||
|
|
||||||
const record: BackgroundProcess = {
|
const record: PersistedBackgroundProcess = {
|
||||||
|
|
||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
title,
|
title,
|
||||||
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
|
|||||||
pid: child.pid,
|
pid: child.pid,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
outputSizeBytes: 0,
|
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) => {
|
const exitPromise = new Promise<void>((resolve) => {
|
||||||
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
|
|||||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||||
this.running.delete(id)
|
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.exitCode = code === null ? undefined : code
|
||||||
record.stoppedAt = new Date().toISOString()
|
record.stoppedAt = new Date().toISOString()
|
||||||
|
|
||||||
await this.upsertIndex(workspaceId, record)
|
await this.finalizeRecord(workspaceId, record, completion)
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
|
||||||
this.publishUpdate(workspaceId, record)
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
runningState.exitPromise = exitPromise
|
||||||
|
|
||||||
|
this.running.set(id, runningState)
|
||||||
|
|
||||||
let lastPublishAt = 0
|
let lastPublishAt = 0
|
||||||
const maybePublishSize = () => {
|
const maybePublishSize = () => {
|
||||||
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
|
|||||||
await this.upsertIndex(workspaceId, record)
|
await this.upsertIndex(workspaceId, record)
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||||
this.publishUpdate(workspaceId, record)
|
this.publishUpdate(workspaceId, record)
|
||||||
return record
|
return this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||||
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
|
running.completion = { reason: "user_stopped", endContext: "normal" }
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
|
const updated = await this.findProcess(workspaceId, processId)
|
||||||
|
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (record.status === "running") {
|
if (record.status === "running") {
|
||||||
record.status = "stopped"
|
record.status = "stopped"
|
||||||
|
record.terminalReason = "user_stopped"
|
||||||
record.stoppedAt = new Date().toISOString()
|
record.stoppedAt = new Date().toISOString()
|
||||||
await this.upsertIndex(workspaceId, record)
|
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
|
||||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
|
||||||
this.publishUpdate(workspaceId, record)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return record
|
return this.toPublicProcess(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||||
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
|
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeFromIndex(workspaceId, processId)
|
record.status = "stopped"
|
||||||
await this.removeProcessDir(workspaceId, processId)
|
record.terminalReason = "user_terminated"
|
||||||
|
record.stoppedAt = new Date().toISOString()
|
||||||
this.deps.eventBus.publish({
|
await this.finalizeRecord(workspaceId, record, {
|
||||||
type: "instance.event",
|
reason: "user_terminated",
|
||||||
instanceId: workspaceId,
|
endContext: "normal",
|
||||||
event: { type: "background.process.removed", properties: { processId } },
|
removeAfterFinalize: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
|
|||||||
private async cleanupWorkspace(workspaceId: string) {
|
private async cleanupWorkspace(workspaceId: string) {
|
||||||
for (const [, running] of this.running.entries()) {
|
for (const [, running] of this.running.entries()) {
|
||||||
if (running.workspaceId !== workspaceId) continue
|
if (running.workspaceId !== workspaceId) continue
|
||||||
|
running.completion = {
|
||||||
|
reason: "user_terminated",
|
||||||
|
endContext: "workspace_cleanup",
|
||||||
|
removeAfterFinalize: true,
|
||||||
|
}
|
||||||
this.killProcessTree(running.child, "SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
private completionFromExit(code: number | null): ProcessCompletion {
|
||||||
if (code === null) return "stopped"
|
if (code === 0) {
|
||||||
if (code === 0) return "stopped"
|
return { reason: "finished", endContext: "normal" }
|
||||||
return "error"
|
}
|
||||||
|
|
||||||
|
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> {
|
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)
|
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)
|
const records = await this.readIndex(workspaceId)
|
||||||
return records.find((entry) => entry.id === processId) ?? null
|
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)
|
const indexPath = await this.getIndexPath(workspaceId)
|
||||||
if (!existsSync(indexPath)) return []
|
if (!existsSync(indexPath)) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(indexPath, "utf-8")
|
const raw = await fs.readFile(indexPath, "utf-8")
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
|
||||||
const records = await this.readIndex(workspaceId)
|
const records = await this.readIndex(workspaceId)
|
||||||
const index = records.findIndex((entry) => entry.id === record.id)
|
const index = records.findIndex((entry) => entry.id === record.id)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
|
|||||||
await this.writeIndex(workspaceId, next)
|
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)
|
const indexPath = await this.getIndexPath(workspaceId)
|
||||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||||
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
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({
|
this.deps.eventBus.publish({
|
||||||
type: "instance.event",
|
type: "instance.event",
|
||||||
instanceId: workspaceId,
|
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 {
|
private generateId(): string {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||||
const random = randomBytes(3).toString("hex")
|
const random = randomBytes(3).toString("hex")
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
|||||||
showUsageMetrics: z.boolean().default(true),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
autoCleanupBlankSessions: z.boolean().default(true),
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
|
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||||
|
|
||||||
// OS notifications
|
// OS notifications
|
||||||
osNotificationsEnabled: z.boolean().default(false),
|
osNotificationsEnabled: z.boolean().default(false),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
|
this.on("sidecar.updated", handler)
|
||||||
|
this.on("sidecar.removed", handler)
|
||||||
this.on("storage.configChanged", handler)
|
this.on("storage.configChanged", handler)
|
||||||
this.on("storage.stateChanged", handler)
|
this.on("storage.stateChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
|
this.off("sidecar.updated", handler)
|
||||||
|
this.off("sidecar.removed", handler)
|
||||||
this.off("storage.configChanged", handler)
|
this.off("storage.configChanged", handler)
|
||||||
this.off("storage.stateChanged", handler)
|
this.off("storage.stateChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
|
|||||||
@@ -19,18 +19,24 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
|||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { resolveUi } from "./ui/remote-ui"
|
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 { 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 { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
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 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 __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
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 {
|
interface CliOptions {
|
||||||
host: string
|
host: string
|
||||||
@@ -55,6 +61,7 @@ interface CliOptions {
|
|||||||
launch: boolean
|
launch: boolean
|
||||||
authUsername: string
|
authUsername: string
|
||||||
authPassword?: string
|
authPassword?: string
|
||||||
|
authCookieName: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth: boolean
|
dangerouslySkipAuth: boolean
|
||||||
}
|
}
|
||||||
@@ -100,6 +107,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.default(DEFAULT_AUTH_USERNAME),
|
.default(DEFAULT_AUTH_USERNAME),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
.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(
|
.addOption(
|
||||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||||
.env("CODENOMAD_GENERATE_TOKEN")
|
.env("CODENOMAD_GENERATE_TOKEN")
|
||||||
@@ -139,6 +151,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch?: boolean
|
launch?: boolean
|
||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
|
authCookieName: string
|
||||||
generateToken?: boolean
|
generateToken?: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -185,6 +198,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch: Boolean(parsed.launch),
|
launch: Boolean(parsed.launch),
|
||||||
authUsername: parsed.username,
|
authUsername: parsed.username,
|
||||||
authPassword: parsed.password,
|
authPassword: parsed.password,
|
||||||
|
authCookieName: parsed.authCookieName,
|
||||||
generateToken: Boolean(parsed.generateToken),
|
generateToken: Boolean(parsed.generateToken),
|
||||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||||
}
|
}
|
||||||
@@ -266,6 +280,7 @@ async function main() {
|
|||||||
configPath: configLocation.configYamlPath,
|
configPath: configLocation.configYamlPath,
|
||||||
username: options.authUsername,
|
username: options.authUsername,
|
||||||
password: options.authPassword,
|
password: options.authPassword,
|
||||||
|
cookieName: options.authCookieName,
|
||||||
generateToken: options.generateToken,
|
generateToken: options.generateToken,
|
||||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||||
},
|
},
|
||||||
@@ -306,6 +321,11 @@ async function main() {
|
|||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||||
|
const sidecarManager = new SideCarManager({
|
||||||
|
settings,
|
||||||
|
eventBus,
|
||||||
|
logger: logger.child({ component: "sidecars" }),
|
||||||
|
})
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -357,12 +377,21 @@ async function main() {
|
|||||||
})
|
})
|
||||||
: null
|
: 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 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 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)
|
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||||
|
|
||||||
@@ -391,7 +420,12 @@ async function main() {
|
|||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
|
clientConnectionManager,
|
||||||
|
pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
|
remoteProxySessionManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
@@ -412,7 +446,12 @@ async function main() {
|
|||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
speechService,
|
||||||
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
|
clientConnectionManager,
|
||||||
|
pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
|
remoteProxySessionManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
logger,
|
logger,
|
||||||
@@ -442,18 +481,22 @@ async function main() {
|
|||||||
// which can lead clients to talk to the wrong process.
|
// which can lead clients to talk to the wrong process.
|
||||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||||
let remoteUrl: string | undefined
|
let remoteUrl: string | undefined
|
||||||
|
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||||
if (remoteStart) {
|
if (remoteStart) {
|
||||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
let remoteHost = options.host
|
let remoteHost = options.host
|
||||||
if (wantsAll) {
|
if (wantsAll) {
|
||||||
if (options.host === "0.0.0.0") {
|
if (options.host === "0.0.0.0") {
|
||||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
remoteAddresses = resolved.userVisible
|
||||||
|
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
remoteHost = "localhost"
|
remoteHost = "localhost"
|
||||||
}
|
}
|
||||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
if (!remoteUrl) {
|
||||||
|
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serverMeta.localUrl = localUrl
|
serverMeta.localUrl = localUrl
|
||||||
@@ -464,7 +507,9 @@ async function main() {
|
|||||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||||
|
|
||||||
if (serverMeta.remotePort && remoteUrl) {
|
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 {
|
} else {
|
||||||
serverMeta.addresses = []
|
serverMeta.addresses = []
|
||||||
}
|
}
|
||||||
@@ -472,6 +517,16 @@ async function main() {
|
|||||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||||
if (serverMeta.remoteUrl) {
|
if (serverMeta.remoteUrl) {
|
||||||
console.log(`Remote Connection URL : ${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) {
|
if (options.launch) {
|
||||||
@@ -495,6 +550,18 @@ async function main() {
|
|||||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
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 {
|
try {
|
||||||
await workspaceManager.shutdown()
|
await workspaceManager.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import path from "path"
|
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
|
import { resolveOpencodeTemplateDir } from "./runtime-paths"
|
||||||
|
|
||||||
const log = createLogger({ component: "opencode-config" })
|
const log = createLogger({ component: "opencode-config" })
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const templateDir = resolveOpencodeTemplateDir(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
|
||||||
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
|
|
||||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
|
||||||
const prodTemplateDirs = [
|
|
||||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
|
|
||||||
path.resolve(__dirname, "opencode-config"),
|
|
||||||
].filter((dir): dir is string => Boolean(dir))
|
|
||||||
|
|
||||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
|
||||||
const templateDir = isDevBuild
|
|
||||||
? devTemplateDir
|
|
||||||
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
|
|
||||||
|
|
||||||
export function getOpencodeConfigDir(): string {
|
export function getOpencodeConfigDir(): string {
|
||||||
if (!existsSync(templateDir)) {
|
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)) {
|
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||||
this.options.logger.debug(
|
this.options.logger.debug(
|
||||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||||
"Ignoring voice mode enable for disconnected client connection",
|
"Ignoring voice mode enable for disconnected client connection",
|
||||||
)
|
)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = getConnectionKey(connection)
|
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.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||||
this.publishIfChanged(instanceId)
|
this.publishIfChanged(instanceId)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
syncInstance(instanceId: string): void {
|
syncInstance(instanceId: string): void {
|
||||||
@@ -76,7 +77,10 @@ export class VoiceModeManager {
|
|||||||
this.aggregateByInstance.delete(instanceId)
|
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))
|
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 fastifyStatic from "@fastify/static"
|
||||||
import replyFrom from "@fastify/reply-from"
|
import replyFrom from "@fastify/reply-from"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { connect as connectTcp, type Socket } from "net"
|
||||||
import path from "path"
|
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 { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
|
||||||
|
|
||||||
import type { SettingsService } from "../settings/service"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
@@ -22,6 +27,9 @@ import { registerPluginRoutes } from "./routes/plugin"
|
|||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
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 { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
@@ -32,6 +40,8 @@ import type { SpeechService } from "../speech/service"
|
|||||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||||
import { PluginChannelManager } from "../plugins/channel"
|
import { PluginChannelManager } from "../plugins/channel"
|
||||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||||
|
import type { SideCarManager } from "../sidecars/manager"
|
||||||
|
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -47,7 +57,12 @@ interface HttpServerDeps {
|
|||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
speechService: SpeechService
|
speechService: SpeechService
|
||||||
|
sidecarManager: SideCarManager
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
|
clientConnectionManager: ClientConnectionManager
|
||||||
|
pluginChannel: PluginChannelManager
|
||||||
|
voiceModeManager: VoiceModeManager
|
||||||
|
remoteProxySessionManager: RemoteProxySessionManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -176,13 +191,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: deps.logger.child({ component: "background-processes" }),
|
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 })
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
@@ -196,14 +204,19 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
publicPagePaths.add("/auth/token")
|
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()
|
done()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = deps.authManager.getSessionFromRequest(request)
|
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) {
|
if (requiresAuthForApi && !session) {
|
||||||
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||||
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||||
@@ -262,7 +275,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
registerClient: registerSseClient,
|
registerClient: registerSseClient,
|
||||||
logger: sseLogger,
|
logger: sseLogger,
|
||||||
connectionManager: clientConnectionManager,
|
connectionManager: deps.clientConnectionManager,
|
||||||
})
|
})
|
||||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
@@ -270,13 +283,22 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
|
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||||
|
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
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, {
|
registerPluginRoutes(app, {
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: proxyLogger,
|
logger: proxyLogger,
|
||||||
channel: pluginChannel,
|
channel: deps.pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager: deps.voiceModeManager,
|
||||||
})
|
})
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
@@ -342,7 +364,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
closeSseClients()
|
closeSseClients()
|
||||||
clientConnectionManager.shutdown()
|
|
||||||
return app.close()
|
return app.close()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -353,6 +374,68 @@ interface InstanceProxyDeps {
|
|||||||
logger: Logger
|
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) {
|
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
|
||||||
app.register(async (instance) => {
|
app.register(async (instance) => {
|
||||||
instance.removeAllContentTypeParsers()
|
instance.removeAllContentTypeParsers()
|
||||||
@@ -545,57 +628,57 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.from(targetUrl, {
|
const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
|
||||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
|
||||||
if (instanceAuthHeader) {
|
|
||||||
headers.authorization = instanceAuthHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
if (logger.isLevelEnabled("trace")) {
|
||||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
logger.trace(
|
||||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
{
|
||||||
|
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).
|
const init: any = {
|
||||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
redirect: "manual",
|
||||||
|
}
|
||||||
|
|
||||||
if (logger.isLevelEnabled("trace")) {
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
const outgoing: Record<string, unknown> = {}
|
const body = toProxyRequestBody(request.body)
|
||||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
if (body !== undefined) {
|
||||||
outgoing[key] = value
|
init.body = body
|
||||||
}
|
init.duplex = "half"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redact sensitive headers.
|
try {
|
||||||
for (const key of Object.keys(outgoing)) {
|
const response = await fetch(targetUrl, init)
|
||||||
const lower = key.toLowerCase()
|
reply.code(response.status)
|
||||||
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
applyInstanceProxyResponseHeaders(reply, response)
|
||||||
outgoing[key] = "<redacted>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.trace(
|
if (!response.body || request.method === "HEAD") {
|
||||||
{
|
reply.send()
|
||||||
workspaceId,
|
return
|
||||||
method: request.method,
|
}
|
||||||
targetUrl,
|
|
||||||
worktreeSlug,
|
|
||||||
directory,
|
|
||||||
contentType: request.headers["content-type"],
|
|
||||||
body: bodyToJson(request.body),
|
|
||||||
headers: outgoing,
|
|
||||||
},
|
|
||||||
"Proxy -> OpenCode request",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
reply.hijack()
|
||||||
},
|
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
|
||||||
onError: (proxyReply, { error }) => {
|
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
|
||||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
} catch (error) {
|
||||||
if (!proxyReply.sent) {
|
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||||
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
|
if (!reply.sent) {
|
||||||
}
|
reply.code(502).send({ error: "Workspace instance proxy failed" })
|
||||||
},
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||||
@@ -689,52 +772,6 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|||||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
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) {
|
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||||
if (!uiDir) {
|
if (!uiDir) {
|
||||||
app.log.warn("UI static directory not provided; API endpoints only")
|
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> {
|
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
||||||
const result: Record<string, string> = {}
|
const result: Record<string, string> = {}
|
||||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
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
|
result[key] = Array.isArray(value) ? value.join(",") : value
|
||||||
}
|
}
|
||||||
return result
|
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 os from "os"
|
||||||
import type { NetworkAddress } from "../api-types"
|
import type { NetworkAddress } from "../api-types"
|
||||||
|
|
||||||
|
export interface ResolvedRemoteAddresses {
|
||||||
|
all: NetworkAddress[]
|
||||||
|
userVisible: NetworkAddress[]
|
||||||
|
primaryRemoteUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveNetworkAddresses(args: {
|
export function resolveNetworkAddresses(args: {
|
||||||
host: string
|
host: string
|
||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
|||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||||
if (scopeDelta !== 0) return scopeDelta
|
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 {
|
function isIPv4Address(value: string | undefined): value is string {
|
||||||
if (!value) return false
|
if (!value) return false
|
||||||
const parts = value.split(".")
|
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 { z } from "zod"
|
||||||
import type { AuthManager } from "../../auth/manager"
|
import type { AuthManager } from "../../auth/manager"
|
||||||
import { isLoopbackAddress } from "../../auth/http-auth"
|
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||||
|
import { resolveAuthTemplatePath } from "../../runtime-paths"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
@@ -21,21 +22,21 @@ const PasswordSchema = z.object({
|
|||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
})
|
})
|
||||||
|
|
||||||
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
|
||||||
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
|
||||||
|
|
||||||
let cachedLoginTemplate: string | null = null
|
let cachedLoginTemplate: string | null = null
|
||||||
let cachedTokenTemplate: 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
|
if (cache) return cache
|
||||||
const content = fs.readFileSync(url, "utf-8")
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLoginHtml(defaultUsername: string): string {
|
function getLoginHtml(defaultUsername: string): string {
|
||||||
if (!cachedLoginTemplate) {
|
if (!cachedLoginTemplate) {
|
||||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapedUsername = escapeHtml(defaultUsername)
|
const escapedUsername = escapeHtml(defaultUsername)
|
||||||
@@ -44,7 +45,7 @@ function getLoginHtml(defaultUsername: string): string {
|
|||||||
|
|
||||||
function getTokenHtml(): string {
|
function getTokenHtml(): string {
|
||||||
if (!cachedTokenTemplate) {
|
if (!cachedTokenTemplate) {
|
||||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedTokenTemplate
|
return cachedTokenTemplate
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ interface RouteDeps {
|
|||||||
const StartSchema = z.object({
|
const StartSchema = z.object({
|
||||||
title: z.string().trim().min(1),
|
title: z.string().trim().min(1),
|
||||||
command: 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({
|
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) => {
|
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
||||||
const payload = StartSchema.parse(request.body ?? {})
|
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)
|
reply.code(201)
|
||||||
return process
|
return process
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { ServerMeta } from "../../api-types"
|
import { ServerMeta } from "../../api-types"
|
||||||
import { resolveNetworkAddresses } from "../network-addresses"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||||
const localPort = resolveLocalPort(meta)
|
const localPort = resolveLocalPort(meta)
|
||||||
const remote = resolveRemote(meta)
|
const remote = resolveRemote(meta)
|
||||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
localPort,
|
localPort,
|
||||||
remotePort: remote?.port,
|
remotePort: remote?.port,
|
||||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
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 ?? {})
|
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||||
deps.voiceModeManager.setEnabled(
|
const applied = deps.voiceModeManager.setEnabled(
|
||||||
request.params.id,
|
request.params.id,
|
||||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||||
payload.enabled,
|
payload.enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (payload.enabled && !applied) {
|
||||||
|
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return { enabled: payload.enabled }
|
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
|
||||||
|
}
|
||||||
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 { FastifyInstance, FastifyReply } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { WorkspaceManager } from "../../workspaces/manager"
|
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 {
|
interface RouteDeps {
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
@@ -23,6 +27,20 @@ const WorkspaceFileContentBodySchema = z.object({
|
|||||||
contents: z.string(),
|
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({
|
const WorkspaceFileSearchQuerySchema = z.object({
|
||||||
q: z.string().trim().min(1, "Query is required"),
|
q: z.string().trim().min(1, "Query is required"),
|
||||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||||
@@ -118,10 +136,138 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return handleWorkspaceError(error, reply)
|
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) {
|
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") {
|
if (error instanceof Error && error.message === "Workspace not found") {
|
||||||
reply.code(404)
|
reply.code(404)
|
||||||
return { error: "Workspace not found" }
|
return { error: "Workspace not found" }
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
|||||||
if (typeof listeningMode === "string") {
|
if (typeof listeningMode === "string") {
|
||||||
serverConfig.listeningMode = listeningMode
|
serverConfig.listeningMode = listeningMode
|
||||||
}
|
}
|
||||||
|
const logLevel = preferences.logLevel
|
||||||
|
if (typeof logLevel === "string") {
|
||||||
|
serverConfig.logLevel = logLevel
|
||||||
|
}
|
||||||
const lastUsedBinary = preferences.lastUsedBinary
|
const lastUsedBinary = preferences.lastUsedBinary
|
||||||
if (typeof lastUsedBinary === "string") {
|
if (typeof lastUsedBinary === "string") {
|
||||||
serverConfig.opencodeBinary = lastUsedBinary
|
serverConfig.opencodeBinary = lastUsedBinary
|
||||||
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
|||||||
const moved = new Set([
|
const moved = new Set([
|
||||||
"environmentVariables",
|
"environmentVariables",
|
||||||
"listeningMode",
|
"listeningMode",
|
||||||
|
"logLevel",
|
||||||
"lastUsedBinary",
|
"lastUsedBinary",
|
||||||
"modelRecents",
|
"modelRecents",
|
||||||
"modelFavorites",
|
"modelFavorites",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
import type { EventBus } from "../events/bus"
|
import type { EventBus } from "../events/bus"
|
||||||
import type { ConfigLocation } from "../config/location"
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { z } from "zod"
|
||||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
import { migrateSettingsLayout } from "./migrate"
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
import type { WorkspaceEventPayload } from "../api-types"
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
|
|||||||
|
|
||||||
export type DocKind = "config" | "state"
|
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 {
|
export class SettingsService {
|
||||||
private readonly configStore: YamlDocStore
|
private readonly configStore: YamlDocStore
|
||||||
private readonly stateStore: YamlDocStore
|
private readonly stateStore: YamlDocStore
|
||||||
@@ -23,22 +72,44 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDoc(kind: DocKind): SettingsDoc {
|
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 {
|
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, "*")
|
this.publish(kind, "*")
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
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 {
|
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||||
const updated =
|
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)
|
this.publish(kind, owner, updated)
|
||||||
return 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
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
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> {
|
function runGit(args: string[], cwd: string): Promise<GitResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
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 }> {
|
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
|
||||||
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
|
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) {
|
if (!result.ok) {
|
||||||
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
|
||||||
return { repoRoot: folder, isGitRepo: false }
|
return { repoRoot: folder, isGitRepo: false }
|
||||||
@@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
|
|||||||
return { repoRoot, isGitRepo: true }
|
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 }> {
|
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 records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
|
||||||
const lines = output.split(/\r?\n/)
|
const lines = output.split(/\r?\n/)
|
||||||
@@ -90,15 +102,22 @@ export async function listWorktrees(params: {
|
|||||||
logger?: LogLike
|
logger?: LogLike
|
||||||
}): Promise<WorktreeDescriptor[]> {
|
}): Promise<WorktreeDescriptor[]> {
|
||||||
const { repoRoot, workspaceFolder, logger } = params
|
const { repoRoot, workspaceFolder, logger } = params
|
||||||
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
|
|
||||||
|
|
||||||
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
|
||||||
if (!result.ok) {
|
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")
|
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
|
||||||
return [rootDescriptor]
|
return [rootDescriptor]
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = parseWorktreePorcelain(result.stdout)
|
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 worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||||
const seen = new Set<string>(["root"])
|
const seen = new Set<string>(["root"])
|
||||||
|
|||||||
@@ -21,6 +21,70 @@ import {
|
|||||||
|
|
||||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
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 {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
settings: SettingsService
|
settings: SettingsService
|
||||||
@@ -142,12 +206,15 @@ export class WorkspaceManager {
|
|||||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logLevel = (serverConfig as any)?.logLevel
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: resolvedBinaryPath,
|
binaryPath: resolvedBinaryPath,
|
||||||
environment,
|
environment,
|
||||||
|
logLevel,
|
||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
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")
|
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
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ interface LaunchOptions {
|
|||||||
folder: string
|
folder: string
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
environment?: Record<string, string>
|
environment?: Record<string, string>
|
||||||
|
logLevel?: string
|
||||||
onExit?: (info: ProcessExitInfo) => void
|
onExit?: (info: ProcessExitInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
|
|||||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||||
this.validateFolder(options.folder)
|
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 ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||||
|
|||||||
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -408,6 +430,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -444,6 +468,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -456,17 +486,28 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64 0.22.1",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"keepawake",
|
"keepawake",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"regex",
|
"regex",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
@@ -476,8 +517,8 @@ dependencies = [
|
|||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"thiserror 1.0.69",
|
|
||||||
"url",
|
"url",
|
||||||
|
"webkit2gtk",
|
||||||
"which",
|
"which",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
@@ -969,6 +1010,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1139,6 +1189,12 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1379,8 +1435,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1390,9 +1448,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1574,6 +1634,25 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1699,6 +1778,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1710,6 +1790,23 @@ dependencies = [
|
|||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1999,6 +2096,16 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -2157,6 +2264,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2995,6 +3108,61 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -3212,6 +3380,50 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3242,7 +3454,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.5.0",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3270,6 +3482,20 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -3311,6 +3537,44 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3531,6 +3795,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.18.0"
|
version = "3.18.0"
|
||||||
@@ -3792,6 +4068,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3943,7 +4225,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -4367,6 +4649,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.50.0"
|
version = "1.50.0"
|
||||||
@@ -4381,6 +4678,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -4691,6 +4998,12 @@ version = "0.2.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4902,6 +5215,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -4937,6 +5263,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -4993,6 +5329,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -5286,6 +5631,15 @@ dependencies = [
|
|||||||
"windows-targets 0.48.5",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -5927,6 +6281,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.10.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const serverDevInstallCommand =
|
|||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"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 serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||||
|
const serverStandaloneBuildCommand = "npm run build:standalone --workspace @neuralnomads/codenomad"
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -37,6 +38,12 @@ const braceExpansionPath = path.join(
|
|||||||
"package.json",
|
"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")
|
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||||
|
|
||||||
async function ensureMonacoAssets() {
|
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() {
|
function ensureUiBuild() {
|
||||||
const loadingHtml = path.join(uiDist, "loading.html")
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
if (fs.existsSync(loadingHtml)) {
|
if (fs.existsSync(loadingHtml)) {
|
||||||
@@ -98,7 +114,7 @@ function syncServerUiBundle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureServerDevDependencies() {
|
function ensureServerDevDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,15 +127,19 @@ function ensureServerDevDependencies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureServerDependencies() {
|
function ensureServerDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
console.log("[prebuild] pruning server to production dependencies...")
|
||||||
return
|
execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[prebuild] ensuring server production dependencies...")
|
|
||||||
execSync(serverInstallCommand, {
|
|
||||||
cwd: serverRoot,
|
cwd: serverRoot,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!fs.existsSync(braceExpansionPath)) {
|
||||||
|
console.log("[prebuild] restoring missing server production dependencies...")
|
||||||
|
execSync(serverInstallCommand, {
|
||||||
|
cwd: serverRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureUiDevDependencies() {
|
function ensureUiDevDependencies() {
|
||||||
@@ -142,6 +162,7 @@ function ensureRollupPlatformBinary() {
|
|||||||
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
||||||
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
||||||
"darwin-x64": "@rollup/rollup-darwin-x64",
|
"darwin-x64": "@rollup/rollup-darwin-x64",
|
||||||
|
"win32-arm64": "@rollup/rollup-win32-arm64-msvc",
|
||||||
"win32-x64": "@rollup/rollup-win32-x64-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() {
|
function copyServerArtifacts() {
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
@@ -249,8 +311,10 @@ function copyUiLoadingAssets() {
|
|||||||
ensureUiDevDependencies()
|
ensureUiDevDependencies()
|
||||||
await ensureMonacoAssets()
|
await ensureMonacoAssets()
|
||||||
ensureRollupPlatformBinary()
|
ensureRollupPlatformBinary()
|
||||||
ensureServerDependencies()
|
ensureEsbuildPlatformBinary()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
|
ensureStandaloneServerBuild()
|
||||||
|
ensureServerDependencies()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
syncServerUiBundle()
|
syncServerUiBundle()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.2", features = [] }
|
tauri-build = { version = "2.5.6", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
tauri = { version = "2.10.1", features = [ "devtools"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
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"
|
regex = "1"
|
||||||
once_cell = "1"
|
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
thiserror = "1"
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
@@ -28,4 +29,7 @@ url = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[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",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"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`",
|
"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",
|
"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 serde_json::json;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::ffi::c_void;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::mem::{size_of, zeroed};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::process::CommandExt;
|
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::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
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};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::os::windows::process::CommandExt;
|
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)]
|
#[cfg(windows)]
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
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) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
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;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -124,7 +216,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
|||||||
Some(value.to_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 parsed = Url::parse(base_url)?;
|
||||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
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 {
|
for line in lines {
|
||||||
// handle case-insensitive header name
|
// handle case-insensitive header name
|
||||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
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));
|
return Ok(Some(session_id));
|
||||||
}
|
}
|
||||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
} 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));
|
return Ok(Some(session_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,11 +268,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
|||||||
Ok(None)
|
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 parsed = Url::parse(base_url)?;
|
||||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
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)
|
.domain(domain)
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
@@ -190,6 +291,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
|||||||
Ok(())
|
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";
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -344,6 +455,8 @@ impl Default for CliStatus {
|
|||||||
pub struct CliProcessManager {
|
pub struct CliProcessManager {
|
||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
|
#[cfg(windows)]
|
||||||
|
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
@@ -353,6 +466,8 @@ impl CliProcessManager {
|
|||||||
Self {
|
Self {
|
||||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
child: Arc::new(Mutex::new(None)),
|
child: Arc::new(Mutex::new(None)),
|
||||||
|
#[cfg(windows)]
|
||||||
|
job: Arc::new(Mutex::new(None)),
|
||||||
ready: Arc::new(AtomicBool::new(false)),
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
@@ -375,6 +490,8 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let status_arc = self.status.clone();
|
let status_arc = self.status.clone();
|
||||||
let child_arc = self.child.clone();
|
let child_arc = self.child.clone();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let job_arc = self.job.clone();
|
||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
let token_arc = self.bootstrap_token.clone();
|
let token_arc = self.bootstrap_token.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@@ -382,6 +499,8 @@ impl CliProcessManager {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
status_arc.clone(),
|
status_arc.clone(),
|
||||||
child_arc,
|
child_arc,
|
||||||
|
#[cfg(windows)]
|
||||||
|
job_arc,
|
||||||
ready_flag,
|
ready_flag,
|
||||||
token_arc,
|
token_arc,
|
||||||
dev,
|
dev,
|
||||||
@@ -401,11 +520,12 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> anyhow::Result<()> {
|
pub fn stop(&self) -> anyhow::Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let _job = self.job.lock().take();
|
||||||
|
|
||||||
let mut child_opt = self.child.lock();
|
let mut child_opt = self.child.lock();
|
||||||
if let Some(mut child) = child_opt.take() {
|
if let Some(mut child) = child_opt.take() {
|
||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(windows)]
|
|
||||||
let mut forced_tree_shutdown = false;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
let pid = child.id() as i32;
|
||||||
@@ -427,18 +547,16 @@ impl CliProcessManager {
|
|||||||
Ok(Some(_)) => break,
|
Ok(Some(_)) => break,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if !forced_tree_shutdown
|
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
|
||||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
|
||||||
{
|
|
||||||
log_line(&format!(
|
log_line(&format!(
|
||||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||||
child.id()
|
child.id()
|
||||||
));
|
));
|
||||||
forced_tree_shutdown = true;
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||||
@@ -457,11 +575,7 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !forced_tree_shutdown
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
&& !kill_process_tree_windows(child.id(), true)
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
} else if forced_tree_shutdown {
|
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,6 +586,9 @@ impl CliProcessManager {
|
|||||||
Err(_) => break,
|
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();
|
let mut status = self.status.lock();
|
||||||
@@ -492,6 +609,7 @@ impl CliProcessManager {
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child_holder: Arc<Mutex<Option<Child>>>,
|
child_holder: Arc<Mutex<Option<Child>>>,
|
||||||
|
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
dev: bool,
|
dev: bool,
|
||||||
@@ -503,44 +621,61 @@ impl CliProcessManager {
|
|||||||
"resolved CLI entry runner={:?} entry={} host={}",
|
"resolved CLI entry runner={:?} entry={} host={}",
|
||||||
resolution.runner, resolution.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));
|
log_line(&format!("CLI args: {:?}", args));
|
||||||
if dev {
|
if dev {
|
||||||
log_line("development mode: will prefer tsx + source if present");
|
log_line("development mode: will prefer tsx + source if present");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cwd = workspace_root();
|
let cwd = launch_cwd();
|
||||||
if let Some(ref c) = cwd {
|
if let Some(ref c) = cwd {
|
||||||
log_line(&format!("using cwd={}", c.display()));
|
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");
|
log_line("spawning via user shell");
|
||||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
} else {
|
} 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 {
|
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),
|
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 {
|
let child = match &command_info {
|
||||||
ShellCommandType::UserShell(cmd) => {
|
ShellCommandType::UserShell(cmd) => {
|
||||||
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||||
let mut c = Command::new(&cmd.shell);
|
let mut c = Command::new(&cmd.shell);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env_remove("npm_config_prefix")
|
||||||
|
.env_remove("NPM_CONFIG_PREFIX")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
if resolution.runner != Runner::Standalone {
|
||||||
|
c.env("ELECTRON_RUN_AS_NODE", "1");
|
||||||
|
}
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
@@ -553,9 +688,11 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
||||||
let mut c = Command::new(&cmd.program);
|
let mut c = Command::new(&cmd.program);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
if resolution.runner != Runner::Standalone {
|
||||||
|
c.env("ELECTRON_RUN_AS_NODE", "1");
|
||||||
|
}
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
@@ -568,6 +705,22 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
log_line(&format!("spawned pid={pid}"));
|
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();
|
let mut locked = status.lock();
|
||||||
locked.pid = Some(pid);
|
locked.pid = Some(pid);
|
||||||
@@ -584,6 +737,7 @@ impl CliProcessManager {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
let token_clone = bootstrap_token.clone();
|
let token_clone = bootstrap_token.clone();
|
||||||
|
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let stdout = child_clone
|
let stdout = child_clone
|
||||||
@@ -598,24 +752,41 @@ impl CliProcessManager {
|
|||||||
.map(BufReader::new);
|
.map(BufReader::new);
|
||||||
|
|
||||||
if let Some(reader) = stdout {
|
if let Some(reader) = stdout {
|
||||||
Self::process_stream(
|
let app = app_clone.clone();
|
||||||
reader,
|
let status = status_clone.clone();
|
||||||
"stdout",
|
let ready = ready_clone.clone();
|
||||||
&app_clone,
|
let token = token_clone.clone();
|
||||||
&status_clone,
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||||
&ready_clone,
|
thread::spawn(move || {
|
||||||
&token_clone,
|
Self::process_stream(
|
||||||
);
|
reader,
|
||||||
|
"stdout",
|
||||||
|
&app,
|
||||||
|
&status,
|
||||||
|
&ready,
|
||||||
|
&token,
|
||||||
|
auth_cookie_name.as_str(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
Self::process_stream(
|
let app = app_clone.clone();
|
||||||
reader,
|
let status = status_clone.clone();
|
||||||
"stderr",
|
let ready = ready_clone.clone();
|
||||||
&app_clone,
|
let token = token_clone.clone();
|
||||||
&status_clone,
|
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||||
&ready_clone,
|
thread::spawn(move || {
|
||||||
&token_clone,
|
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 status_clone = status.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
let child_holder_clone = child_holder.clone();
|
let child_holder_clone = child_holder.clone();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let job_holder_clone = job_holder.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let timeout = Duration::from_secs(60);
|
let timeout = Duration::from_secs(60);
|
||||||
thread::sleep(timeout);
|
thread::sleep(timeout);
|
||||||
@@ -677,6 +850,10 @@ impl CliProcessManager {
|
|||||||
// Drop the handle after the process exits so other callers
|
// Drop the handle after the process exits so other callers
|
||||||
// don't attempt to stop/kill a finished process.
|
// don't attempt to stop/kill a finished process.
|
||||||
*guard = None;
|
*guard = None;
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = job_holder_clone.lock().take();
|
||||||
|
}
|
||||||
Some(status)
|
Some(status)
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -731,10 +908,11 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
auth_cookie_name: &str,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
let local_url_regex =
|
||||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -761,44 +939,32 @@ impl CliProcessManager {
|
|||||||
continue;
|
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
|
if let Some(url) = local_url_regex
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.map(|m| m.as_str().to_string())
|
.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;
|
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,
|
Err(_) => break,
|
||||||
@@ -811,6 +977,7 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
auth_cookie_name: &str,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
) {
|
) {
|
||||||
ready.store(true, Ordering::SeqCst);
|
ready.store(true, Ordering::SeqCst);
|
||||||
@@ -834,9 +1001,11 @@ impl CliProcessManager {
|
|||||||
if scheme.as_deref() != Some("http") {
|
if scheme.as_deref() != Some("http") {
|
||||||
navigate_main(app, &base_url);
|
navigate_main(app, &base_url);
|
||||||
} else {
|
} else {
|
||||||
match exchange_bootstrap_token(&base_url, &token) {
|
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||||
Ok(Some(session_id)) => {
|
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}"));
|
log_line(&format!("failed to set session cookie: {err}"));
|
||||||
navigate_main(app, &format!("{base_url}/login"));
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
} else {
|
} else {
|
||||||
@@ -897,7 +1066,7 @@ struct CliEntry {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Runner {
|
enum Runner {
|
||||||
Node,
|
Standalone,
|
||||||
Tsx,
|
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 {
|
return Ok(Self {
|
||||||
entry,
|
entry,
|
||||||
runner: Runner::Node,
|
runner: Runner::Standalone,
|
||||||
runner_path: None,
|
runner_path: None,
|
||||||
node_binary,
|
node_binary: String::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow::anyhow!(
|
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![
|
let mut args = vec![
|
||||||
"serve".to_string(),
|
"serve".to_string(),
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
host.to_string(),
|
host.to_string(),
|
||||||
|
"--auth-cookie-name".to_string(),
|
||||||
|
auth_cookie_name.to_string(),
|
||||||
"--generate-token".to_string(),
|
"--generate-token".to_string(),
|
||||||
|
"--unrestricted-root".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if dev {
|
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")
|
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
@@ -958,7 +1131,7 @@ impl CliEntry {
|
|||||||
.unwrap_or_else(|| "info".to_string());
|
.unwrap_or_else(|| "info".to_string());
|
||||||
|
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
args.push("false".to_string());
|
args.push("true".to_string());
|
||||||
args.push("--http".to_string());
|
args.push("--http".to_string());
|
||||||
args.push("true".to_string());
|
args.push("true".to_string());
|
||||||
args.push("--http-port".to_string());
|
args.push("--http-port".to_string());
|
||||||
@@ -978,6 +1151,10 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||||
|
if self.runner == Runner::Standalone {
|
||||||
|
return cli_args.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
let mut args = VecDeque::new();
|
let mut args = VecDeque::new();
|
||||||
if self.runner == Runner::Tsx {
|
if self.runner == Runner::Tsx {
|
||||||
if let Some(path) = &self.runner_path {
|
if let Some(path) = &self.runner_path {
|
||||||
@@ -993,71 +1170,94 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||||
let candidates = vec![
|
let cwd = std::env::current_dir().ok();
|
||||||
std::env::current_dir()
|
let workspace = workspace_root();
|
||||||
.ok()
|
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")),
|
.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)
|
first_existing(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let cwd = std::env::current_dir().ok();
|
||||||
|
let workspace = workspace_root();
|
||||||
let candidates = vec![
|
let candidates = vec![
|
||||||
std::env::current_dir()
|
workspace
|
||||||
.ok()
|
.as_ref()
|
||||||
.map(|p| p.join("packages/server/src/index.ts")),
|
.map(|p| p.join("packages/server/src/index.ts")),
|
||||||
std::env::current_dir()
|
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||||
.ok()
|
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||||
.map(|p| p.join("../server/src/index.ts")),
|
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||||
];
|
];
|
||||||
|
|
||||||
first_existing(candidates)
|
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 base = workspace_root();
|
||||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
let mut candidates = vec![base
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
.as_ref()
|
||||||
base.as_ref()
|
.map(|p| p.join("packages/server/dist").join(executable_name))];
|
||||||
.map(|p| p.join("packages/server/dist/index.js")),
|
|
||||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
|
||||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
candidates.push(Some(
|
||||||
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
dir.join("resources/server/dist").join(executable_name),
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
));
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
|
||||||
|
|
||||||
let resources = dir.join("../Resources");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
candidates.push(Some(resources.join("server/dist").join(executable_name)));
|
||||||
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(
|
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")];
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||||
for root in linux_resource_roots {
|
for root in linux_resource_roots {
|
||||||
candidates.push(Some(root.join("server/dist/bin.js")));
|
candidates.push(Some(root.join("server/dist").join(executable_name)));
|
||||||
candidates.push(Some(root.join("server/dist/index.js")));
|
candidates.push(Some(
|
||||||
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
root.join("resources/server/dist").join(executable_name),
|
||||||
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")));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1071,16 +1271,55 @@ fn build_shell_command_string(
|
|||||||
) -> anyhow::Result<ShellCommand> {
|
) -> anyhow::Result<ShellCommand> {
|
||||||
let shell = default_shell();
|
let shell = default_shell();
|
||||||
let mut quoted: Vec<String> = Vec::new();
|
let mut quoted: Vec<String> = Vec::new();
|
||||||
quoted.push(shell_escape(&entry.node_binary));
|
let command = if entry.runner == Runner::Standalone {
|
||||||
for arg in entry.runner_args(cli_args) {
|
quoted.push(shell_escape(&entry.entry));
|
||||||
quoted.push(shell_escape(&arg));
|
for arg in 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);
|
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));
|
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||||
Ok(ShellCommand { 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 {
|
fn default_shell() -> String {
|
||||||
if let Ok(shell) = std::env::var("SHELL") {
|
if let Ok(shell) = std::env::var("SHELL") {
|
||||||
if !shell.trim().is_empty() {
|
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")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod cert_manager;
|
||||||
mod cli_manager;
|
mod cli_manager;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux_tls;
|
||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
use keepawake::KeepAwake;
|
use keepawake::KeepAwake;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
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::{
|
use tauri_plugin_global_shortcut::{
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||||
};
|
};
|
||||||
@@ -30,7 +37,7 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
|||||||
|
|
||||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
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 MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||||
|
|
||||||
@@ -41,6 +48,69 @@ pub struct AppState {
|
|||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
pub zoom_level: Mutex<f64>,
|
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)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -106,7 +176,7 @@ fn is_dev_mode() -> bool {
|
|||||||
|
|
||||||
fn should_allow_internal(url: &Url) -> bool {
|
fn should_allow_internal(url: &Url) -> bool {
|
||||||
match url.scheme() {
|
match url.scheme() {
|
||||||
"tauri" | "asset" | "file" => true,
|
"tauri" | "asset" | "file" | "about" => true,
|
||||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||||
// This must be treated as an internal origin or the navigation guard will
|
// 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.
|
// redirect it to the system browser and the app will appear blank.
|
||||||
@@ -118,11 +188,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) {
|
if should_allow_internal(url) {
|
||||||
return true;
|
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
|
if let Err(err) = webview
|
||||||
.app_handle()
|
.app_handle()
|
||||||
.opener()
|
.opener()
|
||||||
@@ -133,6 +224,161 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
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()))
|
||||||
|
.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> {
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
paths
|
paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -260,6 +506,8 @@ fn set_windows_app_user_model_id() {
|
|||||||
fn set_windows_app_user_model_id() {}
|
fn set_windows_app_user_model_id() {}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
.build();
|
.build();
|
||||||
@@ -286,6 +534,10 @@ fn main() {
|
|||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
wake_lock: Mutex::new(None),
|
wake_lock: Mutex::new(None),
|
||||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
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| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
set_windows_app_user_model_id();
|
||||||
@@ -323,7 +575,9 @@ fn main() {
|
|||||||
cli_get_status,
|
cli_get_status,
|
||||||
cli_restart,
|
cli_restart,
|
||||||
wake_lock_start,
|
wake_lock_start,
|
||||||
wake_lock_stop
|
wake_lock_stop,
|
||||||
|
needs_local_certificate_install,
|
||||||
|
open_remote_window
|
||||||
])
|
])
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
@@ -455,11 +709,24 @@ fn main() {
|
|||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
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) {
|
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.prevent_close();
|
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"identifier": "ai.neuralnomads.codenomad.client",
|
"identifier": "ai.neuralnomads.codenomad.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"frontendDist": "resources/ui-loading"
|
"frontendDist": "resources/ui-loading"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
"enableGTKAppId": true,
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
@@ -41,6 +42,30 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"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": [
|
||||||
"resources/server",
|
"resources/server",
|
||||||
"resources/ui-loading"
|
"resources/ui-loading"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
|
|||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
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 { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
|
import { showAlertDialog } from "./stores/alerts"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
|
|||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
setIsSelectingFolder,
|
setIsSelectingFolder,
|
||||||
showFolderSelection,
|
showFolderSelection,
|
||||||
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
|
|||||||
import {
|
import {
|
||||||
createInstance,
|
createInstance,
|
||||||
instances,
|
instances,
|
||||||
activeInstanceId,
|
|
||||||
setActiveInstanceId,
|
|
||||||
stopInstance,
|
stopInstance,
|
||||||
getActiveInstance,
|
|
||||||
disconnectedInstance,
|
disconnectedInstance,
|
||||||
acknowledgeDisconnectedInstance,
|
acknowledgeDisconnectedInstance,
|
||||||
} from "./stores/instances"
|
} from "./stores/instances"
|
||||||
@@ -53,6 +52,22 @@ import {
|
|||||||
|
|
||||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
import { openSettings } from "./stores/settings-screen"
|
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")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -77,6 +92,7 @@ const App: Component = () => {
|
|||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
|
||||||
|
|
||||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||||
@@ -206,8 +222,7 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
instances()
|
appTabs()
|
||||||
hasInstances()
|
|
||||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,7 +234,15 @@ const App: Component = () => {
|
|||||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
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 activeSessionIdForInstance = createMemo(() => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
if (!instance) return null
|
if (!instance) return null
|
||||||
@@ -244,6 +267,7 @@ const App: Component = () => {
|
|||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
|
selectInstanceTab(instanceId)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
@@ -270,8 +294,27 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
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) => {
|
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
|
||||||
if (!instanceId || !sessionId || sessionId === "info") return
|
if (!instanceId || !sessionId || sessionId === "info") return
|
||||||
await updateSessionAgent(instanceId, sessionId, agent)
|
await updateSessionAgent(instanceId, sessionId, agent)
|
||||||
@@ -361,6 +421,7 @@ const App: Component = () => {
|
|||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
setToolInputsVisibility,
|
setToolInputsVisibility,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
|
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
handleCloseSession,
|
handleCloseSession,
|
||||||
@@ -371,6 +432,7 @@ const App: Component = () => {
|
|||||||
useAppLifecycle({
|
useAppLifecycle({
|
||||||
setEscapeInDebounce,
|
setEscapeInDebounce,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
|
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
handleCloseSession,
|
handleCloseSession,
|
||||||
@@ -470,52 +532,60 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!hasInstances()}
|
when={appTabs().length === 0}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||||
<InstanceTabs
|
<InstanceTabs
|
||||||
instances={instances()}
|
tabs={appTabs()}
|
||||||
activeInstanceId={activeInstanceId()}
|
activeTabId={activeAppTabId()}
|
||||||
onSelect={setActiveInstanceId}
|
onSelect={selectAppTab}
|
||||||
onClose={handleCloseInstance}
|
onClose={(tabId) => void handleCloseAppTab(tabId)}
|
||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</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>
|
</For>
|
||||||
|
|
||||||
@@ -525,6 +595,7 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
onOpenSidecar={handleOpenSidecarPicker}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -534,6 +605,7 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
onOpenSidecar={handleOpenSidecarPicker}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
@@ -544,6 +616,7 @@ const App: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<SettingsScreen />
|
<SettingsScreen />
|
||||||
|
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
|
||||||
|
|
||||||
<AlertDialog />
|
<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 { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||||
import { disableCache } from "@git-diff-view/core"
|
import { disableCache } from "@git-diff-view/core"
|
||||||
@@ -20,6 +20,7 @@ interface ToolCallDiffViewerProps {
|
|||||||
filePath?: string
|
filePath?: string
|
||||||
theme: "light" | "dark"
|
theme: "light" | "dark"
|
||||||
mode: DiffViewMode
|
mode: DiffViewMode
|
||||||
|
wrap?: boolean
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
cachedHtml?: string
|
cachedHtml?: string
|
||||||
cacheEntryParams?: CacheEntryParams
|
cacheEntryParams?: CacheEntryParams
|
||||||
@@ -31,11 +32,183 @@ type DiffData = {
|
|||||||
hunks: string[]
|
hunks: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaptureContext = {
|
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
|
||||||
theme: ToolCallDiffViewerProps["theme"]
|
const computed = window.getComputedStyle(source)
|
||||||
mode: DiffViewMode
|
const probe = document.createElement("span")
|
||||||
diffText: string
|
probe.textContent = text || ""
|
||||||
cacheEntryParams?: CacheEntryParams
|
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) {
|
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||||
@@ -67,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
const contextKey = createMemo(() => {
|
const contextKey = createMemo(() => {
|
||||||
const data = diffData()
|
const data = diffData()
|
||||||
if (!data) return ""
|
if (!data) return ""
|
||||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const cachedHtml = props.cachedHtml
|
const cachedHtml = props.cachedHtml
|
||||||
if (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
|
// When we are given cached HTML, we rely on the caller's cache
|
||||||
// and simply notify once rendered.
|
// and simply notify once rendered.
|
||||||
props.onRendered?.()
|
props.onRendered?.()
|
||||||
@@ -83,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
if (!key) return
|
if (!key) return
|
||||||
if (!diffContainerRef) return
|
if (!diffContainerRef) return
|
||||||
if (lastCapturedKey === key) return
|
if (lastCapturedKey === key) return
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!diffContainerRef) return
|
if (!diffContainerRef) return
|
||||||
|
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||||
const markup = diffContainerRef.innerHTML
|
const markup = diffContainerRef.innerHTML
|
||||||
if (!markup) return
|
if (!markup) return
|
||||||
lastCapturedKey = key
|
lastCapturedKey = key
|
||||||
@@ -95,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
html: markup,
|
html: markup,
|
||||||
theme: props.theme,
|
theme: props.theme,
|
||||||
mode: props.mode,
|
mode: props.mode,
|
||||||
|
wrap: props.wrap,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
props.onRendered?.()
|
props.onRendered?.()
|
||||||
@@ -122,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||||
diffViewTheme={props.theme}
|
diffViewTheme={props.theme}
|
||||||
diffViewHighlight
|
diffViewHighlight
|
||||||
diffViewWrap={false}
|
diffViewWrap={Boolean(props.wrap)}
|
||||||
diffViewFontSize={13}
|
diffViewFontSize={13}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -131,7 +309,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div innerHTML={props.cachedHtml} />
|
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 { loadMonaco } from "../../lib/monaco/setup"
|
||||||
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||||
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||||
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||||
import { useTheme } from "../../lib/theme"
|
import { useTheme } from "../../lib/theme"
|
||||||
|
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
|
||||||
|
|
||||||
interface MonacoDiffViewerProps {
|
interface MonacoDiffViewerProps {
|
||||||
scopeKey: string
|
scopeKey: string
|
||||||
path: string
|
path: string
|
||||||
before: string
|
patch?: string
|
||||||
after: string
|
before?: string
|
||||||
|
after?: string
|
||||||
viewMode?: "split" | "unified"
|
viewMode?: "split" | "unified"
|
||||||
contextMode?: "expanded" | "collapsed"
|
contextMode?: "expanded" | "collapsed"
|
||||||
wordWrap?: "on" | "off"
|
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) {
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
@@ -21,7 +72,22 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
|
|
||||||
let diffEditor: any = null
|
let diffEditor: any = null
|
||||||
let monaco: any = null
|
let monaco: any = null
|
||||||
|
let splitLayoutFrame: number | null = null
|
||||||
const [ready, setReady] = createSignal(false)
|
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 = () => {
|
const disposeEditor = () => {
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +103,90 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
diffEditor = null
|
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(() => {
|
onMount(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -69,10 +219,17 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setReady(true)
|
setReady(true)
|
||||||
|
|
||||||
|
layoutInsertWidget()
|
||||||
})()
|
})()
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
if (splitLayoutFrame !== null && typeof window !== "undefined") {
|
||||||
|
window.cancelAnimationFrame(splitLayoutFrame)
|
||||||
|
splitLayoutFrame = null
|
||||||
|
}
|
||||||
|
clearSplitLayoutVariables()
|
||||||
setReady(false)
|
setReady(false)
|
||||||
disposeEditor()
|
disposeEditor()
|
||||||
})
|
})
|
||||||
@@ -83,15 +240,101 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
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(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !monaco || !diffEditor) return
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
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({
|
diffEditor.updateOptions({
|
||||||
renderSideBySide: viewMode === "split",
|
renderSideBySide: viewMode === "split",
|
||||||
renderSideBySideInlineBreakpoint: 0,
|
renderSideBySideInlineBreakpoint: 0,
|
||||||
|
renderIndicators: true,
|
||||||
|
lineNumbersMinChars: diffEditorLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
hideUnchangedRegions:
|
hideUnchangedRegions:
|
||||||
contextMode === "collapsed"
|
contextMode === "collapsed"
|
||||||
? { enabled: true }
|
? { enabled: true }
|
||||||
@@ -100,26 +343,41 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
diffEditor.getOriginalEditor?.()?.updateOptions?.({
|
||||||
|
wordWrap,
|
||||||
|
lineNumbersMinChars: originalLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
diffEditor.getModifiedEditor?.()?.updateOptions?.({
|
||||||
|
wordWrap,
|
||||||
|
lineNumbersMinChars: modifiedLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncSplitLayoutVariables({
|
||||||
|
viewMode,
|
||||||
|
originalLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !monaco || !diffEditor) return
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const languageId = inferMonacoLanguageId(monaco, props.path)
|
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||||
|
const { before, after } = resolvedContent()
|
||||||
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
||||||
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
||||||
|
|
||||||
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
|
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
|
||||||
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
|
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
|
||||||
diffEditor.setModel({ original, modified })
|
diffEditor.setModel({ original, modified })
|
||||||
|
|
||||||
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
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,6 +1,7 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Select } from "@kobalte/core/select"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
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 { useConfig } from "../stores/preferences"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
@@ -14,25 +15,49 @@ import { useI18n, type Locale } from "../lib/i18n"
|
|||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
import { openExternalUrl } from "../lib/external-url"
|
||||||
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { runtimeEnv } from "../lib/runtime-env"
|
||||||
|
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
|
|
||||||
|
type HomeTab = "local" | "servers"
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
|
onOpenSidecar?: () => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
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 { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
|
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)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -49,10 +74,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
|
const serverList = () => remoteServers()
|
||||||
const isLoading = () => Boolean(props.isLoading)
|
const isLoading = () => Boolean(props.isLoading)
|
||||||
|
|
||||||
|
function getActiveListLength() {
|
||||||
|
return activeTab() === "local" ? folders().length : serverList().length
|
||||||
|
}
|
||||||
|
|
||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = serverSettings().opencodeBinary
|
const lastUsed = serverSettings().opencodeBinary
|
||||||
@@ -64,7 +94,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
function scrollToIndex(index: number) {
|
function scrollToIndex(index: number) {
|
||||||
const container = recentListRef
|
const container = recentListRef
|
||||||
if (!container) return
|
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
|
if (!element) return
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
@@ -113,19 +143,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderList = folders()
|
|
||||||
|
|
||||||
if (isBrowseShortcut) {
|
if (isBrowseShortcut) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void handleBrowse()
|
void handleBrowse()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folderList.length === 0) return
|
const listLength = getActiveListLength()
|
||||||
|
if (listLength === 0) return
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -138,7 +167,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
} else if (e.key === "PageDown") {
|
} else if (e.key === "PageDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pageSize = 5
|
const pageSize = 5
|
||||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -156,7 +185,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
scrollToIndex(0)
|
scrollToIndex(0)
|
||||||
} else if (e.key === "End") {
|
} else if (e.key === "End") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = folderList.length - 1
|
const newIndex = listLength - 1
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("recent")
|
setFocusMode("recent")
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
@@ -165,10 +194,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
handleEnterKey()
|
handleEnterKey()
|
||||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (folderList.length > 0 && focusMode() === "recent") {
|
if (listLength > 0 && focusMode() === "recent") {
|
||||||
const folder = folderList[selectedIndex()]
|
if (activeTab() === "local") {
|
||||||
if (folder) {
|
const folder = folders()[selectedIndex()]
|
||||||
handleRemove(folder.path)
|
if (folder) {
|
||||||
|
handleRemove(folder.path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const server = serverList()[selectedIndex()]
|
||||||
|
if (server) {
|
||||||
|
removeRemoteServerProfile(server.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,15 +213,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
function handleEnterKey() {
|
function handleEnterKey() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
const folderList = folders()
|
|
||||||
const index = selectedIndex()
|
const index = selectedIndex()
|
||||||
|
|
||||||
const folder = folderList[index]
|
if (activeTab() === "local") {
|
||||||
if (folder) {
|
const folder = folders()[index]
|
||||||
handleFolderSelect(folder.path)
|
if (folder) {
|
||||||
|
handleFolderSelect(folder.path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serverList()[index]
|
||||||
|
if (server) {
|
||||||
|
void handleConnectSavedServer(server.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
activeTab()
|
||||||
|
setSelectedIndex(0)
|
||||||
|
setFocusMode("recent")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const length = getActiveListLength()
|
||||||
|
if (length === 0) {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex() >= length) {
|
||||||
|
setSelectedIndex(length - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
@@ -236,6 +297,103 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetServerDialog() {
|
||||||
|
setServerName("")
|
||||||
|
setServerUrl("")
|
||||||
|
setSkipTlsVerify(false)
|
||||||
|
setServerDialogError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openServerDialog() {
|
||||||
|
resetServerDialog()
|
||||||
|
setIsServerDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||||
|
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 =
|
||||||
|
runtimeEnv.host === "tauri" && 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) {
|
||||||
|
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() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -476,90 +634,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||||
{/* Right column: recent folders */}
|
{/* Right column: recent folders */}
|
||||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||||
<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 flex flex-col flex-1 min-h-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header !gap-0 !p-0">
|
||||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
||||||
<p class="panel-subtitle">
|
<button
|
||||||
{t(
|
type="button"
|
||||||
folders().length === 1
|
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||||
? "folderSelection.recent.subtitle.one"
|
classList={{
|
||||||
: "folderSelection.recent.subtitle.other",
|
"text-primary": activeTab() === "local",
|
||||||
{ count: folders().length },
|
"text-muted hover:text-secondary": activeTab() !== "local",
|
||||||
)}
|
}}
|
||||||
</p>
|
style={{
|
||||||
</div>
|
"background-color": "var(--surface-secondary)",
|
||||||
<div
|
}}
|
||||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
onClick={() => setActiveTab("local")}
|
||||||
ref={(el) => (recentListRef = el)}
|
>
|
||||||
>
|
|
||||||
<For each={folders()}>
|
|
||||||
{(folder, index) => (
|
|
||||||
<div
|
<div
|
||||||
class="panel-list-item"
|
class="panel-title text-base"
|
||||||
classList={{
|
style={{
|
||||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
"panel-list-item-disabled": isLoading(),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 w-full px-1">
|
{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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={activeTab() === "local"}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={remoteServers().length > 0}
|
||||||
|
fallback={
|
||||||
|
<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
|
<button
|
||||||
data-folder-index={index()}
|
type="button"
|
||||||
class="panel-list-item-content flex-1"
|
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||||
disabled={isLoading()}
|
onClick={openServerDialog}
|
||||||
onClick={() => handleFolderSelect(folder.path)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (isLoading()) return
|
|
||||||
setFocusMode("recent")
|
|
||||||
setSelectedIndex(index())
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3 w-full">
|
<Globe class="w-4 h-4" />
|
||||||
<div class="flex-1 min-w-0">
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
<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>
|
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
}
|
||||||
</div>
|
>
|
||||||
|
<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 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>
|
||||||
|
</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>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -567,11 +858,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="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||||
<div class="panel shrink-0">
|
<div class="panel shrink-0">
|
||||||
<div class="panel-header hidden sm:block">
|
<div class="panel-header hidden sm:block">
|
||||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
|
||||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleBrowse()}
|
onClick={() => void handleBrowse()}
|
||||||
disabled={props.isLoading}
|
disabled={props.isLoading}
|
||||||
@@ -588,6 +879,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={openServerDialog}
|
||||||
|
class="button-primary w-full flex items-center justify-center text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Globe class="w-4 h-4" />
|
||||||
|
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* OpenCode settings section */}
|
||||||
@@ -663,6 +975,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
onClose={() => setIsFolderBrowserOpen(false)}
|
onClose={() => setIsFolderBrowserOpen(false)}
|
||||||
onSelect={handleBrowserSelect}
|
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,6 +1,5 @@
|
|||||||
import { Component, For, Show, createMemo } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { Instance } from "../types/instance"
|
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||||
@@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { openSettings } from "../stores/settings-screen"
|
import { openSettings } from "../stores/settings-screen"
|
||||||
|
import type { AppTabRecord } from "../stores/app-tabs"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
tabs: AppTabRecord[]
|
||||||
activeInstanceId: string | null
|
activeTabId: string | null
|
||||||
onSelect: (instanceId: string) => void
|
onSelect: (tabId: string) => void
|
||||||
onClose: (instanceId: string) => void
|
onClose: (tabId: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<div class="tab-scroll">
|
<div class="tab-scroll">
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
<div class="tab-strip-tabs">
|
<div class="tab-strip-tabs">
|
||||||
<For each={Array.from(props.instances.entries())}>
|
<For each={props.tabs}>
|
||||||
{([id, instance]) => (
|
{(tab) =>
|
||||||
<InstanceTab
|
tab.kind === "instance" ? (
|
||||||
instance={instance}
|
<InstanceTab
|
||||||
active={id === props.activeInstanceId}
|
instance={tab.instance}
|
||||||
onSelect={() => props.onSelect(id)}
|
active={tab.id === props.activeTabId}
|
||||||
onClose={() => props.onClose(id)}
|
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>
|
</For>
|
||||||
<button
|
<button
|
||||||
class="new-tab-button"
|
class="new-tab-button"
|
||||||
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-strip-spacer" />
|
<div class="tab-strip-spacer" />
|
||||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
<Show when={props.tabs.length > 1}>
|
||||||
<div class="tab-shortcuts">
|
<div class="tab-shortcuts">
|
||||||
<KeyboardHint
|
<KeyboardHint
|
||||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import RightPanel from "./shell/right-panel/RightPanel"
|
|||||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||||
|
import type { PromptInputApi } from "../prompt-input/types"
|
||||||
|
|
||||||
import type { LayoutMode } from "./shell/types"
|
import type { LayoutMode } from "./shell/types"
|
||||||
import {
|
import {
|
||||||
@@ -105,6 +106,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||||
const [now, setNow] = createSignal(Date.now())
|
const [now, setNow] = createSignal(Date.now())
|
||||||
|
const [sessionPromptApis, setSessionPromptApis] = createSignal<Record<string, PromptInputApi | null>>({})
|
||||||
|
|
||||||
// Worktree selector manages its own dialogs.
|
// Worktree selector manages its own dialogs.
|
||||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||||
@@ -268,6 +270,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
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(() => {
|
createEffect(() => {
|
||||||
getPermissionAutoAcceptInFlightVersion()
|
getPermissionAutoAcceptInFlightVersion()
|
||||||
|
|
||||||
@@ -342,7 +357,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const pill = activeSessionStatusPill()
|
const pill = activeSessionStatusPill()
|
||||||
if (!pill) return null
|
if (!pill) return null
|
||||||
return (
|
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.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||||
{pill.text}
|
{pill.text}
|
||||||
</span>
|
</span>
|
||||||
@@ -594,6 +613,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onCloseRightDrawer={closeRightDrawer}
|
onCloseRightDrawer={closeRightDrawer}
|
||||||
onPinRightDrawer={pinRightDrawer}
|
onPinRightDrawer={pinRightDrawer}
|
||||||
onUnpinRightDrawer={unpinRightDrawer}
|
onUnpinRightDrawer={unpinRightDrawer}
|
||||||
|
promptInputApi={activePromptInputApi}
|
||||||
setContentEl={setRightDrawerContentEl}
|
setContentEl={setRightDrawerContentEl}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -656,6 +676,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onCloseRightDrawer={closeRightDrawer}
|
onCloseRightDrawer={closeRightDrawer}
|
||||||
onPinRightDrawer={pinRightDrawer}
|
onPinRightDrawer={pinRightDrawer}
|
||||||
onUnpinRightDrawer={unpinRightDrawer}
|
onUnpinRightDrawer={unpinRightDrawer}
|
||||||
|
promptInputApi={activePromptInputApi}
|
||||||
setContentEl={setRightDrawerContentEl}
|
setContentEl={setRightDrawerContentEl}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -892,6 +913,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
escapeInDebounce={props.escapeInDebounce}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
isPhoneLayout={isPhoneLayout()}
|
isPhoneLayout={isPhoneLayout()}
|
||||||
compactPromptLayout={compactPromptLayout()}
|
compactPromptLayout={compactPromptLayout()}
|
||||||
|
registerSessionPromptApi={registerSessionPromptApi}
|
||||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||||
onSidebarToggle={() => setLeftOpen(true)}
|
onSidebarToggle={() => setLeftOpen(true)}
|
||||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
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 IconButton from "@suid/material/IconButton"
|
||||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
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 { Instance } from "../../../../types/instance"
|
||||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||||
import type { Session } from "../../../../types/session"
|
import type { Session } from "../../../../types/session"
|
||||||
|
import type { PromptInputApi } from "../../../prompt-input/types"
|
||||||
import type { DrawerViewState } from "../types"
|
import type { DrawerViewState } from "../types"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } 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 { requestData } from "../../../../lib/opencode-api"
|
||||||
import { serverApi } from "../../../../lib/api-client"
|
import { serverApi } from "../../../../lib/api-client"
|
||||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||||
import { showToastNotification } from "../../../../lib/notifications"
|
import { showToastNotification } from "../../../../lib/notifications"
|
||||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
|
||||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||||
|
import { useGitChanges } from "./useGitChanges"
|
||||||
import {
|
import {
|
||||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||||
@@ -41,7 +48,11 @@ import {
|
|||||||
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||||
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_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_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
|
||||||
RIGHT_PANEL_TAB_STORAGE_KEY,
|
RIGHT_PANEL_TAB_STORAGE_KEY,
|
||||||
readStoredBool,
|
readStoredBool,
|
||||||
readStoredEnum,
|
readStoredEnum,
|
||||||
@@ -82,6 +93,7 @@ interface RightPanelProps {
|
|||||||
onCloseRightDrawer: () => void
|
onCloseRightDrawer: () => void
|
||||||
onPinRightDrawer: () => void
|
onPinRightDrawer: () => void
|
||||||
onUnpinRightDrawer: () => void
|
onUnpinRightDrawer: () => void
|
||||||
|
promptInputApi: Accessor<PromptInputApi | null>
|
||||||
|
|
||||||
setContentEl: (el: HTMLElement | null) => void
|
setContentEl: (el: HTMLElement | null) => void
|
||||||
}
|
}
|
||||||
@@ -133,6 +145,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||||
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||||
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||||
|
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
|
||||||
|
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
|
||||||
|
|
||||||
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
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
|
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) => {
|
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
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(() => {
|
createEffect(() => {
|
||||||
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
||||||
const layout = listLayoutKey()
|
const layout = listLayoutKey()
|
||||||
@@ -185,6 +216,12 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setGitChangesListOpen(true)
|
setGitChangesListOpen(true)
|
||||||
setGitChangesListTouched(false)
|
setGitChangesListTouched(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stagedPersisted = readStoredBool(gitSectionStorageKey("staged"))
|
||||||
|
setGitStagedOpen(stagedPersisted ?? true)
|
||||||
|
|
||||||
|
const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged"))
|
||||||
|
setGitUnstagedOpen(unstagedPersisted ?? true)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -339,34 +376,56 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
return getDefaultWorktreeSlug(props.instanceId)
|
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 browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
||||||
|
|
||||||
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
|
const {
|
||||||
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
gitStatusEntries,
|
||||||
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
gitStatusLoading,
|
||||||
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
gitStatusError,
|
||||||
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
gitSelectedItemId,
|
||||||
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
gitBulkSelectedItemIds,
|
||||||
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
gitSelectedLoading,
|
||||||
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
gitSelectedError,
|
||||||
|
gitSelectedBefore,
|
||||||
const gitMostChangedPath = createMemo<string | null>(() => {
|
gitSelectedAfter,
|
||||||
const entries = gitStatusEntries()
|
gitCommitMessage,
|
||||||
if (!Array.isArray(entries) || entries.length === 0) return null
|
gitCommitSubmitting,
|
||||||
const candidates = entries.filter((item) => item && item.status !== "deleted")
|
gitMostChangedItemId,
|
||||||
if (candidates.length === 0) return null
|
setGitCommitMessage,
|
||||||
const best = candidates.reduce((currentBest, item) => {
|
handleGitRowClick,
|
||||||
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
|
refreshGitStatus,
|
||||||
const score = (item?.added ?? 0) + (item?.removed ?? 0)
|
insertGitChangeContext,
|
||||||
if (score > bestScore) return item
|
submitGitCommit,
|
||||||
if (score < bestScore) return currentBest
|
stageGitFile,
|
||||||
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
|
unstageGitFile,
|
||||||
}, candidates[0])
|
} = useGitChanges({
|
||||||
return typeof best?.path === "string" ? best.path : null
|
t: props.t,
|
||||||
|
instanceId: props.instanceId,
|
||||||
|
rightPanelTab,
|
||||||
|
worktreeSlug: worktreeSlugForViewer,
|
||||||
|
isPhoneLayout: props.isPhoneLayout,
|
||||||
|
promptInputApi: props.promptInputApi,
|
||||||
|
closeGitList: () => setGitChangesListOpen(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
// Reset tab state when worktree context changes.
|
|
||||||
worktreeSlugForViewer()
|
worktreeSlugForViewer()
|
||||||
setBrowserPath(".")
|
setBrowserPath(".")
|
||||||
setBrowserEntries(null)
|
setBrowserEntries(null)
|
||||||
@@ -375,111 +434,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedContent(null)
|
setBrowserSelectedContent(null)
|
||||||
setBrowserSelectedError(null)
|
setBrowserSelectedError(null)
|
||||||
setBrowserSelectedLoading(false)
|
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 bestDiffFile = createMemo<string | null>(() => {
|
||||||
const diffs = props.activeSessionDiffs()
|
const diffs = props.activeSessionDiffs()
|
||||||
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||||
@@ -680,21 +636,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedDirty(false)
|
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) => {
|
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||||
setSelectedFile(file)
|
setSelectedFile(file)
|
||||||
if (closeList) {
|
if (closeList) {
|
||||||
@@ -911,12 +852,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
entries={gitStatusEntries}
|
entries={gitStatusEntries}
|
||||||
statusLoading={gitStatusLoading}
|
statusLoading={gitStatusLoading}
|
||||||
statusError={gitStatusError}
|
statusError={gitStatusError}
|
||||||
selectedPath={gitSelectedPath}
|
selectedItemId={gitSelectedItemId}
|
||||||
|
selectedBulkItemIds={gitBulkSelectedItemIds}
|
||||||
selectedLoading={gitSelectedLoading}
|
selectedLoading={gitSelectedLoading}
|
||||||
selectedError={gitSelectedError}
|
selectedError={gitSelectedError}
|
||||||
selectedBefore={gitSelectedBefore}
|
selectedBefore={gitSelectedBefore}
|
||||||
selectedAfter={gitSelectedAfter}
|
selectedAfter={gitSelectedAfter}
|
||||||
mostChangedPath={gitMostChangedPath}
|
mostChangedItemId={gitMostChangedItemId}
|
||||||
scopeKey={gitScopeKey}
|
scopeKey={gitScopeKey}
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
diffContextMode={diffContextMode}
|
diffContextMode={diffContextMode}
|
||||||
@@ -924,8 +866,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
onViewModeChange={setDiffViewMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onOpenFile={(path: string) => void openGitFile(path)}
|
onRowClick={handleGitRowClick}
|
||||||
onRefresh={() => void refreshGitStatus()}
|
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}
|
listOpen={gitChangesListOpen}
|
||||||
onToggleList={toggleGitList}
|
onToggleList={toggleGitList}
|
||||||
splitWidth={gitChangesSplitWidth}
|
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) => (
|
{(file) => (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LazyMonacoDiffViewer
|
<LazyMonacoDiffViewer
|
||||||
scopeKey={scopeKey()}
|
scopeKey={scopeKey()}
|
||||||
path={String(file().file || "")}
|
path={String(file().file || "")}
|
||||||
before={String((file() as any).before || "")}
|
patch={String((file() as any).patch || "")}
|
||||||
after={String((file() as any).after || "")}
|
viewMode={props.diffViewMode()}
|
||||||
viewMode={props.diffViewMode()}
|
contextMode={props.diffContextMode()}
|
||||||
contextMode={props.diffContextMode()}
|
wordWrap={props.diffWordWrapMode()}
|
||||||
wordWrap={props.diffWordWrapMode()}
|
/>
|
||||||
/>
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import {
|
||||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
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 DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
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(() =>
|
const LazyMonacoDiffViewer = lazy(() =>
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||||
@@ -16,16 +25,17 @@ interface GitChangesTabProps {
|
|||||||
|
|
||||||
activeSessionId: Accessor<string | null>
|
activeSessionId: Accessor<string | null>
|
||||||
|
|
||||||
entries: Accessor<GitFileStatus[] | null>
|
entries: Accessor<GitChangeEntry[] | null>
|
||||||
statusLoading: Accessor<boolean>
|
statusLoading: Accessor<boolean>
|
||||||
statusError: Accessor<string | null>
|
statusError: Accessor<string | null>
|
||||||
|
|
||||||
selectedPath: Accessor<string | null>
|
selectedItemId: Accessor<string | null>
|
||||||
|
selectedBulkItemIds: Accessor<Set<string>>
|
||||||
selectedLoading: Accessor<boolean>
|
selectedLoading: Accessor<boolean>
|
||||||
selectedError: Accessor<string | null>
|
selectedError: Accessor<string | null>
|
||||||
selectedBefore: Accessor<string | null>
|
selectedBefore: Accessor<string | null>
|
||||||
selectedAfter: Accessor<string | null>
|
selectedAfter: Accessor<string | null>
|
||||||
mostChangedPath: Accessor<string | null>
|
mostChangedItemId: Accessor<string | null>
|
||||||
|
|
||||||
scopeKey: Accessor<string>
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
@@ -36,8 +46,21 @@ interface GitChangesTabProps {
|
|||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
onOpenFile: (path: string) => void
|
onRowClick: (item: GitChangeListItem, event: MouseEvent) => void
|
||||||
onRefresh: () => 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>
|
listOpen: Accessor<boolean>
|
||||||
onToggleList: () => void
|
onToggleList: () => void
|
||||||
@@ -52,48 +75,54 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||||
|
|
||||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
const sorted = createMemo<GitChangeEntry[]>(() => {
|
||||||
const list = entries()
|
const list = entries()
|
||||||
if (!Array.isArray(list)) return []
|
if (!Array.isArray(list)) return []
|
||||||
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const listItems = createMemo<GitChangeListItem[]>(() => buildGitChangeListItems(sorted()))
|
||||||
|
|
||||||
const totals = createMemo(() => {
|
const totals = createMemo(() => {
|
||||||
return sorted().reduce(
|
return listItems().reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{ additions: 0, deletions: 0 },
|
{ 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<GitChangeEntry | null>(() => {
|
||||||
|
const list = listItems()
|
||||||
const selectedEntry = createMemo<GitFileStatus | null>(() => {
|
const selectedId = props.selectedItemId()
|
||||||
const list = sorted()
|
const fallbackId = props.mostChangedItemId()
|
||||||
const selectedPath = props.selectedPath()
|
|
||||||
const fallbackPath = props.mostChangedPath()
|
|
||||||
const found =
|
const found =
|
||||||
list.find((item) => item.path === selectedPath) ||
|
list.find((item) => item.id === selectedId) ||
|
||||||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
|
(fallbackId ? list.find((item) => item.id === fallbackId) : undefined)
|
||||||
return found ?? null
|
return found?.entry ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const emptyViewerMessage = createMemo(() => {
|
const emptyViewerMessage = createMemo(() => {
|
||||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
||||||
const currentEntries = entries()
|
const currentEntries = entries()
|
||||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
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")
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer"))
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
const totalsValue = totals()
|
const totalsValue = totals()
|
||||||
const selected = selectedEntry()
|
const selected = selectedEntry()
|
||||||
const sortedList = sorted()
|
const allItems = listItems()
|
||||||
const nonDeletedList = nonDeleted()
|
const stagedList = stagedItems()
|
||||||
|
const unstagedList = unstagedItems()
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<div class="file-viewer-panel flex-1">
|
||||||
@@ -109,7 +138,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
selected &&
|
selected &&
|
||||||
props.selectedBefore() !== null &&
|
props.selectedBefore() !== null &&
|
||||||
props.selectedAfter() !== null &&
|
props.selectedAfter() !== null &&
|
||||||
selected.status !== "deleted"
|
true
|
||||||
? {
|
? {
|
||||||
path: selected.path,
|
path: selected.path,
|
||||||
before: props.selectedBefore() as string,
|
before: props.selectedBefore() as string,
|
||||||
@@ -139,6 +168,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
viewMode={props.diffViewMode()}
|
viewMode={props.diffViewMode()}
|
||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
wordWrap={props.diffWordWrapMode()}
|
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>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -163,66 +200,149 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
|
|
||||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
|
||||||
const renderListPanel = () => (
|
const renderListItem = (item: GitChangeListItem) => {
|
||||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id))
|
||||||
<For each={sortedList}>
|
const actionLabel =
|
||||||
{(item) => (
|
item.section === "staged"
|
||||||
<div
|
? props.t("instanceShell.gitChanges.actions.unstage")
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
: props.t("instanceShell.gitChanges.actions.stage")
|
||||||
onClick={() => {
|
|
||||||
props.onOpenFile(item.path)
|
const triggerAction = () => {
|
||||||
}}
|
if (item.section === "staged") props.onUnstageFile(item)
|
||||||
>
|
else props.onStageFile(item)
|
||||||
<div class="file-list-item-content">
|
}
|
||||||
<div class="file-list-item-path" title={item.path}>
|
|
||||||
<span class="file-path-text">{item.path}</span>
|
return (
|
||||||
</div>
|
<div
|
||||||
<div class="file-list-item-stats">
|
class={`file-list-item git-change-list-item ${props.selectedItemId() === item.id ? "file-list-item-active" : ""} ${isBulkSelected() ? "git-change-list-item-bulk-selected" : ""}`}
|
||||||
<Show when={item.status === "deleted"}>
|
onMouseDown={(event) => {
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||||
</Show>
|
event.preventDefault()
|
||||||
<Show when={item.status !== "deleted"}>
|
}
|
||||||
<>
|
}}
|
||||||
<span class="file-list-item-additions">+{item.added}</span>
|
onClick={(event) => props.onRowClick(item, event)}
|
||||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
title={item.path}
|
||||||
</>
|
>
|
||||||
</Show>
|
<div class="file-list-item-content" title={item.path}>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</For>
|
<div class="git-change-list-item-actions-zone">
|
||||||
</Show>
|
<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 = () => (
|
const renderGroupedList = () => (
|
||||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
<Show when={allItems.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sortedList}>
|
<div class="git-change-sections">
|
||||||
{(item) => (
|
<div class="git-change-section">
|
||||||
<div
|
<button type="button" class="git-change-section-header" onClick={props.onToggleStagedOpen}>
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
<span class="git-change-section-header-main">
|
||||||
onClick={() => props.onOpenFile(item.path)}
|
<span class="git-change-section-chevron">
|
||||||
title={item.path}
|
{props.stagedOpen() ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
|
||||||
>
|
</span>
|
||||||
<div class="file-list-item-content">
|
<span class="git-change-section-title-row">
|
||||||
<div class="file-list-item-path" title={item.path}>
|
<span class="git-change-section-title">{props.t("instanceShell.gitChanges.sections.staged")}</span>
|
||||||
<span class="file-path-text">{item.path}</span>
|
<Show when={props.branchLabel()}>
|
||||||
</div>
|
{(label) => (
|
||||||
<div class="file-list-item-stats">
|
<span class="status-indicator session-status-list worktree-indicator git-change-section-badge" title={`Branch: ${label()}`}>
|
||||||
<Show when={item.status === "deleted"}>
|
<GitBranch class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="worktree-indicator-label">{label()}</span>
|
||||||
</Show>
|
</span>
|
||||||
<Show when={item.status !== "deleted"}>
|
)}
|
||||||
<>
|
|
||||||
<span class="file-list-item-additions">+{item.added}</span>
|
|
||||||
<span class="file-list-item-deletions">-{item.removed}</span>
|
|
||||||
</>
|
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
|
<For each={stagedList}>{(item) => renderListItem(item)}</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
|
</div>
|
||||||
|
{renderSection(
|
||||||
|
props.t("instanceShell.gitChanges.sections.unstaged"),
|
||||||
|
unstagedList,
|
||||||
|
props.unstagedOpen(),
|
||||||
|
props.onToggleUnstagedOpen,
|
||||||
)}
|
)}
|
||||||
</For>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -264,9 +384,10 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
onContextModeChange={props.onContextModeChange}
|
onContextModeChange={props.onContextModeChange}
|
||||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||||
viewer={renderViewer()}
|
viewer={renderViewer()}
|
||||||
listOpen={props.listOpen()}
|
listOpen={props.listOpen()}
|
||||||
onToggleList={props.onToggleList}
|
onToggleList={props.onToggleList}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core"
|
|||||||
import { Tooltip } from "@kobalte/core/tooltip"
|
import { Tooltip } from "@kobalte/core/tooltip"
|
||||||
import Switch from "@suid/material/Switch"
|
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 { Instance } from "../../../../../types/instance"
|
||||||
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||||
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
<div class="status-process-header">
|
<div class="status-process-header">
|
||||||
<span class="status-process-title">{process.title}</span>
|
<span class="status-process-title">{process.title}</span>
|
||||||
<div class="status-process-meta">
|
<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>
|
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -5,3 +5,40 @@ export type DiffViewMode = "split" | "unified"
|
|||||||
export type DiffContextMode = "expanded" | "collapsed"
|
export type DiffContextMode = "expanded" | "collapsed"
|
||||||
|
|
||||||
export type DiffWordWrapMode = "on" | "off"
|
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_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_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_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_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_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"
|
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 { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { ClientPart, MessageInfo } from "../types/message"
|
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 { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { formatTokenTotal } from "../lib/formatters"
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { selectInstanceTab } from "../stores/app-tabs"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessage } from "../stores/session-actions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
import { useSpeech } from "../lib/hooks/use-speech"
|
||||||
import SpeechActionButton from "./speech-action-button"
|
import SpeechActionButton from "./speech-action-button"
|
||||||
|
import { createFollowScroll } from "../lib/follow-scroll"
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
function DeleteUpToIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
|
|||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||||
|
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||||
setActiveInstanceId(location.instanceId)
|
selectInstanceTab(location.instanceId)
|
||||||
const parentToActivate = location.parentId ?? location.sessionId
|
const parentToActivate = location.parentId ?? location.sessionId
|
||||||
setActiveParentSession(location.instanceId, parentToActivate)
|
setActiveParentSession(location.instanceId, parentToActivate)
|
||||||
if (location.parentId) {
|
if (location.parentId) {
|
||||||
@@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
|
|||||||
return type === "text" || type === "file"
|
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) {
|
function MessageContentItem(props: MessageContentItemProps) {
|
||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
@@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
return resolved
|
return resolved
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
|
||||||
|
|
||||||
const showAgentMeta = createMemo(() => {
|
const showAgentMeta = createMemo(() => {
|
||||||
const current = record()
|
const current = record()
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
if (current.role !== "assistant") return false
|
if (current.role !== "assistant") return false
|
||||||
|
|
||||||
const currentParts = parts()
|
const currentParts = parts()
|
||||||
if (!currentParts.some((part) => partHasRenderableText(part))) {
|
if (visibleParts().length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
if (!isSupportedPartType(part)) continue
|
if (!isSupportedPartType(part)) continue
|
||||||
|
|
||||||
if (!isContentPartType((part as any).type)) continue
|
if (!isContentPartType((part as any).type)) continue
|
||||||
if (partHasRenderableText(part)) {
|
if (isVisibleContentPart(part)) {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
<MessageItem
|
<MessageItem
|
||||||
record={resolvedRecord()}
|
record={resolvedRecord()}
|
||||||
messageInfo={messageInfo()}
|
messageInfo={messageInfo()}
|
||||||
parts={parts()}
|
parts={visibleParts()}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
isQueued={isQueued()}
|
isQueued={isQueued()}
|
||||||
@@ -619,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const lastAssistantIdx = props.lastAssistantIndex()
|
const lastAssistantIdx = props.lastAssistantIndex()
|
||||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||||
|
|
||||||
// Intentionally untracked: messageInfoVersion updates should not trigger
|
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
|
||||||
// a full message block rebuild; record revision is the invalidation key.
|
|
||||||
const info = untrack(messageInfo)
|
|
||||||
|
|
||||||
const cacheSignature = [
|
const cacheSignature = [
|
||||||
current.id,
|
current.id,
|
||||||
current.revision,
|
current.revision,
|
||||||
|
messageInfoVersion,
|
||||||
isQueued ? 1 : 0,
|
isQueued ? 1 : 0,
|
||||||
props.showThinking() ? 1 : 0,
|
props.showThinking() ? 1 : 0,
|
||||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||||
@@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
return cachedBlock.block
|
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 { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
||||||
const items: MessageBlockItem[] = []
|
const items: MessageBlockItem[] = []
|
||||||
const blockContentKeys: string[] = []
|
const blockContentKeys: string[] = []
|
||||||
@@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
data-message-id={resolvedBlock().record.id}
|
data-message-id={resolvedBlock().record.id}
|
||||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||||
>
|
>
|
||||||
<For each={resolvedBlock().items}>
|
<Index each={resolvedBlock().items}>
|
||||||
{(item, index) => (
|
{(item, index) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={item.type === "content"}>
|
<Match when={item().type === "content"}>
|
||||||
<MessageContentItem
|
<MessageContentItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={(item as ContentDisplayItem).messageId}
|
messageId={(item() as ContentDisplayItem).messageId}
|
||||||
startPartId={(item as ContentDisplayItem).startPartId}
|
startPartId={(item() as ContentDisplayItem).startPartId}
|
||||||
messageIndex={props.messageIndex}
|
messageIndex={props.messageIndex}
|
||||||
lastAssistantIndex={props.lastAssistantIndex}
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -825,18 +837,18 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "tool"}>
|
<Match when={item().type === "tool"}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const toolItem = item as ToolDisplayItem
|
const toolItem = item() as ToolDisplayItem
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-message" data-key={toolItem.key}>
|
<div class="tool-call-message" data-key={toolItem.key}>
|
||||||
<ToolCallItem
|
<ToolCallItem
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={toolItem.messageId}
|
messageId={toolItem.messageId}
|
||||||
partId={toolItem.partId}
|
partId={toolItem.partId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
deleteHover={props.deleteHover}
|
deleteHover={props.deleteHover}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
@@ -849,13 +861,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-start"}>
|
<Match when={item().type === "step-start"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="start"
|
kind="start"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showAgentMeta
|
showAgentMeta
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -865,14 +877,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-finish"}>
|
<Match when={item().type === "step-finish"}>
|
||||||
<StepCard
|
<StepCard
|
||||||
kind="finish"
|
kind="finish"
|
||||||
part={(item as StepDisplayItem).part}
|
part={(item() as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item() as StepDisplayItem).messageInfo}
|
||||||
showUsage={props.showUsageMetrics()}
|
showUsage={props.showUsageMetrics()}
|
||||||
borderColor={(item as StepDisplayItem).accentColor}
|
borderColor={(item() as StepDisplayItem).accentColor}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={props.messageId}
|
messageId={props.messageId}
|
||||||
@@ -882,31 +894,31 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "compaction"}>
|
<Match when={item().type === "compaction"}>
|
||||||
<CompactionCard
|
<CompactionCard
|
||||||
part={(item as CompactionDisplayItem).part}
|
part={(item() as CompactionDisplayItem).part}
|
||||||
messageInfo={(item as CompactionDisplayItem).messageInfo}
|
messageInfo={(item() as CompactionDisplayItem).messageInfo}
|
||||||
borderColor={(item as CompactionDisplayItem).accentColor}
|
borderColor={(item() as CompactionDisplayItem).accentColor}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as CompactionDisplayItem).messageId}
|
messageId={(item() as CompactionDisplayItem).messageId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "reasoning"}>
|
<Match when={item().type === "reasoning"}>
|
||||||
<ReasoningCard
|
<ReasoningCard
|
||||||
part={(item as ReasoningDisplayItem).part}
|
part={(item() as ReasoningDisplayItem).part}
|
||||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as ReasoningDisplayItem).messageId}
|
messageId={(item() as ReasoningDisplayItem).messageId}
|
||||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
|
||||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index === 0}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
@@ -916,7 +928,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)}
|
)}
|
||||||
</For>
|
</Index>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const info = props.messageInfo
|
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
|
return null
|
||||||
}
|
}
|
||||||
const tokens = info.tokens
|
|
||||||
return {
|
return {
|
||||||
input: tokens.input ?? 0,
|
input: tokens.input ?? 0,
|
||||||
output: tokens.output ?? 0,
|
output: tokens.output ?? 0,
|
||||||
reasoning: tokens.reasoning ?? 0,
|
reasoning: tokens.reasoning ?? 0,
|
||||||
cacheRead: tokens.cache?.read ?? 0,
|
cacheRead: tokens.cache?.read ?? 0,
|
||||||
cacheWrite: tokens.cache?.write ?? 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
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningStreamOutput(props: {
|
||||||
const { t } = useI18n()
|
text: Accessor<string>
|
||||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
scrollTopSnapshot: Accessor<number>
|
||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
setScrollTopSnapshot: (next: number) => void
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
onContentRendered?: () => void
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
ariaLabel: string
|
||||||
|
}) {
|
||||||
|
let preRef: HTMLPreElement | undefined
|
||||||
let pendingRenderNotificationFrame: number | null = null
|
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 = () => {
|
const notifyContentRendered = () => {
|
||||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
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(() => {
|
onCleanup(() => {
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
if (pendingRenderNotificationFrame !== null) {
|
||||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
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(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1393,12 +1460,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!expanded()) return
|
|
||||||
reasoningText()
|
|
||||||
notifyContentRendered()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
|
|
||||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
@@ -1553,9 +1614,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
<ReasoningStreamOutput
|
||||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
text={reasoningText}
|
||||||
</div>
|
scrollTopSnapshot={scrollTopSnapshot}
|
||||||
|
setScrollTopSnapshot={setScrollTopSnapshot}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
|||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
||||||
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
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 type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
const getRawContent = () => {
|
const getRawContent = () => {
|
||||||
return props.parts
|
return props.parts
|
||||||
.filter(part => part.type === "text")
|
.filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
|
||||||
.map(part => (part as { text?: string }).text || "")
|
.map((part) => (part as { text?: string }).text || "")
|
||||||
.filter(text => text.trim().length > 0)
|
.filter((text) => text.trim().length > 0)
|
||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUser() && !hasContent() && !isGenerating()) {
|
if (!hasContent() && !isGenerating()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
const shouldHideTextPart = () => {
|
const shouldHideTextPart = () => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
if (!part || part.type !== "text") return false
|
if (!part || part.type !== "text") return false
|
||||||
|
return Boolean((part as any).synthetic)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
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 Kbd from "./kbd"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
||||||
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
import { partHasRenderableText } from "../types/message"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getPartCharCount } from "../lib/token-utils"
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
|
|
||||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
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
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
export interface MessageSectionProps {
|
export interface MessageSectionProps {
|
||||||
@@ -40,12 +42,40 @@ export interface MessageSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageSection(props: MessageSectionProps) {
|
export default function MessageSection(props: MessageSectionProps) {
|
||||||
const { preferences } = useConfig()
|
const { preferences, updatePreferences } = useConfig()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||||
|
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
|
||||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
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({
|
const scrollCache = useScrollCache({
|
||||||
instanceId: props.instanceId,
|
instanceId: props.instanceId,
|
||||||
@@ -129,6 +159,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
|
||||||
|
|
||||||
const lastCompactionIndex = createMemo(() => {
|
const lastCompactionIndex = createMemo(() => {
|
||||||
// Depend on a single session revision signal (not every message/part read)
|
// Depend on a single session revision signal (not every message/part read)
|
||||||
// to keep reactive overhead small.
|
// to keep reactive overhead small.
|
||||||
@@ -315,15 +347,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastAssistantIndex = createMemo(() => {
|
const lastAssistantIndex = createMemo(() => {
|
||||||
const ids = messageIds()
|
const messageId = lastAssistantMessageId()
|
||||||
const resolvedStore = store()
|
if (!messageId) return -1
|
||||||
for (let index = ids.length - 1; index >= 0; index--) {
|
return messageIndexById().get(messageId) ?? -1
|
||||||
const record = resolvedStore.getMessage(ids[index])
|
|
||||||
if (record?.role === "assistant") {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||||
@@ -571,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [streamShellElement, setStreamShellElement] = 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 initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
||||||
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
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 [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(() => {
|
createEffect(() => {
|
||||||
const api = listApi()
|
const api = listApi()
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -615,7 +680,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const api = listApi()
|
const api = listApi()
|
||||||
if (!element || !api) return
|
if (!element || !api) return
|
||||||
if (props.loading) return
|
if (props.loading) return
|
||||||
if (messageIds().length === 0) return
|
if (visibleMessageIds().length === 0) return
|
||||||
if (didRestoreScroll()) return
|
if (didRestoreScroll()) return
|
||||||
|
|
||||||
scrollCache.restore(element, {
|
scrollCache.restore(element, {
|
||||||
@@ -734,88 +799,93 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const loading = Boolean(props.loading)
|
const loading = Boolean(props.loading)
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
|
|
||||||
if (loading) {
|
// Wrap all iteration of the store-proxied `ids` array in untrack()
|
||||||
handleClearTimelineSelection()
|
// to prevent O(n) per-element reactive subscriptions. The effect
|
||||||
previousTimelineIds = []
|
// only needs to re-run when `messageIds` (memo) changes.
|
||||||
setTimelineSegments([])
|
untrack(() => {
|
||||||
seenTimelineMessageIds.clear()
|
if (loading) {
|
||||||
seenTimelineSegmentKeys.clear()
|
handleClearTimelineSelection()
|
||||||
timelinePartCountsByMessageId.clear()
|
previousTimelineIds = []
|
||||||
pendingTimelineMessagePartUpdates.clear()
|
setTimelineSegments([])
|
||||||
if (pendingTimelinePartUpdateFrame !== null) {
|
seenTimelineMessageIds.clear()
|
||||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
seenTimelineSegmentKeys.clear()
|
||||||
pendingTimelinePartUpdateFrame = null
|
timelinePartCountsByMessageId.clear()
|
||||||
}
|
pendingTimelineMessagePartUpdates.clear()
|
||||||
return
|
if (pendingTimelinePartUpdateFrame !== null) {
|
||||||
}
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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.
|
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
seedTimeline()
|
||||||
if (existingPartCount !== undefined) {
|
previousTimelineIds = [...ids]
|
||||||
timelinePartCountsByMessageId.delete(oldId)
|
return
|
||||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
}
|
||||||
|
|
||||||
|
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()
|
// Keep part count tracking in sync with id replacement.
|
||||||
return
|
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||||
|
if (existingPartCount !== undefined) {
|
||||||
|
timelinePartCountsByMessageId.delete(oldId)
|
||||||
|
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const newIds: string[] = []
|
const newIds: string[] = []
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
if (!seenTimelineMessageIds.has(id)) {
|
if (!seenTimelineMessageIds.has(id)) {
|
||||||
newIds.push(id)
|
newIds.push(id)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (newIds.length > 0) {
|
|
||||||
newIds.forEach((id) => {
|
|
||||||
seenTimelineMessageIds.add(id)
|
|
||||||
appendTimelineForMessage(id)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
if (newIds.length > 0) {
|
||||||
|
newIds.forEach((id) => {
|
||||||
|
seenTimelineMessageIds.add(id)
|
||||||
|
appendTimelineForMessage(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = [...ids]
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearPendingTimelinePartUpdateFrame() {
|
function clearPendingTimelinePartUpdateFrame() {
|
||||||
@@ -886,36 +956,49 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.loading) return
|
if (props.loading) return
|
||||||
const ids = messageIds()
|
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
|
// Wrap the iteration in untrack() so that accessing individual elements
|
||||||
for (const messageId of ids) {
|
// of the store-proxied `ids` array does not create O(n) per-element
|
||||||
const record = resolvedStore.getMessage(messageId)
|
// reactive subscriptions. We only need to re-run when the memo
|
||||||
const partCount = record?.partIds.length ?? 0
|
// (messageIds) or sessionRevision changes — not per-element.
|
||||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
untrack(() => {
|
||||||
|
const resolvedStore = store()
|
||||||
|
const idsSet = new Set(ids)
|
||||||
|
let hasChanges = false
|
||||||
|
|
||||||
if (previousCount === undefined) {
|
for (const messageId of ids) {
|
||||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
continue
|
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) {
|
// Drop tracking for ids that are no longer present.
|
||||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
|
||||||
pendingTimelineMessagePartUpdates.add(messageId)
|
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||||
hasChanges = true
|
if (!idsSet.has(trackedId)) {
|
||||||
|
timelinePartCountsByMessageId.delete(trackedId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Drop tracking for ids that are no longer present.
|
if (hasChanges) {
|
||||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
scheduleTimelinePartUpdateFlush()
|
||||||
if (!ids.includes(trackedId)) {
|
|
||||||
timelinePartCountsByMessageId.delete(trackedId)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
scheduleTimelinePartUpdateFlush()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -989,7 +1072,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
data-scroll-buttons={scrollButtonsCount()}
|
data-scroll-buttons={scrollButtonsCount()}
|
||||||
>
|
>
|
||||||
<VirtualFollowList
|
<VirtualFollowList
|
||||||
items={messageIds}
|
items={visibleMessageIds}
|
||||||
getKey={(messageId) => messageId}
|
getKey={(messageId) => messageId}
|
||||||
getAnchorId={getMessageAnchorId}
|
getAnchorId={getMessageAnchorId}
|
||||||
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
||||||
@@ -1003,6 +1086,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
initialAutoScroll={initialAutoScroll}
|
initialAutoScroll={initialAutoScroll}
|
||||||
resetKey={() => props.sessionId}
|
resetKey={() => props.sessionId}
|
||||||
followToken={followToken}
|
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={() => {
|
onScroll={() => {
|
||||||
clearQuoteSelection()
|
clearQuoteSelection()
|
||||||
scrollCache.persist(streamElement())
|
scrollCache.persist(streamElement())
|
||||||
@@ -1033,9 +1122,55 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
||||||
registerApi={(api) => setListApi(api)}
|
registerApi={(api) => setListApi(api)}
|
||||||
registerState={(state) => setListState(state)}
|
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={() => (
|
renderBeforeItems={() => (
|
||||||
<>
|
<>
|
||||||
<Show when={!props.loading && messageIds().length === 0}>
|
<Show when={!props.loading && visibleMessageIds().length === 0}>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-content">
|
<div class="empty-state-content">
|
||||||
<div class="flex flex-col items-center gap-3 mb-6">
|
<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 { 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 MessagePreview from "./message-preview"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import type { ClientPart } from "../types/message"
|
import type { ClientPart } from "../types/message"
|
||||||
|
import { isHiddenSyntheticTextPart } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getPartCharCount } from "../lib/token-utils"
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
@@ -53,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
|
|||||||
const LONG_PRESS_MS = 500
|
const LONG_PRESS_MS = 500
|
||||||
const JITTER_THRESHOLD = 10
|
const JITTER_THRESHOLD = 10
|
||||||
const ABSOLUTE_TOKEN_CAP = 10000
|
const ABSOLUTE_TOKEN_CAP = 10000
|
||||||
|
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
@@ -65,6 +69,13 @@ interface PendingSegment {
|
|||||||
hasPrimaryText: boolean
|
hasPrimaryText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimelineSegmentState {
|
||||||
|
deleteHovered: boolean
|
||||||
|
deleteSelected: boolean
|
||||||
|
hasActivePermission: boolean
|
||||||
|
hidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
function truncateText(value: string): string {
|
function truncateText(value: string): string {
|
||||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||||
return value
|
return value
|
||||||
@@ -105,6 +116,7 @@ function collectReasoningText(part: ClientPart): string {
|
|||||||
|
|
||||||
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
|
if (isHiddenSyntheticTextPart(part)) return ""
|
||||||
if (typeof (part as any).text === "string") {
|
if (typeof (part as any).text === "string") {
|
||||||
return (part as any).text as 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 = () => {
|
const scheduleClose = () => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
clearHoverTimer()
|
clearHoverTimer()
|
||||||
@@ -356,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||||
closeTimer = window.setTimeout(() => {
|
closeTimer = window.setTimeout(() => {
|
||||||
closeTimer = null
|
closeTimer = null
|
||||||
setHoveredSegment(null)
|
clearHoverPreview()
|
||||||
setHoverAnchorRect(null)
|
|
||||||
}, 160)
|
}, 160)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
clearHoverTimer()
|
clearHoverPreview()
|
||||||
clearCloseTimer()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Selection & histogram rib state ---
|
// --- Selection & histogram rib state ---
|
||||||
@@ -416,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
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 scrollContainerRef: HTMLDivElement | undefined
|
||||||
let xrayOverlayRef: HTMLDivElement | undefined
|
let xrayOverlayRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -447,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
if (renderVirtualizedTimeline()) {
|
||||||
|
if (hoveredSegment()) {
|
||||||
|
clearHoverPreview()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!isSelectionActive()) return
|
if (!isSelectionActive()) return
|
||||||
if (!scrollContainerRef || !xrayOverlayRef) return
|
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
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))
|
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
||||||
|
|
||||||
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
||||||
@@ -577,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
wasLongPress = true
|
wasLongPress = true
|
||||||
|
|
||||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
// 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
|
let anchorOffset: number | null = null
|
||||||
if (btn && scrollContainerRef) {
|
if (btn && scrollContainerRef) {
|
||||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||||
@@ -629,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
|
|
||||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||||
if (!activeId) return
|
if (!activeId) return
|
||||||
const element = buttonRefs.get(activeId)
|
|
||||||
if (!element) return
|
|
||||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
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" })
|
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
}, 120) : null
|
}, 120) : null
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -682,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
return map
|
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 (
|
return (
|
||||||
<div class="message-timeline-container">
|
<div class="message-timeline-container">
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={(element) => {
|
||||||
|
scrollContainerRef = element
|
||||||
|
setScrollElement(element)
|
||||||
|
}}
|
||||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t("messageTimeline.ariaLabel")}
|
aria-label={t("messageTimeline.ariaLabel")}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<For each={props.segments}>
|
<Show
|
||||||
{(segment, segIndex) => {
|
when={renderVirtualizedTimeline()}
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
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 isActive = () => props.activeSegmentId === segment.id
|
||||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||||
|
const state = () => segmentStateFor(segment.id)
|
||||||
const isDeleteHovered = () => {
|
const isDeleteHovered = () => state().deleteHovered
|
||||||
const hover = deleteHover() as DeleteHoverState
|
const isDeleteSelected = () => state().deleteSelected
|
||||||
if (hover.kind === "message") {
|
const hasActivePermission = () => state().hasActivePermission
|
||||||
return hover.messageId === segment.messageId
|
const isHidden = () => state().hidden
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
|
|
||||||
// Group visual indicators: tools belong to the same message as their
|
// Group visual indicators: tools belong to the same message as their
|
||||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
// 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"
|
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||||
return "none"
|
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 = () => {
|
const shortLabelContent = () => {
|
||||||
if (segment.type === "tool") {
|
if (segment.type === "tool") {
|
||||||
if (hasActivePermission()) {
|
if (hasActivePermission()) {
|
||||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
return segment.shortLabel ?? getToolIcon("tool")
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
@@ -765,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
if (segment.type === "user") {
|
if (segment.type === "user") {
|
||||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
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 (
|
return (
|
||||||
<button
|
<div class="message-timeline-item">
|
||||||
ref={(el) => registerButtonRef(segment.id, el)}
|
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||||
type="button"
|
<button
|
||||||
data-variant={segment.variant}
|
type="button"
|
||||||
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-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}
|
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||||
|
aria-current={isActive() ? "true" : undefined}
|
||||||
aria-current={isActive() ? "true" : undefined}
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
aria-hidden={isHidden() ? "true" : undefined}
|
onClick={(event) => {
|
||||||
onClick={(event) => {
|
if (wasLongPress) {
|
||||||
if (wasLongPress) {
|
wasLongPress = false
|
||||||
wasLongPress = false
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}}
|
const btn = buttonRefs.get(segment.id)
|
||||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||||
onPointerUp={handlePointerUp}
|
let anchorOffset: number | null = null
|
||||||
onPointerCancel={handlePointerUp}
|
if (stableBtn && scrollContainerRef) {
|
||||||
onPointerMove={handlePointerMove}
|
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||||
onContextMenu={handleContextMenu}
|
}
|
||||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||||
>
|
|
||||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
if (event.shiftKey) {
|
||||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
props.onSelectRange?.(segment.id)
|
||||||
</button>
|
} else if (event.ctrlKey || event.metaKey) {
|
||||||
)
|
props.onToggleSelection?.(segment.id)
|
||||||
}}
|
} else if (isMultiSelectActive) {
|
||||||
</For>
|
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()}>
|
<Show when={previewData()}>
|
||||||
{(data) => {
|
{(data) => {
|
||||||
onCleanup(() => setTooltipElement(null))
|
onCleanup(() => setTooltipElement(null))
|
||||||
return (
|
return (
|
||||||
<div
|
<Portal>
|
||||||
ref={(element) => setTooltipElement(element)}
|
<div
|
||||||
class="message-timeline-tooltip"
|
ref={(element) => setTooltipElement(element)}
|
||||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
class="message-timeline-tooltip"
|
||||||
onMouseEnter={() => clearCloseTimer()}
|
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||||
onMouseLeave={() => scheduleClose()}
|
onMouseEnter={() => clearCloseTimer()}
|
||||||
>
|
onMouseLeave={() => scheduleClose()}
|
||||||
<MessagePreview
|
>
|
||||||
messageId={data().messageId}
|
<MessagePreview
|
||||||
instanceId={props.instanceId}
|
messageId={data().messageId}
|
||||||
sessionId={props.sessionId}
|
instanceId={props.instanceId}
|
||||||
store={store}
|
sessionId={props.sessionId}
|
||||||
deleteHover={props.deleteHover}
|
store={store}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
deleteHover={props.deleteHover}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
/>
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
insertQuotedSelection(text)
|
insertQuotedSelection(text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
insertComment: (text: string) => {
|
||||||
|
const normalized = (text ?? "").replace(/\r/g, "").trim()
|
||||||
|
if (!normalized) return
|
||||||
|
insertBlockContent(`${normalized}\n\n`)
|
||||||
|
},
|
||||||
expandTextAttachment: (attachmentId: string) => {
|
expandTextAttachment: (attachmentId: string) => {
|
||||||
const attachment = attachments().find((a) => a.id === attachmentId)
|
const attachment = attachments().find((a) => a.id === attachmentId)
|
||||||
if (!attachment) return
|
if (!attachment) return
|
||||||
@@ -540,6 +545,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
mode={pickerMode()}
|
mode={pickerMode()}
|
||||||
onClose={handlePickerClose}
|
onClose={handlePickerClose}
|
||||||
onSelect={handlePickerSelect}
|
onSelect={handlePickerSelect}
|
||||||
|
onSubmitWithoutSelection={() => {
|
||||||
|
handlePickerClose()
|
||||||
|
void handleSend()
|
||||||
|
}}
|
||||||
agents={instanceAgents()}
|
agents={instanceAgents()}
|
||||||
commands={getCommands(props.instanceId)}
|
commands={getCommands(props.instanceId)}
|
||||||
instanceClient={instance()!.client}
|
instanceClient={instance()!.client}
|
||||||
@@ -572,113 +581,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<div class="prompt-nav-buttons">
|
|
||||||
<div class="prompt-nav-column prompt-nav-column-left">
|
|
||||||
<Show when={showVoiceInput()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
|
||||||
onPointerDown={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
beginVoicePress(event)
|
|
||||||
}}
|
|
||||||
onPointerUp={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
endVoicePress()
|
|
||||||
}}
|
|
||||||
onPointerCancel={() => endVoicePress()}
|
|
||||||
onLostPointerCapture={() => endVoicePress()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.repeat) return
|
|
||||||
if (event.key !== " " && event.key !== "Enter") return
|
|
||||||
event.preventDefault()
|
|
||||||
beginVoicePress(event)
|
|
||||||
}}
|
|
||||||
onKeyUp={(event) => {
|
|
||||||
if (event.key !== " " && event.key !== "Enter") return
|
|
||||||
event.preventDefault()
|
|
||||||
endVoicePress()
|
|
||||||
}}
|
|
||||||
onBlur={() => endVoicePress()}
|
|
||||||
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
|
||||||
aria-label={voiceInput.buttonTitle()}
|
|
||||||
title={voiceInput.buttonTitle()}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={voiceInput.isRecording()}
|
|
||||||
fallback={
|
|
||||||
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
|
||||||
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<Show when={showConversationToggle()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
|
||||||
onClick={() => toggleConversationMode(props.instanceId)}
|
|
||||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
|
||||||
aria-pressed={conversationModeEnabled()}
|
|
||||||
aria-label={conversationModeButtonTitle()}
|
|
||||||
title={conversationModeButtonTitle()}
|
|
||||||
>
|
|
||||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="prompt-clear-button"
|
|
||||||
onClick={handleClearPrompt}
|
|
||||||
disabled={!canClearPrompt()}
|
|
||||||
aria-label={t("promptInput.clear.ariaLabel")}
|
|
||||||
title={t("promptInput.clear.title")}
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="prompt-nav-column prompt-nav-column-right">
|
|
||||||
<ExpandButton
|
|
||||||
expandState={expandState}
|
|
||||||
onToggleExpand={handleExpandToggle}
|
|
||||||
/>
|
|
||||||
<Show when={hasHistory()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="prompt-history-button"
|
|
||||||
onClick={() =>
|
|
||||||
selectPreviousHistory({
|
|
||||||
force: true,
|
|
||||||
isPickerOpen: showPicker(),
|
|
||||||
getTextarea: () => textareaRef,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!canHistoryGoPrevious()}
|
|
||||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
|
||||||
>
|
|
||||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="prompt-history-button"
|
|
||||||
onClick={() =>
|
|
||||||
selectNextHistory({
|
|
||||||
force: true,
|
|
||||||
isPickerOpen: showPicker(),
|
|
||||||
getTextarea: () => textareaRef,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!canHistoryGoNext()}
|
|
||||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
|
||||||
>
|
|
||||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={shouldShowOverlay()}>
|
<Show when={shouldShowOverlay()}>
|
||||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
<Show
|
<Show
|
||||||
@@ -733,6 +635,116 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<div class="prompt-input-actions">
|
||||||
|
<div class="prompt-nav-buttons">
|
||||||
|
<div class="prompt-nav-column prompt-nav-column-left">
|
||||||
|
<Show when={showVoiceInput()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
beginVoicePress(event)
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
endVoicePress()
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => endVoicePress()}
|
||||||
|
onLostPointerCapture={() => endVoicePress()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.repeat) return
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
beginVoicePress(event)
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
endVoicePress()
|
||||||
|
}}
|
||||||
|
onBlur={() => endVoicePress()}
|
||||||
|
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||||
|
aria-label={voiceInput.buttonTitle()}
|
||||||
|
title={voiceInput.buttonTitle()}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={voiceInput.isRecording()}
|
||||||
|
fallback={
|
||||||
|
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={showConversationToggle()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||||
|
onClick={() => toggleConversationMode(props.instanceId)}
|
||||||
|
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||||
|
aria-pressed={conversationModeEnabled()}
|
||||||
|
aria-label={conversationModeButtonTitle()}
|
||||||
|
title={conversationModeButtonTitle()}
|
||||||
|
>
|
||||||
|
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-clear-button"
|
||||||
|
onClick={handleClearPrompt}
|
||||||
|
disabled={!canClearPrompt()}
|
||||||
|
aria-label={t("promptInput.clear.ariaLabel")}
|
||||||
|
title={t("promptInput.clear.title")}
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-nav-column prompt-nav-column-right">
|
||||||
|
<ExpandButton
|
||||||
|
expandState={expandState}
|
||||||
|
onToggleExpand={handleExpandToggle}
|
||||||
|
/>
|
||||||
|
<Show when={hasHistory()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-history-button"
|
||||||
|
onClick={() =>
|
||||||
|
selectPreviousHistory({
|
||||||
|
force: true,
|
||||||
|
isPickerOpen: showPicker(),
|
||||||
|
getTextarea: () => textareaRef,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!canHistoryGoPrevious()}
|
||||||
|
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||||
|
>
|
||||||
|
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-history-button"
|
||||||
|
onClick={() =>
|
||||||
|
selectNextHistory({
|
||||||
|
force: true,
|
||||||
|
isPickerOpen: showPicker(),
|
||||||
|
getTextarea: () => textareaRef,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!canHistoryGoNext()}
|
||||||
|
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||||
|
>
|
||||||
|
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-input-primary-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="stop-button"
|
class="stop-button"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type PromptInsertMode = "quote" | "code"
|
|||||||
|
|
||||||
export interface PromptInputApi {
|
export interface PromptInputApi {
|
||||||
insertSelection(text: string, mode: PromptInsertMode): void
|
insertSelection(text: string, mode: PromptInsertMode): void
|
||||||
|
insertComment(text: string): void
|
||||||
expandTextAttachment(attachmentId: string): void
|
expandTextAttachment(attachmentId: string): void
|
||||||
removeAttachment(attachmentId: string): void
|
removeAttachment(attachmentId: string): void
|
||||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||||
|
|||||||
@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pickerMode() === "mention" && pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
|
|
||||||
// Remove the partial @mention text from the textarea when ESC is pressed
|
|
||||||
const textarea = options.getTextarea()
|
|
||||||
if (textarea) {
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const cursorPos = textarea.selectionStart
|
|
||||||
// Remove text from @ position to cursor position
|
|
||||||
const before = currentPrompt.substring(0, pos)
|
|
||||||
const after = currentPrompt.substring(cursorPos)
|
|
||||||
options.setPrompt(before + after)
|
|
||||||
|
|
||||||
// Restore cursor position to where @ was
|
|
||||||
setTimeout(() => {
|
|
||||||
const nextTextarea = options.getTextarea()
|
|
||||||
if (nextTextarea) {
|
|
||||||
nextTextarea.setSelectionRange(pos, pos)
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
// Clear ignoredAtPositions so typing @ again will work
|
|
||||||
setIgnoredAtPositions(new Set<number>())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
|
|||||||
@@ -169,18 +169,25 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
|||||||
const textarea = options.getTextarea()
|
const textarea = options.getTextarea()
|
||||||
const start = textarea ? textarea.selectionStart : current.length
|
const start = textarea ? textarea.selectionStart : current.length
|
||||||
const end = textarea ? textarea.selectionEnd : current.length
|
const end = textarea ? textarea.selectionEnd : current.length
|
||||||
|
const wasCursorAtEnd = end === current.length
|
||||||
|
const wasScrolledToBottom = textarea
|
||||||
|
? textarea.scrollHeight - (textarea.scrollTop + textarea.clientHeight) <= 4
|
||||||
|
: false
|
||||||
const before = current.slice(0, start)
|
const before = current.slice(0, start)
|
||||||
const after = current.slice(end)
|
const after = current.slice(end)
|
||||||
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
|
const prefix = ""
|
||||||
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
|
const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " "
|
||||||
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
||||||
const cursor = before.length + prefix.length + text.length
|
const cursor = before.length + prefix.length + text.length + suffix.length
|
||||||
|
|
||||||
options.setPrompt(nextValue)
|
options.setPrompt(nextValue)
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
textarea.setSelectionRange(cursor, cursor)
|
textarea.setSelectionRange(cursor, cursor)
|
||||||
|
if (wasCursorAtEnd || wasScrolledToBottom) {
|
||||||
|
textarea.scrollTop = textarea.scrollHeight
|
||||||
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
|
|||||||
import { Switch } from "@kobalte/core/switch"
|
import { Switch } from "@kobalte/core/switch"
|
||||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import { toDataURL } from "qrcode"
|
import { toDataURL } from "qrcode"
|
||||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { restartCli } from "../lib/native/cli"
|
import { restartCli } from "../lib/native/cli"
|
||||||
@@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
|
|||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
@@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
if (!allowExternalConnections()) {
|
if (!allowExternalConnections()) {
|
||||||
return []
|
return { recommended: null, hidden: [] }
|
||||||
}
|
}
|
||||||
// Local URL is displayed separately; list only remote-friendly addresses.
|
return splitRemoteAddresses(list)
|
||||||
return list.filter((address) => address.scope !== "loopback")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
@@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(metaResult)
|
setMeta(metaResult)
|
||||||
setAuthStatus(authResult)
|
setAuthStatus(authResult)
|
||||||
|
setShowAllAddresses(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -326,7 +328,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
|
|
||||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
<Show when={meta()?.localUrl}>
|
<Show when={meta()?.localUrl}>
|
||||||
{(url) => {
|
{(url) => {
|
||||||
@@ -373,8 +375,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
<For each={displayAddresses()}>
|
<Show when={displayAddresses().recommended}>
|
||||||
{(address) => {
|
{(addressAccessor) => {
|
||||||
|
const address = addressAccessor()
|
||||||
const url = address.remoteUrl
|
const url = address.remoteUrl
|
||||||
const expandedState = () => expandedUrl() === url
|
const expandedState = () => expandedUrl() === url
|
||||||
const qr = () => qrCodes()[url]
|
const qr = () => qrCodes()[url]
|
||||||
@@ -384,13 +387,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
: address.scope === "loopback"
|
: address.scope === "loopback"
|
||||||
? t("remoteAccess.address.scope.loopback")
|
? t("remoteAccess.address.scope.loopback")
|
||||||
: t("remoteAccess.address.scope.internal")
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="remote-address">
|
<div class="remote-address">
|
||||||
<div class="remote-address-main">
|
<div class="remote-address-main">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-address-url">{url}</p>
|
<p class="remote-address-url">{url}</p>
|
||||||
<p class="remote-address-meta">
|
<p class="remote-address-meta">
|
||||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="remote-actions">
|
<div class="remote-actions">
|
||||||
@@ -425,7 +429,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={displayAddresses().hidden.length > 0}>
|
||||||
|
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||||
|
<button
|
||||||
|
class="remote-address-disclosure-trigger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||||
|
aria-expanded={showAllAddresses()}
|
||||||
|
>
|
||||||
|
<span class="remote-address-disclosure-label">
|
||||||
|
{showAllAddresses()
|
||||||
|
? t("remoteAccess.addresses.actions.hideOther")
|
||||||
|
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||||
|
</span>
|
||||||
|
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={showAllAddresses()}>
|
||||||
|
<div class="remote-address-disclosure-content">
|
||||||
|
<For each={displayAddresses().hidden}>
|
||||||
|
{(address) => {
|
||||||
|
const url = address.remoteUrl
|
||||||
|
const expandedState = () => expandedUrl() === url
|
||||||
|
const qr = () => qrCodes()[url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{url}</p>
|
||||||
|
<p class="remote-address-meta">
|
||||||
|
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(url)}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -520,7 +520,11 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
|
<span
|
||||||
|
class={`status-indicator session-status session-status-list ${statusClassName()} notranslate`}
|
||||||
|
title={statusTooltip()}
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||||
{statusText()}
|
{statusText()}
|
||||||
</span>
|
</span>
|
||||||
@@ -736,7 +740,9 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div class="session-list-header p-3 border-b border-base">
|
<div class="session-list-header p-3 border-b border-base">
|
||||||
{props.headerContent ?? (
|
{props.headerContent ?? (
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3>
|
<h3 class="text-sm font-semibold text-primary notranslate" translate="no">
|
||||||
|
{t("sessionList.header.title")}
|
||||||
|
</h3>
|
||||||
<KeyboardHint
|
<KeyboardHint
|
||||||
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user