Compare commits
245 Commits
split
...
v0.2.8-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ab2d5e2c | ||
|
|
72773546f5 | ||
|
|
2f58e8a1a9 | ||
|
|
d0cab51eca | ||
|
|
6f04d23b09 | ||
|
|
3e72b83393 | ||
|
|
87da8ee9f8 | ||
|
|
ec5c5c8c0f | ||
|
|
b9394fb467 | ||
|
|
de432106e5 | ||
|
|
1fbf51b7ae | ||
|
|
864d665049 | ||
|
|
c4a9c032a3 | ||
|
|
3373e23a41 | ||
|
|
b0650a283e | ||
|
|
52149f5543 | ||
|
|
2e5a904034 | ||
|
|
f1ad1400a7 | ||
|
|
bbd28404ff | ||
|
|
04f6e362b9 | ||
|
|
0b9cce6f86 | ||
|
|
d68cb6b1b8 | ||
|
|
e345dc1262 | ||
|
|
2b27790a81 | ||
|
|
2514fa94b4 | ||
|
|
522910ff64 | ||
|
|
971abe24d7 | ||
|
|
49143bd049 | ||
|
|
df52ed3035 | ||
|
|
617aac8fd8 | ||
|
|
6e82ecc97e | ||
|
|
636a19fc50 | ||
|
|
97f78bb337 | ||
|
|
0ca39d2fb0 | ||
|
|
aad1337111 | ||
|
|
6d7bc813ed | ||
|
|
1a0dd21540 | ||
|
|
7cf9c35375 | ||
|
|
f1c32253af | ||
|
|
4a8d13e2cd | ||
|
|
b0fd63ead5 | ||
|
|
94cb741c7f | ||
|
|
976430d61c | ||
|
|
8a8555d591 | ||
|
|
57c1605242 | ||
|
|
cfbd0bdffa | ||
|
|
58efb8bc3e | ||
|
|
b35bfe63c0 | ||
|
|
d7b5f53d59 | ||
|
|
168b782006 | ||
|
|
9e0fbd185d | ||
|
|
11be314f63 | ||
|
|
36ee301ef2 | ||
|
|
d6dd06b7d1 | ||
|
|
6a16dd8f98 | ||
|
|
78338f33c1 | ||
|
|
8c72d279df | ||
|
|
a9500276c8 | ||
|
|
f9ec757c64 | ||
|
|
f4c9385661 | ||
|
|
6ba50cadd2 | ||
|
|
8d5169cb39 | ||
|
|
fe8b4a9acd | ||
|
|
831e59cd77 | ||
|
|
7fde8afcf0 | ||
|
|
d07c2ec4a9 | ||
|
|
4306147990 | ||
|
|
c614da3e3c | ||
|
|
73b59d8266 | ||
|
|
a2d8ea0dfd | ||
|
|
52ee196103 | ||
|
|
1a1aee8f91 | ||
|
|
5384ff8e80 | ||
|
|
6d5836ce1f | ||
|
|
d3dc170e02 | ||
|
|
983c8cc4a3 | ||
|
|
757c587b17 | ||
|
|
5f9cf397b9 | ||
|
|
78ab17d148 | ||
|
|
e91923ad99 | ||
|
|
fd23ea54b6 | ||
|
|
1e7969eaba | ||
|
|
77bfe41a8e | ||
|
|
6d134e4dec | ||
|
|
9423326193 | ||
|
|
c5011e4ece | ||
|
|
66c270151a | ||
|
|
5ce41217e9 | ||
|
|
1e4d949d35 | ||
|
|
6bb9e8e414 | ||
|
|
1efc49b67b | ||
|
|
f0ed98222a | ||
|
|
ddd8ce341a | ||
|
|
b7721ba3e7 | ||
|
|
0554018980 | ||
|
|
ca18942bfd | ||
|
|
c9c1f69b82 | ||
|
|
aa0c31fa1e | ||
|
|
96b88dbcdc | ||
|
|
50676416ed | ||
|
|
f633d75005 | ||
|
|
4085f6d6b9 | ||
|
|
ae288833e1 | ||
|
|
f16e244265 | ||
|
|
b6e43c899b | ||
|
|
9fa436b0b8 | ||
|
|
ccd65fbc74 | ||
|
|
daa7e3a6d1 | ||
|
|
ff356ac5ea | ||
|
|
d68b92ff38 | ||
|
|
940216d98b | ||
|
|
69cd3cf545 | ||
|
|
042a45db0d | ||
|
|
cc45c16d73 | ||
|
|
91fb351a63 | ||
|
|
d9b149a7cb | ||
|
|
222a467a19 | ||
|
|
18513939f7 | ||
|
|
c123714271 | ||
|
|
5c82a2d653 | ||
|
|
435881529e | ||
|
|
700342670c | ||
|
|
2f40f5eedf | ||
|
|
54905c5626 | ||
|
|
1bf1a4761d | ||
|
|
755695a35a | ||
|
|
6a9a442948 | ||
|
|
3db9b0f673 | ||
|
|
4e0e5dcdca | ||
|
|
fad2809299 | ||
|
|
c77bfc2ee7 | ||
|
|
f1fa28dd2c | ||
|
|
91ace25333 | ||
|
|
b54db28fb1 | ||
|
|
f13feb3062 | ||
|
|
4622bdc7ea | ||
|
|
919127b6d9 | ||
|
|
27cd4515cd | ||
|
|
93a5c16cab | ||
|
|
16b76385e2 | ||
|
|
9313b2bd6c | ||
|
|
d25cb09714 | ||
|
|
0d0d1271c3 | ||
|
|
1fd3b2e75c | ||
|
|
bf32fcf136 | ||
|
|
48eb6b8982 | ||
|
|
797fafe854 | ||
|
|
b342660ed0 | ||
|
|
169d5ddeb9 | ||
|
|
38642b60e9 | ||
|
|
01effb8924 | ||
|
|
b434bfd3e9 | ||
|
|
ed769911d6 | ||
|
|
dd6efee900 | ||
|
|
48a16a6702 | ||
|
|
841b9daa1f | ||
|
|
1741e49568 | ||
|
|
8577b3d1e6 | ||
|
|
011533b3c4 | ||
|
|
002efad9ad | ||
|
|
3ce5569b82 | ||
|
|
d7c0c225b9 | ||
|
|
f4de0103a8 | ||
|
|
0a9b7fafed | ||
|
|
073604c9f5 | ||
|
|
4062b43380 | ||
|
|
00bd9f9c1c | ||
|
|
3edb0ac09e | ||
|
|
e9f3c4ee52 | ||
|
|
92420d9e02 | ||
|
|
3688be06ee | ||
|
|
8b2be441fc | ||
|
|
b2493a3a53 | ||
|
|
4eb3dbf492 | ||
|
|
adbe0399b2 | ||
|
|
e2461661f7 | ||
|
|
cc012094b4 | ||
|
|
968a6f3cab | ||
|
|
f0d8634a83 | ||
|
|
9f862d5afc | ||
|
|
08f3d75015 | ||
|
|
d9adab3022 | ||
|
|
9019f7622e | ||
|
|
631b5002e7 | ||
|
|
b1987008c7 | ||
|
|
3338109d51 | ||
|
|
c0616df704 | ||
|
|
ca4030e86e | ||
|
|
0a2d57624c | ||
|
|
dbbed94381 | ||
|
|
a088b948b4 | ||
|
|
87faa32c7c | ||
|
|
873be2d6c1 | ||
|
|
5fafed31d0 | ||
|
|
a763831b83 | ||
|
|
076aa4ff9a | ||
|
|
31cbb9cc53 | ||
|
|
ee6db23b14 | ||
|
|
9fe3dcdc6d | ||
|
|
ea644a7d0f | ||
|
|
2f7ddd57dd | ||
|
|
f2f108c14e | ||
|
|
731885f54a | ||
|
|
3c11a1bfcb | ||
|
|
166dd3f719 | ||
|
|
123da12468 | ||
|
|
2a7cdbae42 | ||
|
|
88952a140a | ||
|
|
b64ea411e6 | ||
|
|
44b2bb1b68 | ||
|
|
efabf83a26 | ||
|
|
ac540a18f2 | ||
|
|
4bad384ca0 | ||
|
|
0eb00901b9 | ||
|
|
459b950ab6 | ||
|
|
d7edd4cf4a | ||
|
|
e0bd5ccc92 | ||
|
|
5e82fc4e5d | ||
|
|
1b2c775348 | ||
|
|
16e9cb21da | ||
|
|
cacfbc24cc | ||
|
|
2052c5566e | ||
|
|
2486af2808 | ||
|
|
881afbba0a | ||
|
|
b6d48bfb69 | ||
|
|
d9596f7b4b | ||
|
|
6467bdfe7c | ||
|
|
4fdd299919 | ||
|
|
2de2d26043 | ||
|
|
70e6052dc8 | ||
|
|
2ff51c1866 | ||
|
|
d6fdef68d9 | ||
|
|
30b075e4ba | ||
|
|
3f46d73a31 | ||
|
|
038cf3c762 | ||
|
|
85c0632719 | ||
|
|
c4c2c92974 | ||
|
|
c5fd5694ee | ||
|
|
bc5423ce14 | ||
|
|
8fab34e356 | ||
|
|
d3ee15dcd7 | ||
|
|
45dca7a7f0 | ||
|
|
885059b0aa | ||
|
|
629d098add | ||
|
|
7e95005d8c |
519
.github/workflows/build-and-upload.yml
vendored
Normal file
519
.github/workflows/build-and-upload.yml
vendored
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
name: Build and Upload Binaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to apply to workspace packages"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
tag:
|
||||||
|
description: "Git tag to upload assets to"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release_name:
|
||||||
|
description: "Release name (unused here, for context)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 20
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-15-intel
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||||
|
|
||||||
|
- name: Build macOS binaries (Electron)
|
||||||
|
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in packages/electron-app/release/*.zip; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
echo "Uploading $file"
|
||||||
|
gh release upload "$TAG" "$file" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-2025
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||||
|
|
||||||
|
- name: Build Windows binaries (Electron)
|
||||||
|
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
|
||||||
|
Write-Host "Uploading $($_.FullName)"
|
||||||
|
gh release upload $env:TAG $_.FullName --clobber
|
||||||
|
}
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
|
- name: Build Linux binaries (Electron)
|
||||||
|
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in packages/electron-app/release/*.zip; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
echo "Uploading $file"
|
||||||
|
gh release upload "$TAG" "$file" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
|
build-tauri-macos:
|
||||||
|
runs-on: macos-15-intel
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust (Tauri)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||||
|
|
||||||
|
- name: Build macOS bundle (Tauri)
|
||||||
|
run: npm run build --workspace @codenomad/tauri-app
|
||||||
|
|
||||||
|
- name: Package Tauri artifacts (macOS)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
|
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||||
|
rm -rf "$ARTIFACT_DIR"
|
||||||
|
mkdir -p "$ARTIFACT_DIR"
|
||||||
|
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||||
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload Tauri release assets (macOS)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
echo "Uploading $file"
|
||||||
|
gh release upload "$TAG" "$file" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
|
build-tauri-macos-arm64:
|
||||||
|
runs-on: macos-26
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust (Tauri)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-darwin-arm64 --no-save
|
||||||
|
|
||||||
|
- name: Build macOS bundle (Tauri, arm64)
|
||||||
|
run: npm run build --workspace @codenomad/tauri-app
|
||||||
|
|
||||||
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
|
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||||
|
rm -rf "$ARTIFACT_DIR"
|
||||||
|
mkdir -p "$ARTIFACT_DIR"
|
||||||
|
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||||
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
echo "Uploading $file"
|
||||||
|
gh release upload "$TAG" "$file" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
|
build-tauri-windows:
|
||||||
|
runs-on: windows-2025
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust (Tauri)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||||
|
|
||||||
|
- name: Build Windows bundle (Tauri)
|
||||||
|
run: npm run build --workspace @codenomad/tauri-app
|
||||||
|
|
||||||
|
- name: Package Tauri artifacts (Windows)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
|
$artifactDir = "packages/tauri-app/release-tauri"
|
||||||
|
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Path $artifactDir | Out-Null
|
||||||
|
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
|
||||||
|
if ($null -ne $exe) {
|
||||||
|
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
|
||||||
|
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Upload Tauri release assets (Windows)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if (Test-Path "packages/tauri-app/release-tauri") {
|
||||||
|
Get-ChildItem -Path "packages/tauri-app/release-tauri" -Filter *.zip -File | ForEach-Object {
|
||||||
|
Write-Host "Uploading $($_.FullName)"
|
||||||
|
gh release upload $env:TAG $_.FullName --clobber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build-tauri-linux:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust (Tauri)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install Linux build dependencies (Tauri)
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
|
- name: Build Linux bundle (Tauri)
|
||||||
|
run: npm run build --workspace @codenomad/tauri-app
|
||||||
|
|
||||||
|
- name: Package Tauri artifacts (Linux)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
|
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||||
|
rm -rf "$ARTIFACT_DIR"
|
||||||
|
mkdir -p "$ARTIFACT_DIR"
|
||||||
|
shopt -s nullglob globstar
|
||||||
|
|
||||||
|
find_one() {
|
||||||
|
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
appimage=$(find_one "*.AppImage")
|
||||||
|
deb=$(find_one "*.deb")
|
||||||
|
rpm=$(find_one "*.rpm")
|
||||||
|
|
||||||
|
if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then
|
||||||
|
echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.AppImage"
|
||||||
|
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||||
|
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||||
|
|
||||||
|
- name: Upload Tauri release assets (Linux)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in packages/tauri-app/release-tauri/*; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
echo "Uploading $file"
|
||||||
|
gh release upload "$TAG" "$file" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
|
build-tauri-linux-arm64:
|
||||||
|
if: ${{ false }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
with:
|
||||||
|
platforms: linux/arm64
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust (Tauri)
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
|
- name: Install Linux build dependencies (Tauri)
|
||||||
|
run: |
|
||||||
|
sudo dpkg --add-architecture arm64
|
||||||
|
sudo tee /etc/apt/sources.list.d/arm64.list >/dev/null <<'EOF'
|
||||||
|
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
|
||||||
|
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
|
||||||
|
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
|
||||||
|
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
|
||||||
|
EOF
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
g++-aarch64-linux-gnu \
|
||||||
|
libgtk-3-dev:arm64 \
|
||||||
|
libglib2.0-dev:arm64 \
|
||||||
|
libwebkit2gtk-4.1-dev:arm64 \
|
||||||
|
libsoup-3.0-dev:arm64 \
|
||||||
|
libayatana-appindicator3-dev:arm64 \
|
||||||
|
librsvg2-dev:arm64
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
|
||||||
|
|
||||||
|
- name: Build Linux bundle (Tauri arm64)
|
||||||
|
env:
|
||||||
|
TAURI_BUILD_TARGET: aarch64-unknown-linux-gnu
|
||||||
|
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
|
||||||
|
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||||
|
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
|
||||||
|
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
||||||
|
run: npm run build --workspace @codenomad/tauri-app
|
||||||
|
|
||||||
|
- name: Package Tauri artifacts (Linux arm64)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
|
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||||
|
rm -rf "$ARTIFACT_DIR"
|
||||||
|
mkdir -p "$ARTIFACT_DIR"
|
||||||
|
shopt -s nullglob globstar
|
||||||
|
first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" \) | head -n1)
|
||||||
|
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
|
||||||
|
if [ -n "$first_artifact" ]; then
|
||||||
|
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact"
|
||||||
|
elif [ -f "$fallback_bin" ]; then
|
||||||
|
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin"
|
||||||
|
else
|
||||||
|
echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
- name: Upload Tauri release assets (Linux arm64)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
echo "Uploading $file"
|
||||||
|
gh release upload "$TAG" "$file" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
|
build-linux-rpm:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install rpm packaging dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y rpm ruby ruby-dev build-essential
|
||||||
|
sudo gem install --no-document fpm
|
||||||
|
|
||||||
|
- name: Set workspace versions
|
||||||
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Install project dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
|
- name: Build Linux RPM binaries
|
||||||
|
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
|
- name: Upload RPM release assets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
for file in packages/electron-app/release/*.rpm; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
echo "Uploading $file"
|
||||||
|
gh release upload "$TAG" "$file" --clobber
|
||||||
|
done
|
||||||
67
.github/workflows/dev-release.yml
vendored
Normal file
67
.github/workflows/dev-release.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Dev Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 20
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare-dev:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.versions.outputs.version }}
|
||||||
|
tag: ${{ steps.versions.outputs.tag }}
|
||||||
|
release_name: ${{ steps.versions.outputs.release_name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Compute dev versions
|
||||||
|
id: versions
|
||||||
|
run: |
|
||||||
|
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
DEV_VERSION="${BASE_VERSION}-dev"
|
||||||
|
TAG="v${DEV_VERSION}"
|
||||||
|
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ steps.versions.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG already exists"
|
||||||
|
else
|
||||||
|
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-and-upload:
|
||||||
|
needs: prepare-dev
|
||||||
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
|
with:
|
||||||
|
version: ${{ needs.prepare-dev.outputs.version }}
|
||||||
|
tag: ${{ needs.prepare-dev.outputs.tag }}
|
||||||
|
release_name: ${{ needs.prepare-dev.outputs.release_name }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
publish-server:
|
||||||
|
needs:
|
||||||
|
- prepare-dev
|
||||||
|
- build-and-upload
|
||||||
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
|
with:
|
||||||
|
version: ${{ needs.prepare-dev.outputs.version }}
|
||||||
|
dist_tag: dev
|
||||||
|
secrets: inherit
|
||||||
74
.github/workflows/manual-npm-publish.yml
vendored
Normal file
74
.github/workflows/manual-npm-publish.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
name: Manual NPM Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to publish (e.g. 0.2.0-dev)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
dist_tag:
|
||||||
|
description: "npm dist-tag"
|
||||||
|
required: false
|
||||||
|
default: dev
|
||||||
|
type: string
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
dist_tag:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: dev
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
|
- name: Ensure npm >=11.5.1
|
||||||
|
run: npm install -g npm@latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspaces
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
|
- name: Build server package (includes UI bundling)
|
||||||
|
run: npm run build --workspace @neuralnomads/codenomad
|
||||||
|
|
||||||
|
- name: Set publish metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION_INPUT="${{ inputs.version }}"
|
||||||
|
if [ -z "$VERSION_INPUT" ]; then
|
||||||
|
VERSION_INPUT=$(node -p "require('./package.json').version")
|
||||||
|
fi
|
||||||
|
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||||
|
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Bump package version for publish
|
||||||
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Publish server package with provenance
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_PROVENANCE: true
|
||||||
|
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||||
|
run: |
|
||||||
|
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
||||||
167
.github/workflows/release.yml
vendored
167
.github/workflows/release.yml
vendored
@@ -6,6 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -63,154 +64,22 @@ jobs:
|
|||||||
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
|
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-macos:
|
build-and-upload:
|
||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
runs-on: macos-13
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
env:
|
with:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
steps:
|
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||||
- name: Checkout
|
release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
|
||||||
uses: actions/checkout@v4
|
secrets: inherit
|
||||||
|
|
||||||
- name: Setup Node
|
publish-server:
|
||||||
uses: actions/setup-node@v4
|
needs:
|
||||||
with:
|
- prepare-release
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
- build-and-upload
|
||||||
cache: npm
|
if: ${{ needs.build-and-upload.result == 'success' }}
|
||||||
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
- name: Install dependencies
|
with:
|
||||||
run: npm ci --workspaces
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
|
dist_tag: latest
|
||||||
- name: Build macOS binaries
|
secrets: inherit
|
||||||
run: npm run build:mac --workspace @codenomad/electron-app
|
|
||||||
|
|
||||||
- name: Upload release assets
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
shopt -s nullglob
|
|
||||||
for file in packages/electron-app/release/*; do
|
|
||||||
[ -f "$file" ] || continue
|
|
||||||
case "$file" in
|
|
||||||
*.dmg|*.zip)
|
|
||||||
gh release upload "$TAG" "$file" --clobber
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Skipping non-installer asset: $file"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
build-windows:
|
|
||||||
needs: prepare-release
|
|
||||||
runs-on: windows-latest
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci --workspaces
|
|
||||||
|
|
||||||
- name: Build Windows binaries
|
|
||||||
run: npm run build:win --workspace @codenomad/electron-app
|
|
||||||
|
|
||||||
- name: Upload release assets
|
|
||||||
shell: pwsh
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
Get-ChildItem -Path "packages/electron-app/release" -File | Where-Object {
|
|
||||||
$_.Name -match '\.(exe|zip)$'
|
|
||||||
} | ForEach-Object {
|
|
||||||
gh release upload $env:TAG $_.FullName --clobber
|
|
||||||
}
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
needs: prepare-release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci --workspaces
|
|
||||||
|
|
||||||
- name: Build Linux binaries
|
|
||||||
run: npm run build:linux --workspace @codenomad/electron-app
|
|
||||||
|
|
||||||
- name: Upload release assets
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
shopt -s nullglob
|
|
||||||
for file in packages/electron-app/release/*; do
|
|
||||||
[ -f "$file" ] || continue
|
|
||||||
case "$file" in
|
|
||||||
*.AppImage|*.deb|*.tar.gz)
|
|
||||||
gh release upload "$TAG" "$file" --clobber
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Skipping non-installer asset: $file"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
build-linux-rpm:
|
|
||||||
needs: prepare-release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install rpm packaging dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y rpm ruby ruby-dev build-essential
|
|
||||||
sudo gem install --no-document fpm
|
|
||||||
|
|
||||||
- name: Install project dependencies
|
|
||||||
run: npm ci --workspaces
|
|
||||||
|
|
||||||
- name: Build Linux RPM binaries
|
|
||||||
run: npm run build:linux-rpm --workspace @codenomad/electron-app
|
|
||||||
|
|
||||||
- name: Upload RPM release assets
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
shopt -s nullglob
|
|
||||||
for file in packages/electron-app/release/*.rpm; do
|
|
||||||
[ -f "$file" ] || continue
|
|
||||||
gh release upload "$TAG" "$file" --clobber
|
|
||||||
done
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ release/
|
|||||||
.vite/
|
.vite/
|
||||||
.electron-vite/
|
.electron-vite/
|
||||||
out/
|
out/
|
||||||
|
.dir-locals.el
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Develops Web UI components.
|
description: Develops Web UI components.
|
||||||
mode: all
|
mode: all
|
||||||
model: zai-coding-plan/glm-4.6
|
|
||||||
---
|
---
|
||||||
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.
|
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.
|
||||||
|
|||||||
6
BUILD.md
6
BUILD.md
@@ -13,7 +13,7 @@ This guide explains how to build distributable binaries for CodeNomad.
|
|||||||
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
|
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build --workspace @codenomad/electron-app
|
npm run build --workspace @neuralnomads/codenomad-electron-app
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build for Current Platform (macOS default)
|
### Build for Current Platform (macOS default)
|
||||||
@@ -77,8 +77,8 @@ bun run build:all
|
|||||||
|
|
||||||
The build script performs these steps:
|
The build script performs these steps:
|
||||||
|
|
||||||
1. **Compile TypeScript** → Electron app (main, preload, renderer)
|
1. **Build @neuralnomads/codenomad** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves)
|
||||||
2. **Bundle with Vite** → Optimized production build
|
2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/`
|
||||||
3. **Package with electron-builder** → Platform-specific binaries
|
3. **Package with electron-builder** → Platform-specific binaries
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|||||||
102
README.md
102
README.md
@@ -1,58 +1,88 @@
|
|||||||
# CodeNomad
|
# CodeNomad
|
||||||
## A fast, multi-instance desktop client for running OpenCode sessions the way long-haul builders actually work.
|
|
||||||
|
|
||||||
## What is CodeNomad?
|
## A fast, multi-instance workspace for running OpenCode sessions.
|
||||||
|
|
||||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. When terminals get unwieldy and web clients feel laggy, CodeNomad delivers a desktop-native workspace that favors speed, clarity, and direct control. It runs on macOS, Windows, and Linux using Electron + SolidJS, with prebuilt binaries so you can get started immediately.
|
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
||||||
|
|
||||||

|

|
||||||
|
_Manage multiple OpenCode sessions side-by-side._
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>📸 More Screenshots</summary>
|
||||||
|
|
||||||

|

|
||||||
|
_Global command palette for keyboard-first control._
|
||||||
|
|
||||||
|

|
||||||
|
_Rich media previews for images and assets._
|
||||||
|
|
||||||
|

|
||||||
|
_Browser support via CodeNomad Server._
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Choose the way that fits your workflow:
|
||||||
|
|
||||||
|
### 🖥️ Desktop App (Recommended)
|
||||||
|
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||||
|
|
||||||
|
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||||
|
- **Run**: Install and launch like any other app.
|
||||||
|
|
||||||
|
### 🦀 Tauri App (Experimental)
|
||||||
|
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
||||||
|
|
||||||
|
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||||
|
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
||||||
|
|
||||||
|
### 💻 CodeNomad Server
|
||||||
|
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @neuralnomads/codenomad --launch
|
||||||
|
```
|
||||||
|
|
||||||
|
This command starts the server and opens the web client in your default browser.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **Long-session native** – scroll through massive transcripts without hitches and keep full context visible.
|
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||||
- **Multiple instances, one window** – juggle several OpenCode instances side-by-side with per-instance tabs.
|
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||||
- **Deep task awareness** – jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly without losing your flow.
|
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||||
- **Keyboard first** – the full UI is optimized for shortcuts so you can stay mouse-free when you want to.
|
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||||
- **Command palette superpowers** – summon a single, global palette to jump tabs, launch tools, tweak preferences, or fire shortcuts. Every action is categorized, fuzzy searchable, and previewed so you can chain moves together in seconds. It keeps your workflow predictable and fast whether you are juggling one session or ten.
|
|
||||||
- **Developer-friendly rendering** – syntax highlighting, inline diffs, and thoughtful presentation keep the signal high.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
|
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||||
|
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||||
|
|
||||||
## Repository Layout
|
## Troubleshooting
|
||||||
|
|
||||||
CodeNomad now ships as a small workspace with two packages:
|
### macOS says the app is damaged
|
||||||
|
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
||||||
|
|
||||||
- `packages/ui` — SolidJS renderer, Tailwind styles, and standalone Vite configuration for building the UI bundle independently.
|
```bash
|
||||||
- `packages/electron-app` — Electron main/preload processes plus packaging scripts. It consumes the UI package during development/build via `electron-vite`.
|
xattr -l /Applications/CodeNomad.app
|
||||||
|
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||||
|
```
|
||||||
|
|
||||||
Use `npm run dev --workspace @codenomad/electron-app` for the Electron shell and `npm run dev --workspace @codenomad/ui` for UI-only work. Working with the workspace requires Node.js 18+ with npm 7 or newer so the workspace protocol is available.
|
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
||||||
|
|
||||||
## Downloads
|
## Architecture & Development
|
||||||
|
|
||||||
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
|
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||||
|
|
||||||
## Quick Start
|
| Package | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||||
|
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||||
|
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||||
|
|
||||||
1. Install the OpenCode CLI and confirm it is reachable via your terminal.
|
### Quick Build
|
||||||
2. Download the CodeNomad build for your platform and launch the app.
|
To build the Desktop App from source:
|
||||||
3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session.
|
|
||||||
4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping.
|
|
||||||
|
|
||||||
## CLI Server Flags
|
|
||||||
|
|
||||||
The bundled CLI server (`@codenomad/cli`) controls which folders the UI can browse when you pick a workspace:
|
|
||||||
|
|
||||||
- `--workspace-root <path>` (default: current working directory) scopes browsing to a safe subtree. The UI can only see folders beneath this root.
|
|
||||||
- `--unrestricted-root` explicitly allows full-machine browsing for the current process. In this mode the UI starts from the host home directory, adds a "parent" option so you can reach `/` on macOS/Linux, and lists drives/UNC paths on Windows. The flag is runtime-only—restart the CLI without it to go back to restricted mode.
|
|
||||||
- `--ui-dev-server <url>` proxies UI asset requests to a running Vite dev server while the CLI continues to expose its REST APIs and workspace proxies from the same port. Point this at `http://localhost:3000` when developing the renderer to keep hot reloads without sacrificing the single entry point.
|
|
||||||
|
|
||||||
Use unrestricted mode only when you trust the host; the CLI will skip directories it cannot read and never persists the opt-in.
|
|
||||||
|
|
||||||
### Single Port Proxying
|
|
||||||
|
|
||||||
Every OpenCode instance now tunnels through the CLI port. Each workspace descriptor publishes a stable `proxyPath` (e.g., `/workspaces/<id>/instance`), and the CLI exposes `GET/POST/...` + SSE at `http(s)://<cli-host>:<cli-port>${proxyPath}`. That means the UI, Electron shell, and browser clients only need firewall access to the CLI; instance ports stay private on `127.0.0.1`. In development, the `--ui-dev-server` flag still routes UI traffic through the CLI proxy so all instance calls share the same origin.
|
|
||||||
|
|
||||||
|
1. Clone the repo.
|
||||||
|
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||||
|
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
|||||||
│ │ │ State Management (SolidJS Stores) │ │ │
|
│ │ │ State Management (SolidJS Stores) │ │ │
|
||||||
│ │ │ - instances[] │ │ │
|
│ │ │ - instances[] │ │ │
|
||||||
│ │ │ - sessions[] per instance │ │ │
|
│ │ │ - sessions[] per instance │ │ │
|
||||||
│ │ │ - messages[] per session │ │ │
|
│ │ │ - normalized message store per session │ │ │
|
||||||
│ │ └────────────────────────────────────────────┘ │ │
|
│ │ └────────────────────────────────────────────┘ │ │
|
||||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||||
│ │ │ UI Components │ │ │
|
│ │ │ UI Components │ │ │
|
||||||
│ │ │ - InstanceTabs │ │ │
|
│ │ │ - InstanceTabs │ │ │
|
||||||
│ │ │ - SessionTabs │ │ │
|
│ │ │ - SessionTabs │ │ │
|
||||||
│ │ │ - MessageStream │ │ │
|
│ │ │ - MessageSection │ │ │
|
||||||
│ │ │ - PromptInput │ │ │
|
│ │ │ - PromptInput │ │ │
|
||||||
│ │ └────────────────────────────────────────────┘ │ │
|
│ │ └────────────────────────────────────────────┘ │ │
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
|||||||
82
dev-docs/solidjs-llms.txt
Normal file
82
dev-docs/solidjs-llms.txt
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# SolidJS Documentation
|
||||||
|
|
||||||
|
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
|
||||||
|
|
||||||
|
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
|
||||||
|
|
||||||
|
Key principles:
|
||||||
|
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
|
||||||
|
- Compile-time optimization: JSX transforms into efficient DOM operations
|
||||||
|
- Unidirectional data flow: Props are read-only, promoting predictable state management
|
||||||
|
- Component lifecycle: Components run once, with reactive primitives handling updates
|
||||||
|
|
||||||
|
**Use your web fetch tool on any of the following links to understand the relevant concept**.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
|
||||||
|
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
|
||||||
|
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
|
||||||
|
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
|
||||||
|
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
|
||||||
|
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
|
||||||
|
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
|
||||||
|
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
|
||||||
|
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
|
||||||
|
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
|
||||||
|
|
||||||
|
## Component APIs
|
||||||
|
|
||||||
|
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
|
||||||
|
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
|
||||||
|
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
|
||||||
|
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
|
||||||
|
|
||||||
|
## Control Flow
|
||||||
|
|
||||||
|
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
|
||||||
|
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
|
||||||
|
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
|
||||||
|
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
|
||||||
|
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
|
||||||
|
|
||||||
|
## Derived Values
|
||||||
|
|
||||||
|
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
|
||||||
|
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
|
||||||
|
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
|
||||||
|
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
|
||||||
|
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
|
||||||
|
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
|
||||||
|
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
|
||||||
|
|
||||||
|
## Advanced Topics
|
||||||
|
|
||||||
|
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
|
||||||
|
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
|
||||||
|
|
||||||
|
## Ecosystem
|
||||||
|
|
||||||
|
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
|
||||||
|
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
|
||||||
|
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
|
||||||
|
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
|
||||||
|
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
|
||||||
|
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
|
||||||
|
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options
|
||||||
@@ -49,7 +49,7 @@ packages/opencode-client/
|
|||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── instance-tabs.tsx # Level 1 tabs
|
│ │ ├── instance-tabs.tsx # Level 1 tabs
|
||||||
│ │ ├── session-tabs.tsx # Level 2 tabs
|
│ │ ├── session-tabs.tsx # Level 2 tabs
|
||||||
│ │ ├── message-stream.tsx # Messages display
|
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
|
||||||
│ │ ├── message-item.tsx # Single message
|
│ │ ├── message-item.tsx # Single message
|
||||||
│ │ ├── tool-call.tsx # Tool execution display
|
│ │ ├── tool-call.tsx # Tool execution display
|
||||||
│ │ ├── prompt-input.tsx # Input with attachments
|
│ │ ├── prompt-input.tsx # Input with attachments
|
||||||
@@ -153,16 +153,24 @@ interface Session {
|
|||||||
providerId: string
|
providerId: string
|
||||||
modelId: string
|
modelId: string
|
||||||
}
|
}
|
||||||
messages: Message[]
|
version: string
|
||||||
status: SessionStatus
|
time: { created: number; updated: number }
|
||||||
createdAt: number
|
revert?: {
|
||||||
updatedAt: number
|
messageID?: string
|
||||||
|
partID?: string
|
||||||
|
snapshot?: string
|
||||||
|
diff?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Message content lives in the normalized message-v2 store
|
||||||
|
// keyed by instanceId/sessionId/messageId
|
||||||
|
|
||||||
type SessionStatus =
|
type SessionStatus =
|
||||||
| "idle" // No activity
|
| "idle" // No activity
|
||||||
| "streaming" // Assistant responding
|
| "streaming" // Assistant responding
|
||||||
| "error" // Error occurred
|
| "error" // Error occurred
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### UI Store
|
### UI Store
|
||||||
|
|||||||
BIN
docs/screenshots/browser-support.png
Normal file
BIN
docs/screenshots/browser-support.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 845 KiB |
BIN
docs/screenshots/image-previews.png
Normal file
BIN
docs/screenshots/image-previews.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
549
package-lock.json
generated
549
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.1.2",
|
"version": "0.2.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.1.2",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -313,12 +313,8 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codenomad/cli": {
|
"node_modules/@codenomad/tauri-app": {
|
||||||
"resolved": "packages/cli",
|
"resolved": "packages/tauri-app",
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@codenomad/electron-app": {
|
|
||||||
"resolved": "packages/electron-app",
|
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@codenomad/ui": {
|
"node_modules/@codenomad/ui": {
|
||||||
@@ -1233,6 +1229,14 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@neuralnomads/codenomad": {
|
||||||
|
"resolved": "packages/server",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@neuralnomads/codenomad-electron-app": {
|
||||||
|
"resolved": "packages/electron-app",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -1272,9 +1276,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.0.68",
|
"version": "1.0.133",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.68.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.133.tgz",
|
||||||
"integrity": "sha512-QdpLZw2L/nHdPFGCz8z4du2RvlALgZTFgNeKUM+kJuZTtOWC5t425ELGg5xKIpynD0kj83Euvfn6l6uHs99g3w=="
|
"integrity": "sha512-kM+VJJ09SU51aruQ78DSy+6CjNc4wMytvGBrZ1IIJ8etUIdGA59wrnIOSxBVs4u/Gb9pjjgsF8sWp59UdLWP9w=="
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
@@ -1549,6 +1553,223 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/cli": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-pvylWC9QckrOS9ATWXIXcgu7g2hKK5xTL5ZQyZU/U0n9l88SEFGcWgLQNa8WZmd+wWIOWhkxOFcOl3i6ubDNNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"bin": {
|
||||||
|
"tauri": "tauri.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tauri-apps/cli-darwin-arm64": "2.9.4",
|
||||||
|
"@tauri-apps/cli-darwin-x64": "2.9.4",
|
||||||
|
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.4",
|
||||||
|
"@tauri-apps/cli-linux-arm64-gnu": "2.9.4",
|
||||||
|
"@tauri-apps/cli-linux-arm64-musl": "2.9.4",
|
||||||
|
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.4",
|
||||||
|
"@tauri-apps/cli-linux-x64-gnu": "2.9.4",
|
||||||
|
"@tauri-apps/cli-linux-x64-musl": "2.9.4",
|
||||||
|
"@tauri-apps/cli-win32-arm64-msvc": "2.9.4",
|
||||||
|
"@tauri-apps/cli-win32-ia32-msvc": "2.9.4",
|
||||||
|
"@tauri-apps/cli-win32-x64-msvc": "2.9.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-VT9ymNuT06f5TLjCZW2hfSxbVtZDhORk7CDUDYiq5TiSYQdxkl8MVBy0CCFFcOk4QAkUmqmVUA9r3YZ/N/vPRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tootallnate/once": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||||
@@ -2677,6 +2898,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camelcase-css": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@@ -3172,6 +3402,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -3315,6 +3554,12 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dir-compare": {
|
"node_modules/dir-compare": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz",
|
||||||
@@ -4219,6 +4464,19 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -4439,7 +4697,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -5000,15 +5257,6 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/ignore": {
|
|
||||||
"version": "7.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
|
||||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/inflight": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
@@ -5414,6 +5662,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
@@ -6042,6 +6302,42 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -6061,6 +6357,15 @@
|
|||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@@ -6489,6 +6794,98 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -6656,7 +7053,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6671,6 +7067,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -7026,6 +7428,12 @@
|
|||||||
"seroval": "^1.0"
|
"seroval": "^1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
@@ -8224,6 +8632,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
@@ -8399,44 +8813,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/cli": {
|
|
||||||
"name": "@codenomad/cli",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@fastify/cors": "^8.5.0",
|
|
||||||
"@fastify/reply-from": "^9.8.0",
|
|
||||||
"@fastify/static": "^7.0.4",
|
|
||||||
"commander": "^12.1.0",
|
|
||||||
"fastify": "^4.28.1",
|
|
||||||
"pino": "^9.4.0",
|
|
||||||
"undici": "^6.19.8",
|
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"codenomad-cli": "dist/bin.js"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tsx": "^4.20.6",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/cli/node_modules/commander": {
|
|
||||||
"version": "12.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
|
||||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@codenomad/electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.1.2",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"ignore": "7.0.5"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -8446,6 +8828,7 @@
|
|||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
"png2icons": "^2.0.1",
|
"png2icons": "^2.0.1",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
@@ -8458,17 +8841,65 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"packages/server": {
|
||||||
|
"name": "@neuralnomads/codenomad",
|
||||||
|
"version": "0.2.8",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^8.5.0",
|
||||||
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
|
"commander": "^12.1.0",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"fuzzysort": "^2.0.4",
|
||||||
|
"pino": "^9.4.0",
|
||||||
|
"undici": "^6.19.8",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"codenomad": "dist/bin.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/server/node_modules/commander": {
|
||||||
|
"version": "12.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
|
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/server/node_modules/fuzzysort": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"packages/tauri-app": {
|
||||||
|
"name": "@codenomad/tauri-app",
|
||||||
|
"version": "0.2.8",
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.1.2",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.0.68",
|
"@opencode-ai/sdk": "^1.0.133",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0"
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.1.2",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
@@ -9,14 +9,16 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev --workspace @codenomad/electron-app",
|
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"dev:electron": "npm run dev --workspace @codenomad/electron-app",
|
"dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
"dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
|
||||||
"build": "npm run build --workspace @codenomad/electron-app",
|
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
|
||||||
|
"build:tauri": "npm run build --workspace @codenomad/tauri-app",
|
||||||
"build:ui": "npm run build --workspace @codenomad/ui",
|
"build:ui": "npm run build --workspace @codenomad/ui",
|
||||||
"build:mac-x64": "npm run build:mac-x64 --workspace @codenomad/electron-app",
|
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"build:binaries": "npm run build:binaries --workspace @codenomad/electron-app",
|
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @codenomad/electron-app"
|
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||||
|
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
|
||||||
import { ServerMeta } from "../../api-types"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
|
||||||
serverMeta: ServerMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|
||||||
app.get("/api/meta", async () => deps.serverMeta)
|
|
||||||
}
|
|
||||||
40
packages/electron-app/README.md
Normal file
40
packages/electron-app/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# CodeNomad App
|
||||||
|
|
||||||
|
This package contains the native desktop application shell for CodeNomad, built with [Electron](https://www.electronjs.org/).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Electron app wraps the CodeNomad UI and Server into a standalone executable. It provides deeper system integration, such as:
|
||||||
|
- Native window management
|
||||||
|
- Global keyboard shortcuts
|
||||||
|
- Application menu integration
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To run the Electron app in development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start the renderer (UI) and the main process with hot reloading.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the application for your current platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
To build for specific platforms (requires appropriate build tools):
|
||||||
|
|
||||||
|
- **macOS**: `npm run build:mac`
|
||||||
|
- **Windows**: `npm run build:win`
|
||||||
|
- **Linux**: `npm run build:linux`
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `electron/main`: Main process code (window creation, IPC).
|
||||||
|
- `electron/preload`: Preload scripts for secure bridge between main and renderer.
|
||||||
|
- `electron/resources`: Static assets like icons.
|
||||||
@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
|
|||||||
const uiSrc = resolve(uiRoot, "src")
|
const uiSrc = resolve(uiRoot, "src")
|
||||||
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||||
|
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
@@ -25,7 +26,7 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: "dist/preload",
|
outDir: "dist/preload",
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, "electron/preload/index.ts"),
|
entry: resolve(__dirname, "electron/preload/index.cjs"),
|
||||||
formats: ["cjs"],
|
formats: ["cjs"],
|
||||||
fileName: () => "index.js",
|
fileName: () => "index.js",
|
||||||
},
|
},
|
||||||
@@ -52,9 +53,19 @@ export default defineConfig({
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
minify: false,
|
||||||
|
cssMinify: false,
|
||||||
|
sourcemap: true,
|
||||||
outDir: resolve(__dirname, "dist/renderer"),
|
outDir: resolve(__dirname, "dist/renderer"),
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: uiRendererEntry,
|
input: {
|
||||||
|
main: uiRendererEntry,
|
||||||
|
loading: uiRendererLoadingEntry,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
compact: false,
|
||||||
|
minifyInternalExports: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,243 +1,65 @@
|
|||||||
import { ipcMain, BrowserWindow, dialog } from "electron"
|
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||||
import { processManager } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
import { randomBytes } from "crypto"
|
|
||||||
import * as fs from "fs"
|
|
||||||
import * as path from "path"
|
|
||||||
import { spawn } from "child_process"
|
|
||||||
import ignore from "ignore"
|
|
||||||
|
|
||||||
interface Instance {
|
interface DialogOpenRequest {
|
||||||
id: string
|
mode: "directory" | "file"
|
||||||
folder: string
|
title?: string
|
||||||
port: number
|
defaultPath?: string
|
||||||
pid: number
|
filters?: Array<{ name?: string; extensions: string[] }>
|
||||||
status: "starting" | "ready" | "error" | "stopped"
|
|
||||||
error?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const instances = new Map<string, Instance>()
|
interface DialogOpenResult {
|
||||||
|
canceled: boolean
|
||||||
function generateId(): string {
|
paths: string[]
|
||||||
return randomBytes(16).toString("hex")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
|
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||||
return new Promise((resolve, reject) => {
|
cliManager.on("status", (status: CliStatus) => {
|
||||||
const child = spawn(binaryPath, ["-v"], {
|
if (!mainWindow.isDestroyed()) {
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
mainWindow.webContents.send("cli:status", status)
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let stdout = ""
|
cliManager.on("ready", (status: CliStatus) => {
|
||||||
let stderr = ""
|
if (!mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send("cli:ready", status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
cliManager.on("error", (error: Error) => {
|
||||||
child.kill("SIGTERM")
|
if (!mainWindow.isDestroyed()) {
|
||||||
reject(new Error("Version check timed out"))
|
mainWindow.webContents.send("cli:error", { message: error.message })
|
||||||
}, timeoutMs)
|
}
|
||||||
|
})
|
||||||
|
|
||||||
child.stdout?.on("data", (data) => {
|
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
||||||
stdout += data.toString()
|
|
||||||
})
|
|
||||||
|
|
||||||
child.stderr?.on("data", (data) => {
|
ipcMain.handle("cli:restart", async () => {
|
||||||
stderr += data.toString()
|
const devMode = process.env.NODE_ENV === "development"
|
||||||
})
|
await cliManager.stop()
|
||||||
|
return cliManager.start({ dev: devMode })
|
||||||
|
})
|
||||||
|
|
||||||
child.on("error", (error) => {
|
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||||
clearTimeout(timeout)
|
const properties: OpenDialogOptions["properties"] =
|
||||||
reject(error)
|
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
|
||||||
})
|
|
||||||
|
|
||||||
child.on("close", (code) => {
|
const filters = request.filters?.map((filter) => ({
|
||||||
clearTimeout(timeout)
|
name: filter.name ?? "Files",
|
||||||
if (code === 0) {
|
extensions: filter.extensions,
|
||||||
resolve(stdout.trim())
|
}))
|
||||||
} else {
|
|
||||||
reject(new Error(stderr.trim() || `Binary exited with code ${code}`))
|
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
|
||||||
}
|
const dialogOptions: OpenDialogOptions = {
|
||||||
})
|
title: request.title,
|
||||||
})
|
defaultPath: request.defaultPath,
|
||||||
}
|
properties,
|
||||||
|
filters,
|
||||||
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
}
|
||||||
processManager.setMainWindow(mainWindow)
|
const result = windowTarget
|
||||||
|
? await dialog.showOpenDialog(windowTarget, dialogOptions)
|
||||||
ipcMain.handle("dialog:selectFolder", async () => {
|
: await dialog.showOpenDialog(dialogOptions)
|
||||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
||||||
title: "Select Project Folder",
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
properties: ["openDirectory"],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.canceled || !result.filePaths.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.filePaths[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"instance:create",
|
|
||||||
async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) => {
|
|
||||||
const instance: Instance = {
|
|
||||||
id,
|
|
||||||
folder,
|
|
||||||
port: 0,
|
|
||||||
pid: 0,
|
|
||||||
status: "starting",
|
|
||||||
}
|
|
||||||
|
|
||||||
instances.set(id, instance)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
pid,
|
|
||||||
port,
|
|
||||||
binaryPath: actualBinaryPath,
|
|
||||||
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
|
|
||||||
|
|
||||||
instance.port = port
|
|
||||||
instance.pid = pid
|
|
||||||
instance.status = "ready"
|
|
||||||
|
|
||||||
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
|
|
||||||
|
|
||||||
const meta = processManager.getAllProcesses().get(pid)
|
|
||||||
if (meta) {
|
|
||||||
meta.childProcess.on("exit", (code, signal) => {
|
|
||||||
instance.status = "stopped"
|
|
||||||
mainWindow.webContents.send("instance:stopped", { id })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { id, port, pid, binaryPath: actualBinaryPath }
|
|
||||||
} catch (error) {
|
|
||||||
instance.status = "error"
|
|
||||||
instance.error = error instanceof Error ? error.message : String(error)
|
|
||||||
|
|
||||||
mainWindow.webContents.send("instance:error", {
|
|
||||||
id,
|
|
||||||
error: instance.error,
|
|
||||||
})
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
ipcMain.handle("instance:stop", async (event, pid: number) => {
|
|
||||||
await processManager.kill(pid)
|
|
||||||
|
|
||||||
for (const [id, instance] of instances.entries()) {
|
|
||||||
if (instance.pid === pid) {
|
|
||||||
instance.status = "stopped"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("instance:status", async (event, pid: number) => {
|
|
||||||
return processManager.getStatus(pid)
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("instance:list", async () => {
|
|
||||||
return Array.from(instances.values())
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => {
|
|
||||||
const ig = ignore()
|
|
||||||
ig.add([".git", "node_modules"])
|
|
||||||
|
|
||||||
const gitignorePath = path.join(workspaceFolder, ".gitignore")
|
|
||||||
if (fs.existsSync(gitignorePath)) {
|
|
||||||
const content = fs.readFileSync(gitignorePath, "utf-8")
|
|
||||||
ig.add(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanDir(dirPath: string, baseDir: string): string[] {
|
|
||||||
const results: string[] = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dirPath, entry.name)
|
|
||||||
const relativePath = path.relative(baseDir, fullPath)
|
|
||||||
|
|
||||||
if (ig.ignores(relativePath)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const dirWithSlash = relativePath + "/"
|
|
||||||
if (!ig.ignores(dirWithSlash)) {
|
|
||||||
results.push(dirWithSlash)
|
|
||||||
const subFiles = scanDir(fullPath, baseDir)
|
|
||||||
results.push(...subFiles)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
results.push(relativePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Error scanning ${dirPath}:`, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
return scanDir(workspaceFolder, workspaceFolder)
|
|
||||||
})
|
|
||||||
|
|
||||||
// OpenCode binary operations
|
|
||||||
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
|
|
||||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
||||||
title: "Select OpenCode Binary",
|
|
||||||
filters: [
|
|
||||||
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
|
|
||||||
{ name: "All Files", extensions: ["*"] },
|
|
||||||
],
|
|
||||||
properties: ["openFile"],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.canceled || !result.filePaths.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.filePaths[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
|
|
||||||
try {
|
|
||||||
// Special handling for system PATH binary
|
|
||||||
const isSystemPath = binaryPath === "opencode"
|
|
||||||
|
|
||||||
if (!isSystemPath) {
|
|
||||||
// Check if file exists and is executable for custom paths
|
|
||||||
if (!fs.existsSync(binaryPath)) {
|
|
||||||
return { valid: false, error: "File does not exist" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = fs.statSync(binaryPath)
|
|
||||||
if (!stats.isFile()) {
|
|
||||||
return { valid: false, error: "Path is not a file" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get version once via -v flag
|
|
||||||
try {
|
|
||||||
const version = await runBinaryVersion(binaryPath)
|
|
||||||
return { valid: true, version }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,190 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
|
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||||
import { join } from "path"
|
import { existsSync } from "fs"
|
||||||
|
import { dirname, join } from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
import { setupInstanceIPC } from "./ipc"
|
import { setupCliIPC } from "./ipc"
|
||||||
import { setupStorageIPC } from "./storage"
|
import { CliProcessManager } from "./process-manager"
|
||||||
|
|
||||||
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
|
const mainDirname = dirname(mainFilename)
|
||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
const cliManager = new CliProcessManager()
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
let currentCliUrl: string | null = null
|
||||||
|
let pendingCliUrl: string | null = null
|
||||||
|
let showingLoadingScreen = false
|
||||||
|
let preloadingView: BrowserView | null = null
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
app.commandLine.appendSwitch("disable-spell-checking")
|
app.commandLine.appendSwitch("disable-spell-checking")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup IPC handlers before creating windows
|
|
||||||
setupStorageIPC()
|
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
|
||||||
|
|
||||||
function getIconPath() {
|
function getIconPath() {
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
return join(process.resourcesPath, "icon.png")
|
return join(process.resourcesPath, "icon.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
return join(app.getAppPath(), "electron/resources/icon.png")
|
return join(mainDirname, "../resources/icon.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadingTarget =
|
||||||
|
| { type: "url"; source: string }
|
||||||
|
| { type: "file"; source: string }
|
||||||
|
|
||||||
|
function resolveDevLoadingUrl(): string | null {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
|
||||||
|
if (!devBase) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
|
||||||
|
return new URL("loading.html", normalized).toString()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to construct dev loading URL", devBase, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLoadingTarget(): LoadingTarget {
|
||||||
|
const devUrl = resolveDevLoadingUrl()
|
||||||
|
if (devUrl) {
|
||||||
|
return { type: "url", source: devUrl }
|
||||||
|
}
|
||||||
|
const filePath = resolveLoadingFilePath()
|
||||||
|
return { type: "file", source: filePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLoadingFilePath() {
|
||||||
|
const candidates = [
|
||||||
|
join(app.getAppPath(), "dist/renderer/loading.html"),
|
||||||
|
join(process.resourcesPath, "dist/renderer/loading.html"),
|
||||||
|
join(mainDirname, "../dist/renderer/loading.html"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(app.getAppPath(), "dist/renderer/loading.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLoadingScreen(window: BrowserWindow) {
|
||||||
|
const target = resolveLoadingTarget()
|
||||||
|
const loader =
|
||||||
|
target.type === "url"
|
||||||
|
? window.loadURL(target.source)
|
||||||
|
: window.loadFile(target.source)
|
||||||
|
|
||||||
|
loader.catch((error) => {
|
||||||
|
console.error("[cli] failed to load loading screen:", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedRendererOrigins(): string[] {
|
||||||
|
const origins = new Set<string>()
|
||||||
|
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||||
|
for (const candidate of rendererCandidates) {
|
||||||
|
if (!candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
origins.add(new URL(candidate).origin)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to parse origin for", candidate, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(origins)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldOpenExternally(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const allowedOrigins = getAllowedRendererOrigins()
|
||||||
|
return !allowedOrigins.includes(parsed.origin)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupNavigationGuards(window: BrowserWindow) {
|
||||||
|
const handleExternal = (url: string) => {
|
||||||
|
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (shouldOpenExternally(url)) {
|
||||||
|
handleExternal(url)
|
||||||
|
return { action: "deny" }
|
||||||
|
}
|
||||||
|
return { action: "allow" }
|
||||||
|
})
|
||||||
|
|
||||||
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
|
if (shouldOpenExternally(url)) {
|
||||||
|
event.preventDefault()
|
||||||
|
handleExternal(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedPreloadPath: string | null = null
|
||||||
|
function getPreloadPath() {
|
||||||
|
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||||
|
return cachedPreloadPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
join(process.resourcesPath, "preload/index.js"),
|
||||||
|
join(mainDirname, "../preload/index.js"),
|
||||||
|
join(mainDirname, "../preload/index.cjs"),
|
||||||
|
join(mainDirname, "../../preload/index.cjs"),
|
||||||
|
join(mainDirname, "../../electron/preload/index.cjs"),
|
||||||
|
join(app.getAppPath(), "preload/index.cjs"),
|
||||||
|
join(app.getAppPath(), "electron/preload/index.cjs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
cachedPreloadPath = candidate
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(mainDirname, "../preload/index.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyPreloadingView(target?: BrowserView | null) {
|
||||||
|
const view = target ?? preloadingView
|
||||||
|
if (!view) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contents = view.webContents as any
|
||||||
|
contents?.destroy?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to destroy preloading view", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target || view === preloadingView) {
|
||||||
|
preloadingView = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const prefersDark = true //nativeTheme.shouldUseDarkColors
|
const prefersDark = true
|
||||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||||
const iconPath = getIconPath()
|
const iconPath = getIconPath()
|
||||||
|
|
||||||
@@ -36,33 +196,146 @@ function createWindow() {
|
|||||||
backgroundColor,
|
backgroundColor,
|
||||||
icon: iconPath,
|
icon: iconPath,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, "../preload/index.js"),
|
preload: getPreloadPath(),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
spellcheck: !isMac,
|
spellcheck: !isMac,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setupNavigationGuards(mainWindow)
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
// Disable macOS spell server to avoid input lag
|
|
||||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showingLoadingScreen = true
|
||||||
|
currentCliUrl = null
|
||||||
|
loadLoadingScreen(mainWindow)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.loadURL("http://localhost:3000")
|
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||||
mainWindow.webContents.openDevTools()
|
|
||||||
} else {
|
|
||||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createApplicationMenu(mainWindow)
|
createApplicationMenu(mainWindow)
|
||||||
setupInstanceIPC(mainWindow)
|
setupCliIPC(mainWindow, cliManager)
|
||||||
|
|
||||||
mainWindow.on("closed", () => {
|
mainWindow.on("closed", () => {
|
||||||
|
destroyPreloadingView()
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
|
currentCliUrl = null
|
||||||
|
pendingCliUrl = null
|
||||||
|
showingLoadingScreen = false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pendingCliUrl) {
|
||||||
|
const url = pendingCliUrl
|
||||||
|
pendingCliUrl = null
|
||||||
|
startCliPreload(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoadingScreen(force = false) {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showingLoadingScreen && !force) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyPreloadingView()
|
||||||
|
showingLoadingScreen = true
|
||||||
|
currentCliUrl = null
|
||||||
|
pendingCliUrl = null
|
||||||
|
loadLoadingScreen(mainWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCliPreload(url: string) {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
pendingCliUrl = url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCliUrl === url && !showingLoadingScreen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCliUrl = url
|
||||||
|
destroyPreloadingView()
|
||||||
|
|
||||||
|
if (!showingLoadingScreen) {
|
||||||
|
showLoadingScreen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = new BrowserView({
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
spellcheck: !isMac,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
preloadingView = view
|
||||||
|
|
||||||
|
view.webContents.once("did-finish-load", () => {
|
||||||
|
if (preloadingView !== view) {
|
||||||
|
destroyPreloadingView(view)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finalizeCliSwap(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
view.webContents.loadURL(url).catch((error) => {
|
||||||
|
console.error("[cli] failed to preload CLI view:", error)
|
||||||
|
if (preloadingView === view) {
|
||||||
|
destroyPreloadingView(view)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finalizeCliSwap(url: string) {
|
||||||
|
destroyPreloadingView()
|
||||||
|
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
pendingCliUrl = url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showingLoadingScreen = false
|
||||||
|
currentCliUrl = url
|
||||||
|
pendingCliUrl = null
|
||||||
|
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function startCli() {
|
||||||
|
try {
|
||||||
|
const devMode = process.env.NODE_ENV === "development"
|
||||||
|
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||||
|
await cliManager.start({ dev: devMode })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error("[cli] start failed:", message)
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send("cli:error", { message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cliManager.on("ready", (status) => {
|
||||||
|
if (!status.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startCliPreload(status.url)
|
||||||
|
})
|
||||||
|
|
||||||
|
cliManager.on("status", (status) => {
|
||||||
|
if (status.state !== "ready") {
|
||||||
|
showLoadingScreen()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
app.on("web-contents-created", (_, contents) => {
|
app.on("web-contents-created", (_, contents) => {
|
||||||
contents.session.setSpellCheckerEnabled(false)
|
contents.session.setSpellCheckerEnabled(false)
|
||||||
@@ -70,6 +343,8 @@ if (isMac) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
startCli()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
@@ -84,8 +359,6 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
|
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
@@ -95,6 +368,12 @@ app.whenReady().then(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.on("before-quit", async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
await cliManager.stop().catch(() => {})
|
||||||
|
app.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
app.quit()
|
app.quit()
|
||||||
|
|||||||
@@ -1,218 +1,186 @@
|
|||||||
import { spawn, execSync, ChildProcess } from "child_process"
|
import { spawn, type ChildProcess } from "child_process"
|
||||||
import { app, BrowserWindow } from "electron"
|
import { app } from "electron"
|
||||||
import { existsSync, statSync } from "fs"
|
import { createRequire } from "module"
|
||||||
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell"
|
import { EventEmitter } from "events"
|
||||||
|
import { existsSync, readFileSync } from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
export interface ProcessInfo {
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
pid: number
|
|
||||||
port: number
|
|
||||||
binaryPath: string
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
|
type ListeningMode = "local" | "all"
|
||||||
|
|
||||||
|
export interface CliStatus {
|
||||||
|
state: CliState
|
||||||
|
pid?: number
|
||||||
|
port?: number
|
||||||
|
url?: string
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessMeta {
|
export interface CliLogEntry {
|
||||||
pid: number
|
stream: "stdout" | "stderr"
|
||||||
port: number
|
message: string
|
||||||
folder: string
|
|
||||||
startTime: number
|
|
||||||
childProcess: ChildProcess
|
|
||||||
logs: string[]
|
|
||||||
instanceId: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProcessManager {
|
interface StartOptions {
|
||||||
private processes = new Map<number, ProcessMeta>()
|
dev: boolean
|
||||||
private mainWindow: BrowserWindow | null = null
|
}
|
||||||
|
|
||||||
setMainWindow(window: BrowserWindow) {
|
interface CliEntryResolution {
|
||||||
this.mainWindow = window
|
entry: string
|
||||||
|
runner: "node" | "tsx"
|
||||||
|
runnerPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
|
function resolveConfigPath(configPath?: string): string {
|
||||||
|
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||||
|
if (target.startsWith("~/")) {
|
||||||
|
return path.join(os.homedir(), target.slice(2))
|
||||||
}
|
}
|
||||||
|
return path.resolve(target)
|
||||||
|
}
|
||||||
|
|
||||||
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
|
function resolveHostForMode(mode: ListeningMode): string {
|
||||||
const upperMessage = message.toUpperCase()
|
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
|
||||||
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
|
}
|
||||||
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
|
|
||||||
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
|
function readListeningModeFromConfig(): ListeningMode {
|
||||||
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
|
try {
|
||||||
return "info"
|
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
||||||
|
if (!existsSync(configPath)) return "local"
|
||||||
|
const content = readFileSync(configPath, "utf-8")
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
const mode = parsed?.preferences?.listeningMode
|
||||||
|
if (mode === "local" || mode === "all") {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to read listening mode from config", error)
|
||||||
}
|
}
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
|
export declare interface CliProcessManager {
|
||||||
if (this.mainWindow && message.trim()) {
|
on(event: "status", listener: (status: CliStatus) => void): this
|
||||||
const parsedLevel = this.parseLogLevel(message)
|
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||||
this.mainWindow.webContents.send("instance:log", {
|
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||||
id: instanceId,
|
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||||
entry: {
|
on(event: "error", listener: (error: Error) => void): this
|
||||||
timestamp: Date.now(),
|
}
|
||||||
level: parsedLevel,
|
|
||||||
message: message.trim(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async spawn(
|
export class CliProcessManager extends EventEmitter {
|
||||||
folder: string,
|
private child?: ChildProcess
|
||||||
instanceId: string,
|
private status: CliStatus = { state: "stopped" }
|
||||||
binaryPath?: string,
|
private stdoutBuffer = ""
|
||||||
environmentVariables?: Record<string, string>,
|
private stderrBuffer = ""
|
||||||
): Promise<ProcessInfo> {
|
|
||||||
this.validateFolder(folder)
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
const useUserShell = supportsUserShell()
|
if (this.child) {
|
||||||
const logAttempt = (message: string) => {
|
await this.stop()
|
||||||
console.info(`[ProcessManager] ${message}`)
|
|
||||||
this.sendLog(instanceId, "debug", message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = useUserShell ? getUserShellEnv() : { ...process.env }
|
this.stdoutBuffer = ""
|
||||||
if (environmentVariables) {
|
this.stderrBuffer = ""
|
||||||
Object.assign(env, environmentVariables)
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
this.sendLog(
|
|
||||||
instanceId,
|
|
||||||
"info",
|
|
||||||
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Log each environment variable
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
for (const [key, value] of Object.entries(environmentVariables)) {
|
const listeningMode = this.resolveListeningMode()
|
||||||
this.sendLog(instanceId, "info", ` ${key}=${value}`)
|
const host = resolveHostForMode(listeningMode)
|
||||||
}
|
const args = this.buildCliArgs(options, host)
|
||||||
}
|
|
||||||
|
|
||||||
let targetBinary: string
|
console.info(
|
||||||
if (!binaryPath || binaryPath === "opencode") {
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
|
|
||||||
} else {
|
|
||||||
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
|
|
||||||
}
|
|
||||||
|
|
||||||
const spawnCommand = useUserShell
|
|
||||||
? this.buildShellServeCommand(targetBinary)
|
|
||||||
: { command: targetBinary, args: this.buildServeArgs() }
|
|
||||||
|
|
||||||
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
|
|
||||||
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
|
|
||||||
|
|
||||||
this.sendLog(
|
|
||||||
instanceId,
|
|
||||||
"info",
|
|
||||||
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
env.ELECTRON_RUN_AS_NODE = "1"
|
||||||
cwd: folder,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
env,
|
|
||||||
shell: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
const spawnDetails = supportsUserShell()
|
||||||
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
|
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env,
|
||||||
|
shell: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
|
if (!child.pid) {
|
||||||
|
console.error("[cli] spawn failed: no pid")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.child = child
|
||||||
|
this.updateStatus({ pid: child.pid ?? undefined })
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data: Buffer) => {
|
||||||
|
this.handleStream(data.toString(), "stdout")
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr?.on("data", (data: Buffer) => {
|
||||||
|
this.handleStream(data.toString(), "stderr")
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
console.error("[cli] failed to start CLI:", error)
|
||||||
|
this.updateStatus({ state: "error", error: error.message })
|
||||||
|
this.emit("error", error)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
const failed = this.status.state !== "ready"
|
||||||
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||||
|
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||||
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
|
}
|
||||||
|
this.emit("exit", this.status)
|
||||||
|
this.child = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Promise<CliStatus>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
child.kill("SIGKILL")
|
this.handleTimeout()
|
||||||
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
|
reject(new Error("CLI startup timeout"))
|
||||||
reject(new Error("Server startup timeout (10s exceeded)"))
|
}, 60000)
|
||||||
}, 10000)
|
|
||||||
|
|
||||||
let stdoutBuffer = ""
|
this.once("ready", (status) => {
|
||||||
let stderrBuffer = ""
|
|
||||||
let portFound = false
|
|
||||||
|
|
||||||
child.stdout?.on("data", (data: Buffer) => {
|
|
||||||
const text = data.toString()
|
|
||||||
stdoutBuffer += text
|
|
||||||
|
|
||||||
const lines = stdoutBuffer.split("\n")
|
|
||||||
stdoutBuffer = lines.pop() || ""
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue
|
|
||||||
|
|
||||||
this.sendLog(instanceId, "info", line)
|
|
||||||
|
|
||||||
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
|
|
||||||
if (portMatch && !portFound) {
|
|
||||||
portFound = true
|
|
||||||
const port = parseInt(portMatch[1], 10)
|
|
||||||
clearTimeout(timeout)
|
|
||||||
|
|
||||||
const meta: ProcessMeta = {
|
|
||||||
pid: child.pid!,
|
|
||||||
port,
|
|
||||||
folder,
|
|
||||||
startTime: Date.now(),
|
|
||||||
childProcess: child,
|
|
||||||
logs: [line],
|
|
||||||
instanceId,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processes.set(child.pid!, meta)
|
|
||||||
resolve({ pid: child.pid!, port, binaryPath: targetBinary })
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = this.processes.get(child.pid!)
|
|
||||||
if (meta) {
|
|
||||||
meta.logs.push(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
child.stderr?.on("data", (data: Buffer) => {
|
|
||||||
const text = data.toString()
|
|
||||||
stderrBuffer += text
|
|
||||||
|
|
||||||
const lines = stderrBuffer.split("\n")
|
|
||||||
stderrBuffer = lines.pop() || ""
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue
|
|
||||||
|
|
||||||
this.sendLog(instanceId, "error", line)
|
|
||||||
|
|
||||||
const meta = this.processes.get(child.pid!)
|
|
||||||
if (meta) {
|
|
||||||
meta.logs.push(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
child.on("error", (error) => {
|
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
if (error.message.includes("ENOENT")) {
|
resolve(status)
|
||||||
reject(new Error("opencode binary not found in PATH"))
|
|
||||||
} else {
|
|
||||||
reject(error)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
this.once("error", (error) => {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
this.processes.delete(child.pid!)
|
reject(error)
|
||||||
|
|
||||||
if (!portFound) {
|
|
||||||
const errorMsg = stderrBuffer || `Process exited with code ${code}`
|
|
||||||
reject(new Error(errorMsg))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async kill(pid: number): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
const meta = this.processes.get(pid)
|
const child = this.child
|
||||||
if (!meta) {
|
if (!child) {
|
||||||
// Treat unknown processes as already stopped so tabs close cleanly
|
this.updateStatus({ state: "stopped" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
const child = meta.childProcess
|
|
||||||
|
|
||||||
const killTimeout = setTimeout(() => {
|
const killTimeout = setTimeout(() => {
|
||||||
child.kill("SIGKILL")
|
child.kill("SIGKILL")
|
||||||
}, 2000)
|
}, 4000)
|
||||||
|
|
||||||
child.on("exit", () => {
|
child.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
this.processes.delete(pid)
|
this.child = undefined
|
||||||
|
console.info("[cli] CLI process exited")
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -220,134 +188,177 @@ class ProcessManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatus(pid: number): "running" | "stopped" | "unknown" {
|
getStatus(): CliStatus {
|
||||||
if (!this.processes.has(pid)) {
|
return { ...this.status }
|
||||||
return "unknown"
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
private resolveListeningMode(): ListeningMode {
|
||||||
process.kill(pid, 0)
|
return readListeningModeFromConfig()
|
||||||
return "running"
|
}
|
||||||
} catch {
|
|
||||||
return "stopped"
|
private handleTimeout() {
|
||||||
|
if (this.child) {
|
||||||
|
this.child.kill("SIGKILL")
|
||||||
|
this.child = undefined
|
||||||
|
}
|
||||||
|
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||||
|
this.emit("error", new Error("CLI did not start in time"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleStream(chunk: string, stream: "stdout" | "stderr") {
|
||||||
|
if (stream === "stdout") {
|
||||||
|
this.stdoutBuffer += chunk
|
||||||
|
this.processBuffer("stdout")
|
||||||
|
} else {
|
||||||
|
this.stderrBuffer += chunk
|
||||||
|
this.processBuffer("stderr")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllProcesses(): Map<number, ProcessMeta> {
|
private processBuffer(stream: "stdout" | "stderr") {
|
||||||
return new Map(this.processes)
|
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
|
||||||
}
|
const lines = buffer.split("\n")
|
||||||
|
const trailing = lines.pop() ?? ""
|
||||||
|
|
||||||
async cleanup(): Promise<void> {
|
if (stream === "stdout") {
|
||||||
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
|
this.stdoutBuffer = trailing
|
||||||
await Promise.all(killPromises)
|
} else {
|
||||||
}
|
this.stderrBuffer = trailing
|
||||||
|
|
||||||
private validateFolder(folder: string): void {
|
|
||||||
if (!existsSync(folder)) {
|
|
||||||
throw new Error(`Folder does not exist: ${folder}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = statSync(folder)
|
for (const line of lines) {
|
||||||
if (!stats.isDirectory()) {
|
if (!line.trim()) continue
|
||||||
throw new Error(`Path is not a directory: ${folder}`)
|
console.info(`[cli][${stream}] ${line}`)
|
||||||
}
|
this.emit("log", { stream, message: line })
|
||||||
}
|
|
||||||
|
|
||||||
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string {
|
const port = this.extractPort(line)
|
||||||
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`))
|
if (port && this.status.state === "starting") {
|
||||||
|
const url = `http://127.0.0.1:${port}`
|
||||||
if (process.platform === "win32") {
|
console.info(`[cli] ready on ${url}`)
|
||||||
log("Checking PATH via 'where opencode'")
|
this.updateStatus({ state: "ready", port, url })
|
||||||
return this.resolveBinaryViaLocator("where opencode", log)
|
this.emit("ready", this.status)
|
||||||
}
|
|
||||||
|
|
||||||
const shellCheck = buildUserShellCommand("command -v opencode")
|
|
||||||
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
|
|
||||||
log(`Checking PATH via shell: ${shellPreview}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resolved = runUserShellCommandSync("command -v opencode")
|
|
||||||
const path = this.pickFirstPath(resolved)
|
|
||||||
if (path) {
|
|
||||||
log(`Shell located opencode at ${path}`)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
throw new Error("Empty result from shell lookup")
|
|
||||||
} catch (shellError) {
|
|
||||||
const message = shellError instanceof Error ? shellError.message : String(shellError)
|
|
||||||
log(`Shell lookup failed: ${message}`)
|
|
||||||
try {
|
|
||||||
log("Fallback to 'which opencode'")
|
|
||||||
return this.resolveBinaryViaLocator("which opencode", log)
|
|
||||||
} catch (locatorError) {
|
|
||||||
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
|
|
||||||
log(`Locator fallback failed: ${locatorMessage}`)
|
|
||||||
throw new Error(
|
|
||||||
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string {
|
private extractPort(line: string): number | null {
|
||||||
log?.(`Validating custom binary at ${binaryPath}`)
|
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
||||||
|
if (readyMatch) {
|
||||||
if (!existsSync(binaryPath)) {
|
return parseInt(readyMatch[1], 10)
|
||||||
throw new Error(`OpenCode binary not found: ${binaryPath}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = statSync(binaryPath)
|
if (line.toLowerCase().includes("http server listening")) {
|
||||||
if (!stats.isFile()) {
|
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
|
||||||
throw new Error(`Path is not a file: ${binaryPath}`)
|
if (httpMatch) {
|
||||||
}
|
return parseInt(httpMatch[1], 10)
|
||||||
|
}
|
||||||
// Check if executable (on Unix systems)
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
try {
|
try {
|
||||||
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
|
const parsed = JSON.parse(line)
|
||||||
|
if (typeof parsed.port === "number") {
|
||||||
|
return parsed.port
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Binary is not executable: ${binaryPath}`)
|
// not JSON, ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return binaryPath
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string {
|
private updateStatus(patch: Partial<CliStatus>) {
|
||||||
log?.(`Running locator command: ${command}`)
|
this.status = { ...this.status, ...patch }
|
||||||
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
|
this.emit("status", this.status)
|
||||||
log?.(`Locator output: ${output.trim() || "<empty>"}`)
|
}
|
||||||
const path = this.pickFirstPath(output)
|
|
||||||
if (!path) {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
throw new Error("opencode binary not found in PATH")
|
const args = ["serve", "--host", host, "--port", "0"]
|
||||||
|
|
||||||
|
if (options.dev) {
|
||||||
|
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||||
}
|
}
|
||||||
return path
|
|
||||||
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private pickFirstPath(output: string): string | null {
|
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||||
const line = output
|
const parts = [JSON.stringify(process.execPath)]
|
||||||
.split("\n")
|
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||||
.map((entry) => entry.trim())
|
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||||
.find((entry) => entry.length > 0)
|
}
|
||||||
return line ?? null
|
parts.push(JSON.stringify(cliEntry.entry))
|
||||||
|
args.forEach((arg) => parts.push(JSON.stringify(arg)))
|
||||||
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildServeArgs(): string[] {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
if (cliEntry.runner === "tsx") {
|
||||||
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: process.execPath, args: [cliEntry.entry, ...args] }
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } {
|
private resolveCliEntry(options: StartOptions): CliEntryResolution {
|
||||||
const args = this.buildServeArgs()
|
if (options.dev) {
|
||||||
.map((arg) => JSON.stringify(arg))
|
const tsxPath = this.resolveTsx()
|
||||||
.join(" ")
|
if (!tsxPath) {
|
||||||
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`)
|
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
|
||||||
|
}
|
||||||
|
const devEntry = this.resolveDevEntry()
|
||||||
|
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const distEntry = this.resolveProdEntry()
|
||||||
|
return { entry: distEntry, runner: "node" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTsx(): string | null {
|
||||||
|
const candidates: Array<string | (() => string)> = [
|
||||||
|
() => nodeRequire.resolve("tsx/cli"),
|
||||||
|
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
|
||||||
|
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
|
||||||
|
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
|
||||||
|
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
|
||||||
|
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||||
|
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||||
|
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||||
|
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||||
|
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||||
|
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const resolved = typeof candidate === "function" ? candidate() : candidate
|
||||||
|
if (resolved && existsSync(resolved)) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDevEntry(): string {
|
||||||
|
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
|
||||||
|
if (!existsSync(entry)) {
|
||||||
|
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProdEntry(): string {
|
||||||
|
try {
|
||||||
|
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||||
|
if (existsSync(entry)) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to error below
|
||||||
|
}
|
||||||
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const processManager = new ProcessManager()
|
|
||||||
|
|
||||||
app.on("before-quit", async (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
await processManager.cleanup()
|
|
||||||
app.exit(0)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function setupStorageIPC() {
|
|||||||
return await readConfigWithCache()
|
return await readConfigWithCache()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Return empty config if file doesn't exist
|
// Return empty config if file doesn't exist
|
||||||
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
|
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
17
packages/electron-app/electron/preload/index.cjs
Normal file
17
packages/electron-app/electron/preload/index.cjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require("electron")
|
||||||
|
|
||||||
|
const electronAPI = {
|
||||||
|
onCliStatus: (callback) => {
|
||||||
|
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||||
|
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||||
|
},
|
||||||
|
onCliError: (callback) => {
|
||||||
|
ipcRenderer.on("cli:error", (_, data) => callback(data))
|
||||||
|
return () => ipcRenderer.removeAllListeners("cli:error")
|
||||||
|
},
|
||||||
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron"
|
|
||||||
import type { ElectronAPI } from "../../../ui/src/types/electron-api"
|
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
|
||||||
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
|
|
||||||
createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
|
|
||||||
ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
|
|
||||||
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
|
|
||||||
onInstanceStarted: (callback) => {
|
|
||||||
ipcRenderer.on("instance:started", (_, data) => callback(data))
|
|
||||||
},
|
|
||||||
onInstanceError: (callback) => {
|
|
||||||
ipcRenderer.on("instance:error", (_, data) => callback(data))
|
|
||||||
},
|
|
||||||
onInstanceStopped: (callback) => {
|
|
||||||
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
|
|
||||||
},
|
|
||||||
onInstanceLog: (callback) => {
|
|
||||||
ipcRenderer.on("instance:log", (_, data) => callback(data))
|
|
||||||
},
|
|
||||||
onNewInstance: (callback) => {
|
|
||||||
ipcRenderer.on("menu:newInstance", () => callback())
|
|
||||||
},
|
|
||||||
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
|
|
||||||
// OpenCode binary operations
|
|
||||||
selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"),
|
|
||||||
validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path),
|
|
||||||
// Storage operations
|
|
||||||
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
|
|
||||||
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
|
|
||||||
readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"),
|
|
||||||
writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content),
|
|
||||||
readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename),
|
|
||||||
writeInstanceFile: (filename: string, content: string) =>
|
|
||||||
ipcRenderer.invoke("storage:writeInstanceFile", filename, content),
|
|
||||||
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
|
|
||||||
onConfigChanged: (callback: () => void) => {
|
|
||||||
ipcRenderer.on("storage:configChanged", () => callback())
|
|
||||||
return () => ipcRenderer.removeAllListeners("storage:configChanged")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
electronAPI: ElectronAPI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.1.2",
|
"version": "0.2.8",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Shantur Rathore",
|
"name": "Neural Nomads",
|
||||||
"email": "codenomad@shantur.com"
|
"email": "codenomad@neuralnomads.ai"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"dev:electron": "NODE_ENV=development electron .",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
@@ -29,8 +34,8 @@
|
|||||||
"package:linux": "electron-builder --linux"
|
"package:linux": "electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@neuralnomads/codenomad": "file:../server",
|
||||||
"ignore": "7.0.5"
|
"@codenomad/ui": "file:../ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -40,6 +45,7 @@
|
|||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
"png2icons": "^2.0.1",
|
"png2icons": "^2.0.1",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
@@ -55,39 +61,55 @@
|
|||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "electron/resources",
|
||||||
|
"to": "",
|
||||||
|
"filter": [
|
||||||
|
"!icon.icns",
|
||||||
|
"!icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
|
||||||
"target": "dmg",
|
|
||||||
"arch": ["x64", "arm64", "universal"]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"target": "zip",
|
"target": "zip",
|
||||||
"arch": ["x64", "arm64", "universal"]
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||||
"icon": "electron/resources/icon.icns"
|
"icon": "electron/resources/icon.icns"
|
||||||
},
|
},
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"contents": [
|
"contents": [
|
||||||
{ "x": 130, "y": 220 },
|
{
|
||||||
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" }
|
"x": 130,
|
||||||
|
"y": 220
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 410,
|
||||||
|
"y": 220,
|
||||||
|
"type": "link",
|
||||||
|
"path": "/Applications"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
{
|
|
||||||
"target": "nsis",
|
|
||||||
"arch": ["x64", "arm64"]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"target": "zip",
|
"target": "zip",
|
||||||
"arch": ["x64", "arm64"]
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||||
"icon": "electron/resources/icon.ico"
|
"icon": "electron/resources/icon.ico"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
@@ -99,23 +121,14 @@
|
|||||||
"linux": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "AppImage",
|
"target": "zip",
|
||||||
"arch": ["x64", "arm64"]
|
"arch": [
|
||||||
},
|
"x64",
|
||||||
{
|
"arm64"
|
||||||
"target": "deb",
|
]
|
||||||
"arch": ["x64", "arm64"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target": "rpm",
|
|
||||||
"arch": ["x64", "arm64"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"target": "tar.gz",
|
|
||||||
"arch": ["x64", "arm64"]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||||
"category": "Development",
|
"category": "Development",
|
||||||
"icon": "electron/resources/icon.png"
|
"icon": "electron/resources/icon.png"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,17 @@ import { fileURLToPath } from "url"
|
|||||||
|
|
||||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||||
const appDir = join(__dirname, "..")
|
const appDir = join(__dirname, "..")
|
||||||
|
const workspaceRoot = join(appDir, "..", "..")
|
||||||
|
|
||||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||||
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
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 platforms = {
|
const platforms = {
|
||||||
mac: {
|
mac: {
|
||||||
args: ["--mac", "--x64", "--arm64", "--universal"],
|
args: ["--mac", "--x64", "--arm64"],
|
||||||
description: "macOS (Intel, Apple Silicon, Universal)",
|
description: "macOS (Intel & Apple Silicon)",
|
||||||
},
|
},
|
||||||
"mac-x64": {
|
"mac-x64": {
|
||||||
args: ["--mac", "--x64"],
|
args: ["--mac", "--x64"],
|
||||||
@@ -93,10 +95,16 @@ async function build(platform) {
|
|||||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("📦 Step 1/2: Building Electron app...\n")
|
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||||
|
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||||
await run(npmCmd, ["run", "build"])
|
await run(npmCmd, ["run", "build"])
|
||||||
|
|
||||||
console.log("\n📦 Step 2/2: Packaging binaries...\n")
|
console.log("\n📦 Step 3/3: Packaging binaries...\n")
|
||||||
const distPath = join(appDir, "dist")
|
const distPath = join(appDir, "dist")
|
||||||
if (!existsSync(distPath)) {
|
if (!existsSync(distPath)) {
|
||||||
throw new Error("dist/ directory not found. Build failed.")
|
throw new Error("dist/ directory not found. Build failed.")
|
||||||
|
|||||||
58
packages/server/README.md
Normal file
58
packages/server/README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# CodeNomad Server
|
||||||
|
|
||||||
|
**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace.
|
||||||
|
|
||||||
|
## Features & Capabilities
|
||||||
|
|
||||||
|
### 🌍 Deployment Freedom
|
||||||
|
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||||
|
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||||
|
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||||
|
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||||
|
|
||||||
|
### ⚡️ Workspace Power
|
||||||
|
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||||
|
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||||
|
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||||
|
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||||
|
- Node.js 18+ and npm (for running or building from source).
|
||||||
|
- A workspace folder on disk you want to serve.
|
||||||
|
- Optional: a Chromium-based browser if you want `--launch` to open the UI automatically.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Run via npx (Recommended)
|
||||||
|
You can run CodeNomad directly without installing it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx @neuralnomads/codenomad --launch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Globally
|
||||||
|
Or install it globally to use the `codenomad` command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install -g @neuralnomads/codenomad
|
||||||
|
codenomad --launch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Flags
|
||||||
|
You can configure the server using flags or environment variables:
|
||||||
|
|
||||||
|
| Flag | Env Variable | Description |
|
||||||
|
|------|--------------|-------------|
|
||||||
|
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
|
||||||
|
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||||
|
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||||
|
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||||
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
|
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||||
|
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
|
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||||
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/cli",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.1.0",
|
"version": "0.2.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@codenomad/cli",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.1.0",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/cli",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.1.0",
|
"version": "0.2.8",
|
||||||
"description": "CodeNomad CLI server for HTTP/SSE control plane",
|
"description": "CodeNomad Server",
|
||||||
|
"author": {
|
||||||
|
"name": "Neural Nomads",
|
||||||
|
"email": "codenomad@neuralnomads.ai"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"codenomad-cli": "dist/bin.js"
|
"codenomad": "dist/bin.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
||||||
@@ -20,6 +28,7 @@
|
|||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AgentModelSelection,
|
||||||
AgentModelSelections,
|
AgentModelSelections,
|
||||||
ConfigFile,
|
ConfigFile,
|
||||||
ModelPreference,
|
ModelPreference,
|
||||||
@@ -103,8 +104,19 @@ export interface WorkspaceFileResponse {
|
|||||||
contents: string
|
contents: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspaceFileSearchResponse = FileSystemEntry[]
|
||||||
|
|
||||||
export interface InstanceData {
|
export interface InstanceData {
|
||||||
messageHistory: string[]
|
messageHistory: string[]
|
||||||
|
agentModelSelections: AgentModelSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||||
|
|
||||||
|
export interface InstanceStreamEvent {
|
||||||
|
type: string
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BinaryRecord {
|
export interface BinaryRecord {
|
||||||
@@ -112,6 +124,7 @@ export interface BinaryRecord {
|
|||||||
path: string
|
path: string
|
||||||
label: string
|
label: string
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
lastValidatedAt?: string
|
lastValidatedAt?: string
|
||||||
@@ -151,6 +164,10 @@ export type WorkspaceEventType =
|
|||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
| "config.appChanged"
|
| "config.appChanged"
|
||||||
| "config.binariesChanged"
|
| "config.binariesChanged"
|
||||||
|
| "instance.dataChanged"
|
||||||
|
| "instance.event"
|
||||||
|
| "instance.eventStatus"
|
||||||
|
| "app.releaseAvailable"
|
||||||
|
|
||||||
export type WorkspaceEventPayload =
|
export type WorkspaceEventPayload =
|
||||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||||
@@ -160,16 +177,46 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
| { type: "config.appChanged"; config: AppConfig }
|
| { type: "config.appChanged"; config: AppConfig }
|
||||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||||
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
|
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
|
||||||
|
|
||||||
|
export interface NetworkAddress {
|
||||||
|
ip: string
|
||||||
|
family: "ipv4" | "ipv6"
|
||||||
|
scope: "external" | "internal" | "loopback"
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatestReleaseInfo {
|
||||||
|
version: string
|
||||||
|
tag: string
|
||||||
|
url: string
|
||||||
|
channel: "stable" | "dev"
|
||||||
|
publishedAt?: string
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerMeta {
|
export interface ServerMeta {
|
||||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||||
httpBaseUrl: string
|
httpBaseUrl: string
|
||||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||||
eventsUrl: string
|
eventsUrl: string
|
||||||
|
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||||
|
host: string
|
||||||
|
/** Listening mode derived from host binding. */
|
||||||
|
listeningMode: "local" | "all"
|
||||||
|
/** Actual port in use after binding. */
|
||||||
|
port: number
|
||||||
/** Display label for the host (e.g., hostname or friendly name). */
|
/** Display label for the host (e.g., hostname or friendly name). */
|
||||||
hostLabel: string
|
hostLabel: string
|
||||||
/** Absolute path of the filesystem root exposed to clients. */
|
/** Absolute path of the filesystem root exposed to clients. */
|
||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
|
/** Reachable addresses for this server, external first. */
|
||||||
|
addresses: NetworkAddress[]
|
||||||
|
/** Optional metadata about the most recent public release. */
|
||||||
|
latestRelease?: LatestReleaseInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "../api-types"
|
} from "../api-types"
|
||||||
import { ConfigStore } from "./store"
|
import { ConfigStore } from "./store"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import type { ConfigFileUpdate } from "./schema"
|
import type { ConfigFile } from "./schema"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
export class BinaryRegistry {
|
export class BinaryRegistry {
|
||||||
@@ -39,17 +39,15 @@ export class BinaryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = this.configStore.get()
|
const config = this.configStore.get()
|
||||||
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
const nextConfig = this.cloneConfig(config)
|
||||||
|
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||||
const update: ConfigFileUpdate = {
|
nextConfig.opencodeBinaries = [entry, ...deduped]
|
||||||
opencodeBinaries: [entry, ...deduped],
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.makeDefault) {
|
if (request.makeDefault) {
|
||||||
update.preferences = { lastUsedBinary: request.path }
|
nextConfig.preferences.lastUsedBinary = request.path
|
||||||
}
|
}
|
||||||
|
|
||||||
this.configStore.update(update)
|
this.configStore.replace(nextConfig)
|
||||||
const record = this.getById(request.path)
|
const record = this.getById(request.path)
|
||||||
this.emitChange()
|
this.emitChange()
|
||||||
return record
|
return record
|
||||||
@@ -58,19 +56,16 @@ export class BinaryRegistry {
|
|||||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||||
this.logger.debug({ id }, "Updating OpenCode binary")
|
this.logger.debug({ id }, "Updating OpenCode binary")
|
||||||
const config = this.configStore.get()
|
const config = this.configStore.get()
|
||||||
const updatedEntries = config.opencodeBinaries.map((binary) =>
|
const nextConfig = this.cloneConfig(config)
|
||||||
|
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
||||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||||
)
|
)
|
||||||
|
|
||||||
const update: ConfigFileUpdate = {
|
|
||||||
opencodeBinaries: updatedEntries,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.makeDefault) {
|
if (updates.makeDefault) {
|
||||||
update.preferences = { lastUsedBinary: id }
|
nextConfig.preferences.lastUsedBinary = id
|
||||||
}
|
}
|
||||||
|
|
||||||
this.configStore.update(update)
|
this.configStore.replace(nextConfig)
|
||||||
const record = this.getById(id)
|
const record = this.getById(id)
|
||||||
this.emitChange()
|
this.emitChange()
|
||||||
return record
|
return record
|
||||||
@@ -79,14 +74,15 @@ export class BinaryRegistry {
|
|||||||
remove(id: string) {
|
remove(id: string) {
|
||||||
this.logger.debug({ id }, "Removing OpenCode binary")
|
this.logger.debug({ id }, "Removing OpenCode binary")
|
||||||
const config = this.configStore.get()
|
const config = this.configStore.get()
|
||||||
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
|
const nextConfig = this.cloneConfig(config)
|
||||||
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
|
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||||
|
nextConfig.opencodeBinaries = remaining
|
||||||
|
|
||||||
if (config.preferences.lastUsedBinary === id) {
|
if (nextConfig.preferences.lastUsedBinary === id) {
|
||||||
update.preferences = { lastUsedBinary: remaining[0]?.path }
|
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
||||||
}
|
}
|
||||||
|
|
||||||
this.configStore.update(update)
|
this.configStore.replace(nextConfig)
|
||||||
this.emitChange()
|
this.emitChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +96,12 @@ export class BinaryRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cloneConfig(config: ConfigFile): ConfigFile {
|
||||||
|
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
||||||
|
}
|
||||||
|
|
||||||
private mapRecords(): BinaryRecord[] {
|
private mapRecords(): BinaryRecord[] {
|
||||||
|
|
||||||
const config = this.configStore.get()
|
const config = this.configStore.get()
|
||||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
||||||
id: binary.path,
|
id: binary.path,
|
||||||
@@ -10,24 +10,16 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
|
|||||||
|
|
||||||
const PreferencesSchema = z.object({
|
const PreferencesSchema = z.object({
|
||||||
showThinkingBlocks: z.boolean().default(false),
|
showThinkingBlocks: z.boolean().default(false),
|
||||||
|
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
lastUsedBinary: z.string().optional(),
|
lastUsedBinary: z.string().optional(),
|
||||||
environmentVariables: z.record(z.string()).default({}),
|
environmentVariables: z.record(z.string()).default({}),
|
||||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||||
agentModelSelections: AgentModelSelectionsSchema.default({}),
|
|
||||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
})
|
showUsageMetrics: z.boolean().default(true),
|
||||||
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
const PreferencesUpdateSchema = z.object({
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
showThinkingBlocks: z.boolean().optional(),
|
|
||||||
lastUsedBinary: z.string().optional(),
|
|
||||||
environmentVariables: z.record(z.string()).optional(),
|
|
||||||
modelRecents: z.array(ModelPreferenceSchema).optional(),
|
|
||||||
agentModelSelections: AgentModelSelectionsSchema.optional(),
|
|
||||||
diffViewMode: z.enum(["split", "unified"]).optional(),
|
|
||||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(),
|
|
||||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const RecentFolderSchema = z.object({
|
const RecentFolderSchema = z.object({
|
||||||
@@ -49,13 +41,6 @@ const ConfigFileSchema = z.object({
|
|||||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ConfigFileUpdateSchema = z.object({
|
|
||||||
preferences: PreferencesUpdateSchema.optional(),
|
|
||||||
recentFolders: z.array(RecentFolderSchema).optional(),
|
|
||||||
opencodeBinaries: z.array(OpenCodeBinarySchema).optional(),
|
|
||||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -66,7 +51,6 @@ export {
|
|||||||
RecentFolderSchema,
|
RecentFolderSchema,
|
||||||
OpenCodeBinarySchema,
|
OpenCodeBinarySchema,
|
||||||
ConfigFileSchema,
|
ConfigFileSchema,
|
||||||
ConfigFileUpdateSchema,
|
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,4 +61,3 @@ export type Preferences = z.infer<typeof PreferencesSchema>
|
|||||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||||
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>
|
|
||||||
@@ -2,14 +2,7 @@ import fs from "fs"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
import {
|
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
|
||||||
AgentModelSelections,
|
|
||||||
ConfigFile,
|
|
||||||
ConfigFileUpdate,
|
|
||||||
ConfigFileSchema,
|
|
||||||
ConfigFileUpdateSchema,
|
|
||||||
DEFAULT_CONFIG,
|
|
||||||
} from "./schema"
|
|
||||||
|
|
||||||
export class ConfigStore {
|
export class ConfigStore {
|
||||||
private cache: ConfigFile = DEFAULT_CONFIG
|
private cache: ConfigFile = DEFAULT_CONFIG
|
||||||
@@ -50,54 +43,19 @@ export class ConfigStore {
|
|||||||
return this.load()
|
return this.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
update(partial: ConfigFile | ConfigFileUpdate) {
|
replace(config: ConfigFile) {
|
||||||
const safePartial =
|
const validated = ConfigFileSchema.parse(config)
|
||||||
"recentFolders" in partial && "opencodeBinaries" in partial
|
this.commit(validated)
|
||||||
? ConfigFileSchema.parse(partial)
|
}
|
||||||
: ConfigFileUpdateSchema.parse(partial ?? {})
|
|
||||||
const merged = this.mergeConfig(this.load(), safePartial)
|
private commit(next: ConfigFile) {
|
||||||
this.cache = ConfigFileSchema.parse(merged)
|
this.cache = next
|
||||||
|
this.loaded = true
|
||||||
this.persist()
|
this.persist()
|
||||||
|
const published = Boolean(this.eventBus)
|
||||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||||
this.logger.debug("Config updated")
|
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
||||||
}
|
this.logger.trace({ config: this.cache }, "Config payload")
|
||||||
|
|
||||||
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
|
|
||||||
const mergedPreferences = {
|
|
||||||
...current.preferences,
|
|
||||||
...partial.preferences,
|
|
||||||
environmentVariables: {
|
|
||||||
...current.preferences.environmentVariables,
|
|
||||||
...(partial.preferences?.environmentVariables ?? {}),
|
|
||||||
},
|
|
||||||
agentModelSelections: this.mergeAgentSelections(
|
|
||||||
current.preferences.agentModelSelections,
|
|
||||||
partial.preferences?.agentModelSelections,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
...partial,
|
|
||||||
preferences: mergedPreferences,
|
|
||||||
recentFolders: partial.recentFolders ?? current.recentFolders,
|
|
||||||
opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) {
|
|
||||||
if (!update) {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: AgentModelSelections = { ...base }
|
|
||||||
for (const [instanceId, agentMap] of Object.entries(update)) {
|
|
||||||
result[instanceId] = {
|
|
||||||
...(base[instanceId] ?? {}),
|
|
||||||
...agentMap,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private persist() {
|
private persist() {
|
||||||
@@ -8,7 +8,12 @@ export class EventBus extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
publish(event: WorkspaceEventPayload): boolean {
|
publish(event: WorkspaceEventPayload): boolean {
|
||||||
this.logger?.debug({ event }, "Publishing workspace event")
|
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
||||||
|
this.logger?.debug({ type: event.type }, "Publishing workspace event")
|
||||||
|
if (this.logger?.isLevelEnabled("trace")) {
|
||||||
|
this.logger.trace({ event }, "Workspace event payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
return super.emit(event.type, event)
|
return super.emit(event.type, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +26,10 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
this.on("config.appChanged", handler)
|
this.on("config.appChanged", handler)
|
||||||
this.on("config.binariesChanged", handler)
|
this.on("config.binariesChanged", handler)
|
||||||
|
this.on("instance.dataChanged", handler)
|
||||||
|
this.on("instance.event", handler)
|
||||||
|
this.on("instance.eventStatus", handler)
|
||||||
|
this.on("app.releaseAvailable", handler)
|
||||||
return () => {
|
return () => {
|
||||||
this.off("workspace.created", handler)
|
this.off("workspace.created", handler)
|
||||||
this.off("workspace.started", handler)
|
this.off("workspace.started", handler)
|
||||||
@@ -29,6 +38,10 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
this.off("config.appChanged", handler)
|
this.off("config.appChanged", handler)
|
||||||
this.off("config.binariesChanged", handler)
|
this.off("config.binariesChanged", handler)
|
||||||
|
this.off("instance.dataChanged", handler)
|
||||||
|
this.off("instance.event", handler)
|
||||||
|
this.off("instance.eventStatus", handler)
|
||||||
|
this.off("app.releaseAvailable", handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { beforeEach, describe, it } from "node:test"
|
||||||
|
import type { FileSystemEntry } from "../../api-types"
|
||||||
|
import {
|
||||||
|
clearWorkspaceSearchCache,
|
||||||
|
getWorkspaceCandidates,
|
||||||
|
refreshWorkspaceCandidates,
|
||||||
|
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||||
|
} from "../search-cache"
|
||||||
|
|
||||||
|
describe("workspace search cache", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearWorkspaceSearchCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("expires cached candidates after the TTL", () => {
|
||||||
|
const workspacePath = "/tmp/workspace"
|
||||||
|
const startTime = 1_000
|
||||||
|
|
||||||
|
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
|
||||||
|
|
||||||
|
const beforeExpiry = getWorkspaceCandidates(
|
||||||
|
workspacePath,
|
||||||
|
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
|
||||||
|
)
|
||||||
|
assert.ok(beforeExpiry)
|
||||||
|
assert.equal(beforeExpiry.length, 1)
|
||||||
|
assert.equal(beforeExpiry[0].name, "file-a")
|
||||||
|
|
||||||
|
const afterExpiry = getWorkspaceCandidates(
|
||||||
|
workspacePath,
|
||||||
|
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
|
||||||
|
)
|
||||||
|
assert.equal(afterExpiry, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("replaces cached entries when manually refreshed", () => {
|
||||||
|
const workspacePath = "/tmp/workspace"
|
||||||
|
|
||||||
|
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
|
||||||
|
const initial = getWorkspaceCandidates(workspacePath)
|
||||||
|
assert.ok(initial)
|
||||||
|
assert.equal(initial[0].name, "file-a")
|
||||||
|
|
||||||
|
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
|
||||||
|
const refreshed = getWorkspaceCandidates(workspacePath)
|
||||||
|
assert.ok(refreshed)
|
||||||
|
assert.equal(refreshed[0].name, "file-b")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function createEntry(name: string): FileSystemEntry {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: name,
|
||||||
|
absolutePath: `/tmp/${name}`,
|
||||||
|
type: "file",
|
||||||
|
size: 1,
|
||||||
|
modifiedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
66
packages/server/src/filesystem/search-cache.ts
Normal file
66
packages/server/src/filesystem/search-cache.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import path from "path"
|
||||||
|
import type { FileSystemEntry } from "../api-types"
|
||||||
|
|
||||||
|
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
|
||||||
|
|
||||||
|
interface WorkspaceCandidateCacheEntry {
|
||||||
|
expiresAt: number
|
||||||
|
candidates: FileSystemEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
|
||||||
|
|
||||||
|
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
|
||||||
|
const key = normalizeKey(rootDir)
|
||||||
|
const cached = workspaceCandidateCache.get(key)
|
||||||
|
if (!cached) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.expiresAt <= now) {
|
||||||
|
workspaceCandidateCache.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloneEntries(cached.candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshWorkspaceCandidates(
|
||||||
|
rootDir: string,
|
||||||
|
builder: () => FileSystemEntry[],
|
||||||
|
now = Date.now(),
|
||||||
|
): FileSystemEntry[] {
|
||||||
|
const key = normalizeKey(rootDir)
|
||||||
|
const freshCandidates = builder()
|
||||||
|
|
||||||
|
if (!freshCandidates || freshCandidates.length === 0) {
|
||||||
|
workspaceCandidateCache.delete(key)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedCandidates = cloneEntries(freshCandidates)
|
||||||
|
workspaceCandidateCache.set(key, {
|
||||||
|
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||||
|
candidates: storedCandidates,
|
||||||
|
})
|
||||||
|
|
||||||
|
return cloneEntries(storedCandidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearWorkspaceSearchCache(rootDir?: string) {
|
||||||
|
if (typeof rootDir === "undefined") {
|
||||||
|
workspaceCandidateCache.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizeKey(rootDir)
|
||||||
|
workspaceCandidateCache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
|
||||||
|
return entries.map((entry) => ({ ...entry }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(rootDir: string) {
|
||||||
|
return path.resolve(rootDir)
|
||||||
|
}
|
||||||
184
packages/server/src/filesystem/search.ts
Normal file
184
packages/server/src/filesystem/search.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import type { FileSystemEntry } from "../api-types"
|
||||||
|
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 100
|
||||||
|
const MAX_LIMIT = 200
|
||||||
|
const MAX_CANDIDATES = 8000
|
||||||
|
const IGNORED_DIRECTORIES = new Set(
|
||||||
|
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
|
||||||
|
(name) => name.toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type WorkspaceFileSearchType = "all" | "file" | "directory"
|
||||||
|
|
||||||
|
export interface WorkspaceFileSearchOptions {
|
||||||
|
limit?: number
|
||||||
|
type?: WorkspaceFileSearchType
|
||||||
|
refresh?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CandidateEntry {
|
||||||
|
entry: FileSystemEntry
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchWorkspaceFiles(
|
||||||
|
rootDir: string,
|
||||||
|
query: string,
|
||||||
|
options: WorkspaceFileSearchOptions = {},
|
||||||
|
): FileSystemEntry[] {
|
||||||
|
const trimmedQuery = query.trim()
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
throw new Error("Search query is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRoot = path.resolve(rootDir)
|
||||||
|
const limit = normalizeLimit(options.limit)
|
||||||
|
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
|
||||||
|
const refreshRequested = options.refresh === true
|
||||||
|
|
||||||
|
let entries: FileSystemEntry[] | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!refreshRequested) {
|
||||||
|
entries = getWorkspaceCandidates(normalizedRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entries) {
|
||||||
|
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearWorkspaceSearchCache(normalizedRoot)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
clearWorkspaceSearchCache(normalizedRoot)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = buildCandidateEntries(entries, typeFilter)
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
|
||||||
|
key: "key",
|
||||||
|
limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!matches || matches.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.map((match) => match.obj.entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function collectCandidates(rootDir: string): FileSystemEntry[] {
|
||||||
|
const queue: string[] = [""]
|
||||||
|
const entries: FileSystemEntry[] = []
|
||||||
|
|
||||||
|
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
|
||||||
|
const relativeDir = queue.pop() || ""
|
||||||
|
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
|
||||||
|
|
||||||
|
let dirents: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dirent of dirents) {
|
||||||
|
const entryName = dirent.name
|
||||||
|
const lowerName = entryName.toLowerCase()
|
||||||
|
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
|
||||||
|
const absolutePath = path.join(absoluteDir, entryName)
|
||||||
|
|
||||||
|
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats: fs.Stats
|
||||||
|
try {
|
||||||
|
stats = fs.statSync(absolutePath)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectory = stats.isDirectory()
|
||||||
|
|
||||||
|
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
||||||
|
if (entries.length < MAX_CANDIDATES) {
|
||||||
|
queue.push(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
|
||||||
|
const normalizedPath = normalizeRelativeEntryPath(relativePath)
|
||||||
|
const entry: FileSystemEntry = {
|
||||||
|
name: entryName,
|
||||||
|
path: normalizedPath,
|
||||||
|
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
|
||||||
|
type: entryType,
|
||||||
|
size: entryType === "file" ? stats.size : undefined,
|
||||||
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(entry)
|
||||||
|
|
||||||
|
if (entries.length >= MAX_CANDIDATES) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
|
||||||
|
const filtered: CandidateEntry[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!shouldInclude(entry.type, filter)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered.push({ entry, key: buildSearchKey(entry) })
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(limit?: number) {
|
||||||
|
if (!limit || Number.isNaN(limit)) {
|
||||||
|
return DEFAULT_LIMIT
|
||||||
|
}
|
||||||
|
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
|
||||||
|
return clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
|
||||||
|
return filter === "all" || entryType === filter
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativeEntryPath(relativePath: string): string {
|
||||||
|
if (!relativePath) {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
let normalized = relativePath.replace(/\\+/g, "/")
|
||||||
|
if (normalized.startsWith("./")) {
|
||||||
|
normalized = normalized.replace(/^\.\/+/, "")
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("/")) {
|
||||||
|
normalized = normalized.replace(/^\/+/g, "")
|
||||||
|
}
|
||||||
|
return normalized || "."
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchKey(entry: FileSystemEntry) {
|
||||||
|
return entry.path.toLowerCase()
|
||||||
|
}
|
||||||
@@ -14,9 +14,13 @@ import { FileSystemBrowser } from "./filesystem/browser"
|
|||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
import { ServerMeta } from "./api-types"
|
import { ServerMeta } from "./api-types"
|
||||||
import { InstanceStore } from "./storage/instance-store"
|
import { InstanceStore } from "./storage/instance-store"
|
||||||
|
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
|
import { launchInBrowser } from "./launcher"
|
||||||
|
import { startReleaseMonitor } from "./releases/release-monitor"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
const packageJson = require("../package.json") as { version: string }
|
const packageJson = require("../package.json") as { version: string }
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
@@ -32,6 +36,7 @@ interface CliOptions {
|
|||||||
logDestination?: string
|
logDestination?: string
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServer?: string
|
uiDevServer?: string
|
||||||
|
launch: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PORT = 9898
|
const DEFAULT_PORT = 9898
|
||||||
@@ -40,7 +45,7 @@ const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
|||||||
|
|
||||||
function parseCliOptions(argv: string[]): CliOptions {
|
function parseCliOptions(argv: string[]): CliOptions {
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
.name("codenomad-cli")
|
.name("codenomad")
|
||||||
.description("CodeNomad CLI server")
|
.description("CodeNomad CLI server")
|
||||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||||
@@ -57,6 +62,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||||
|
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||||
|
|
||||||
program.parse(argv, { from: "user" })
|
program.parse(argv, { from: "user" })
|
||||||
const parsed = program.opts<{
|
const parsed = program.opts<{
|
||||||
@@ -70,13 +76,16 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
logDestination?: string
|
logDestination?: string
|
||||||
uiDir: string
|
uiDir: string
|
||||||
uiDevServer?: string
|
uiDevServer?: string
|
||||||
|
launch?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||||
|
|
||||||
|
const normalizedHost = resolveHost(parsed.host)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: parsed.port,
|
port: parsed.port,
|
||||||
host: parsed.host,
|
host: normalizedHost,
|
||||||
rootDir: resolvedRoot,
|
rootDir: resolvedRoot,
|
||||||
configPath: parsed.config,
|
configPath: parsed.config,
|
||||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||||
@@ -84,17 +93,25 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
logDestination: parsed.logDestination,
|
logDestination: parsed.logDestination,
|
||||||
uiStaticDir: parsed.uiDir,
|
uiStaticDir: parsed.uiDir,
|
||||||
uiDevServer: parsed.uiDevServer,
|
uiDevServer: parsed.uiDevServer,
|
||||||
|
launch: Boolean(parsed.launch),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePort(input: string): number {
|
function parsePort(input: string): number {
|
||||||
const value = Number(input)
|
const value = Number(input)
|
||||||
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
||||||
throw new InvalidArgumentError("Port must be an integer between 1 and 65535")
|
throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveHost(input: string | undefined): string {
|
||||||
|
if (input && input.trim() === "0.0.0.0") {
|
||||||
|
return "0.0.0.0"
|
||||||
|
}
|
||||||
|
return DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const options = parseCliOptions(process.argv.slice(2))
|
const options = parseCliOptions(process.argv.slice(2))
|
||||||
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
|
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
|
||||||
@@ -116,14 +133,36 @@ 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()
|
const instanceStore = new InstanceStore()
|
||||||
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
|
workspaceManager,
|
||||||
|
eventBus,
|
||||||
|
logger: logger.child({ component: "instance-events" }),
|
||||||
|
})
|
||||||
|
|
||||||
const serverMeta: ServerMeta = {
|
const serverMeta: ServerMeta = {
|
||||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||||
eventsUrl: `/api/events`,
|
eventsUrl: `/api/events`,
|
||||||
|
host: options.host,
|
||||||
|
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
||||||
|
port: options.port,
|
||||||
hostLabel: options.host,
|
hostLabel: options.host,
|
||||||
workspaceRoot: options.rootDir,
|
workspaceRoot: options.rootDir,
|
||||||
|
addresses: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const releaseMonitor = startReleaseMonitor({
|
||||||
|
currentVersion: packageJson.version,
|
||||||
|
logger: logger.child({ component: "release-monitor" }),
|
||||||
|
onUpdate: (release) => {
|
||||||
|
if (release) {
|
||||||
|
serverMeta.latestRelease = release
|
||||||
|
eventBus.publish({ type: "app.releaseAvailable", release })
|
||||||
|
} else {
|
||||||
|
delete serverMeta.latestRelease
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const server = createHttpServer({
|
const server = createHttpServer({
|
||||||
host: options.host,
|
host: options.host,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
@@ -139,11 +178,13 @@ async function main() {
|
|||||||
logger,
|
logger,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const startInfo = await server.start()
|
||||||
|
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
||||||
|
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
||||||
|
|
||||||
await server.start()
|
if (options.launch) {
|
||||||
logger.info({ port: options.port, host: options.host }, "HTTP server listening")
|
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
||||||
const displayHost = options.host === "127.0.0.1" || options.host === "0.0.0.0" ? "localhost" : options.host
|
}
|
||||||
console.log(`CodeNomad Server is ready at http://${displayHost}:${options.port}`)
|
|
||||||
|
|
||||||
let shuttingDown = false
|
let shuttingDown = false
|
||||||
|
|
||||||
@@ -162,12 +203,15 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
instanceEventBridge.shutdown()
|
||||||
await workspaceManager.shutdown()
|
await workspaceManager.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
logger.info("Workspace manager shutdown complete")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
releaseMonitor.stop()
|
||||||
|
|
||||||
logger.info("Exiting process")
|
logger.info("Exiting process")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
177
packages/server/src/launcher.ts
Normal file
177
packages/server/src/launcher.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { spawn } from "child_process"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import type { Logger } from "./logger"
|
||||||
|
|
||||||
|
interface BrowserCandidate {
|
||||||
|
name: string
|
||||||
|
command: string
|
||||||
|
args: (url: string) => string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_ARGS = (url: string) => [`--app=${url}`, "--new-window"]
|
||||||
|
|
||||||
|
export async function launchInBrowser(url: string, logger: Logger): Promise<boolean> {
|
||||||
|
const { platform, candidates, manualExamples } = buildPlatformCandidates(url)
|
||||||
|
|
||||||
|
console.log(`Attempting to launch browser (${platform}) using:`)
|
||||||
|
candidates.forEach((candidate) => console.log(` - ${candidate.name}: ${candidate.command}`))
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const success = await tryLaunch(candidate, url, logger)
|
||||||
|
if (success) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
"No supported browser found to launch. Run without --launch and use one of the commands below or install a compatible browser.",
|
||||||
|
)
|
||||||
|
if (manualExamples.length > 0) {
|
||||||
|
console.error("Manual launch commands:")
|
||||||
|
manualExamples.forEach((line) => console.error(` ${line}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryLaunch(candidate: BrowserCandidate, url: string, logger: Logger): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false
|
||||||
|
try {
|
||||||
|
const args = candidate.args(url)
|
||||||
|
const child = spawn(candidate.command, args, { stdio: "ignore", detached: true })
|
||||||
|
|
||||||
|
child.once("error", (error) => {
|
||||||
|
if (resolved) return
|
||||||
|
resolved = true
|
||||||
|
logger.debug({ err: error, candidate: candidate.name, command: candidate.command, args }, "Browser launch failed")
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.once("spawn", () => {
|
||||||
|
if (resolved) return
|
||||||
|
resolved = true
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
browser: candidate.name,
|
||||||
|
command: candidate.command,
|
||||||
|
args,
|
||||||
|
fullCommand: [candidate.command, ...args].join(" "),
|
||||||
|
},
|
||||||
|
"Launched browser in app mode",
|
||||||
|
)
|
||||||
|
child.unref()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (resolved) return
|
||||||
|
resolved = true
|
||||||
|
logger.debug({ err: error, candidate: candidate.name, command: candidate.command }, "Browser spawn threw")
|
||||||
|
resolve(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlatformCandidates(url: string) {
|
||||||
|
switch (os.platform()) {
|
||||||
|
case "darwin":
|
||||||
|
return {
|
||||||
|
platform: "macOS",
|
||||||
|
candidates: buildMacCandidates(),
|
||||||
|
manualExamples: buildMacManualExamples(url),
|
||||||
|
}
|
||||||
|
case "win32":
|
||||||
|
return {
|
||||||
|
platform: "Windows",
|
||||||
|
candidates: buildWindowsCandidates(),
|
||||||
|
manualExamples: buildWindowsManualExamples(url),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
platform: "Linux",
|
||||||
|
candidates: buildLinuxCandidates(),
|
||||||
|
manualExamples: buildLinuxManualExamples(url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMacCandidates(): BrowserCandidate[] {
|
||||||
|
const apps = [
|
||||||
|
{ name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
|
||||||
|
{ name: "Google Chrome Canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" },
|
||||||
|
{ name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
|
||||||
|
{ name: "Brave Browser", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
|
||||||
|
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
|
||||||
|
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
|
||||||
|
{ name: "Arc", path: "/Applications/Arc.app/Contents/MacOS/Arc" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return apps.map((entry) => ({ name: entry.name, command: entry.path, args: APP_ARGS }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWindowsCandidates(): BrowserCandidate[] {
|
||||||
|
const programFiles = process.env["ProgramFiles"]
|
||||||
|
const programFilesX86 = process.env["ProgramFiles(x86)"]
|
||||||
|
const localAppData = process.env["LocalAppData"]
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
[programFiles, "Google/Chrome/Application/chrome.exe", "Google Chrome"],
|
||||||
|
[programFilesX86, "Google/Chrome/Application/chrome.exe", "Google Chrome (x86)"],
|
||||||
|
[localAppData, "Google/Chrome/Application/chrome.exe", "Google Chrome (User)"],
|
||||||
|
[programFiles, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"],
|
||||||
|
[programFilesX86, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (x86)"],
|
||||||
|
[localAppData, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (User)"],
|
||||||
|
[programFiles, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave"],
|
||||||
|
[localAppData, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave (User)"],
|
||||||
|
[programFiles, "Chromium/Application/chromium.exe", "Chromium"],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
return paths
|
||||||
|
.filter(([root]) => Boolean(root))
|
||||||
|
.map(([root, rel, name]) => ({
|
||||||
|
name,
|
||||||
|
command: path.join(root as string, rel),
|
||||||
|
args: APP_ARGS,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinuxCandidates(): BrowserCandidate[] {
|
||||||
|
const names = [
|
||||||
|
"google-chrome",
|
||||||
|
"google-chrome-stable",
|
||||||
|
"chromium",
|
||||||
|
"chromium-browser",
|
||||||
|
"brave-browser",
|
||||||
|
"microsoft-edge",
|
||||||
|
"microsoft-edge-stable",
|
||||||
|
"vivaldi",
|
||||||
|
]
|
||||||
|
|
||||||
|
return names.map((name) => ({ name, command: name, args: APP_ARGS }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMacManualExamples(url: string) {
|
||||||
|
return [
|
||||||
|
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app="${url}" --new-window`,
|
||||||
|
`"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" --app="${url}" --new-window`,
|
||||||
|
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" --app="${url}" --new-window`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWindowsManualExamples(url: string) {
|
||||||
|
return [
|
||||||
|
`"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" --app="${url}" --new-window`,
|
||||||
|
`"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe" --app="${url}" --new-window`,
|
||||||
|
`"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --app="${url}" --new-window`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinuxManualExamples(url: string) {
|
||||||
|
return [
|
||||||
|
`google-chrome --app="${url}" --new-window`,
|
||||||
|
`chromium --app="${url}" --new-window`,
|
||||||
|
`brave-browser --app="${url}" --new-window`,
|
||||||
|
`microsoft-edge --app="${url}" --new-window`,
|
||||||
|
]
|
||||||
|
}
|
||||||
141
packages/server/src/releases/release-monitor.ts
Normal file
141
packages/server/src/releases/release-monitor.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { fetch } from "undici"
|
||||||
|
import type { LatestReleaseInfo } from "../api-types"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
|
interface ReleaseMonitorOptions {
|
||||||
|
currentVersion: string
|
||||||
|
logger: Logger
|
||||||
|
onUpdate: (release: LatestReleaseInfo | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubReleaseResponse {
|
||||||
|
tag_name?: string
|
||||||
|
name?: string
|
||||||
|
html_url?: string
|
||||||
|
body?: string
|
||||||
|
published_at?: string
|
||||||
|
created_at?: string
|
||||||
|
prerelease?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NormalizedVersion {
|
||||||
|
major: number
|
||||||
|
minor: number
|
||||||
|
patch: number
|
||||||
|
prerelease: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseMonitor {
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
|
||||||
|
let stopped = false
|
||||||
|
|
||||||
|
const refreshRelease = async () => {
|
||||||
|
if (stopped) return
|
||||||
|
try {
|
||||||
|
const release = await fetchLatestRelease(options)
|
||||||
|
options.onUpdate(release)
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.warn({ err: error }, "Failed to refresh release information")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshRelease()
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||||
|
const response = await fetch(RELEASES_API_URL, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Release API responded with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as GithubReleaseResponse
|
||||||
|
const tagFromServer = json.tag_name || json.name
|
||||||
|
if (!tagFromServer) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedVersion = stripTagPrefix(tagFromServer)
|
||||||
|
if (!normalizedVersion) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = parseVersion(options.currentVersion)
|
||||||
|
const remote = parseVersion(normalizedVersion)
|
||||||
|
|
||||||
|
if (compareVersions(remote, current) <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: normalizedVersion,
|
||||||
|
tag: tagFromServer,
|
||||||
|
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
|
||||||
|
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
|
||||||
|
publishedAt: json.published_at ?? json.created_at,
|
||||||
|
notes: json.body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTagPrefix(tag: string | undefined): string | null {
|
||||||
|
if (!tag) return null
|
||||||
|
const trimmed = tag.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
return trimmed.replace(/^v/i, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(value: string): NormalizedVersion {
|
||||||
|
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
||||||
|
const [core, prerelease = null] = normalized.split("-", 2)
|
||||||
|
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||||
|
const parsed = Number.parseInt(segment, 10)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
patch,
|
||||||
|
prerelease,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
|
||||||
|
if (a.major !== b.major) {
|
||||||
|
return a.major > b.major ? 1 : -1
|
||||||
|
}
|
||||||
|
if (a.minor !== b.minor) {
|
||||||
|
return a.minor > b.minor ? 1 : -1
|
||||||
|
}
|
||||||
|
if (a.patch !== b.patch) {
|
||||||
|
return a.patch > b.patch ? 1 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
|
||||||
|
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
|
||||||
|
|
||||||
|
if (aPre === bPre) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (!aPre) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if (!bPre) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return aPre.localeCompare(bPre)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||||
import cors from "@fastify/cors"
|
import cors from "@fastify/cors"
|
||||||
import fastifyStatic from "@fastify/static"
|
import fastifyStatic from "@fastify/static"
|
||||||
import replyFrom, { type FastifyReplyFromOptions } from "@fastify/reply-from"
|
import replyFrom from "@fastify/reply-from"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
@@ -36,10 +36,19 @@ interface HttpServerDeps {
|
|||||||
logger: Logger
|
logger: Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HttpServerStartResult {
|
||||||
|
port: number
|
||||||
|
url: string
|
||||||
|
displayHost: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HTTP_PORT = 9898
|
||||||
|
|
||||||
export function createHttpServer(deps: HttpServerDeps) {
|
export function createHttpServer(deps: HttpServerDeps) {
|
||||||
const app = Fastify({ logger: false })
|
const app = Fastify({ logger: false })
|
||||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||||
|
const apiLogger = deps.logger.child({ component: "http" })
|
||||||
|
const sseLogger = deps.logger.child({ component: "sse" })
|
||||||
|
|
||||||
const sseClients = new Set<() => void>()
|
const sseClients = new Set<() => void>()
|
||||||
const registerSseClient = (cleanup: () => void) => {
|
const registerSseClient = (cleanup: () => void) => {
|
||||||
@@ -53,6 +62,29 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
sseClients.clear()
|
sseClients.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.addHook("onRequest", (request, _reply, done) => {
|
||||||
|
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
|
||||||
|
start: process.hrtime.bigint(),
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.addHook("onResponse", (request, reply, done) => {
|
||||||
|
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
|
||||||
|
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
|
||||||
|
const base = {
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
status: reply.statusCode,
|
||||||
|
durationMs,
|
||||||
|
}
|
||||||
|
apiLogger.debug(base, "HTTP request completed")
|
||||||
|
if (apiLogger.isLevelEnabled("trace")) {
|
||||||
|
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
origin: true,
|
origin: true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -60,16 +92,27 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
app.register(replyFrom, {
|
app.register(replyFrom, {
|
||||||
contentTypesToEncode: [],
|
contentTypesToEncode: [],
|
||||||
|
undici: {
|
||||||
|
connections: 16,
|
||||||
|
pipelining: 1,
|
||||||
|
bodyTimeout: 0,
|
||||||
|
headersTimeout: 0,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||||
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
|
registerStorageRoutes(app, {
|
||||||
|
instanceStore: deps.instanceStore,
|
||||||
|
eventBus: deps.eventBus,
|
||||||
|
workspaceManager: deps.workspaceManager,
|
||||||
|
})
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|
||||||
|
|
||||||
if (deps.uiDevServerUrl) {
|
if (deps.uiDevServerUrl) {
|
||||||
setupDevProxy(app, deps.uiDevServerUrl)
|
setupDevProxy(app, deps.uiDevServerUrl)
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +121,61 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
instance: app,
|
instance: app,
|
||||||
start: () => app.listen({ port: deps.port, host: deps.host }),
|
start: async (): Promise<HttpServerStartResult> => {
|
||||||
|
const attemptListen = async (requestedPort: number) => {
|
||||||
|
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
|
||||||
|
return { addressInfo, requestedPort }
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoPortRequested = deps.port === 0
|
||||||
|
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
||||||
|
|
||||||
|
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||||
|
if (!autoPortRequested) return false
|
||||||
|
const err = error as NodeJS.ErrnoException | undefined
|
||||||
|
return Boolean(err && err.code === "EADDRINUSE")
|
||||||
|
}
|
||||||
|
|
||||||
|
let listenResult
|
||||||
|
|
||||||
|
try {
|
||||||
|
listenResult = await attemptListen(primaryPort)
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldRetryWithEphemeral(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
|
||||||
|
listenResult = await attemptListen(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualPort = listenResult.requestedPort
|
||||||
|
|
||||||
|
if (typeof listenResult.addressInfo === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(listenResult.addressInfo)
|
||||||
|
actualPort = Number(parsed.port) || listenResult.requestedPort
|
||||||
|
} catch {
|
||||||
|
actualPort = listenResult.requestedPort
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const address = app.server.address()
|
||||||
|
if (typeof address === "object" && address) {
|
||||||
|
actualPort = address.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||||
|
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||||
|
|
||||||
|
deps.serverMeta.httpBaseUrl = serverUrl
|
||||||
|
deps.serverMeta.host = deps.host
|
||||||
|
deps.serverMeta.port = actualPort
|
||||||
|
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
||||||
|
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||||
|
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||||
|
|
||||||
|
return { port: actualPort, url: serverUrl, displayHost }
|
||||||
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
closeSseClients()
|
closeSseClients()
|
||||||
return app.close()
|
return app.close()
|
||||||
@@ -153,6 +250,11 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||||
|
|
||||||
|
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||||
|
if (logger.isLevelEnabled("trace")) {
|
||||||
|
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||||
|
}
|
||||||
|
|
||||||
return reply.from(targetUrl, {
|
return reply.from(targetUrl, {
|
||||||
onError: (proxyReply, { error }) => {
|
onError: (proxyReply, { error }) => {
|
||||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { ConfigStore } from "../../config/store"
|
import { ConfigStore } from "../../config/store"
|
||||||
import { BinaryRegistry } from "../../config/binaries"
|
import { BinaryRegistry } from "../../config/binaries"
|
||||||
import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema"
|
import { ConfigFileSchema } from "../../config/schema"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
configStore: ConfigStore
|
configStore: ConfigStore
|
||||||
@@ -29,13 +29,7 @@ export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
|
|
||||||
app.put("/api/config/app", async (request) => {
|
app.put("/api/config/app", async (request) => {
|
||||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
const body = ConfigFileSchema.parse(request.body ?? {})
|
||||||
deps.configStore.update(body)
|
deps.configStore.replace(body)
|
||||||
return deps.configStore.get()
|
|
||||||
})
|
|
||||||
|
|
||||||
app.patch("/api/config/app", async (request) => {
|
|
||||||
const body = ConfigFileUpdateSchema.parse(request.body ?? {})
|
|
||||||
deps.configStore.update(body)
|
|
||||||
return deps.configStore.get()
|
return deps.configStore.get()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { EventBus } from "../../events/bus"
|
import { EventBus } from "../../events/bus"
|
||||||
import { WorkspaceEventPayload } from "../../api-types"
|
import { WorkspaceEventPayload } from "../../api-types"
|
||||||
|
import { Logger } from "../../logger"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
registerClient: (cleanup: () => void) => () => void
|
registerClient: (cleanup: () => void) => () => void
|
||||||
|
logger: Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nextClientId = 0
|
||||||
|
|
||||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/events", (request, reply) => {
|
app.get("/api/events", (request, reply) => {
|
||||||
|
const clientId = ++nextClientId
|
||||||
|
deps.logger.debug({ clientId }, "SSE client connected")
|
||||||
|
|
||||||
const origin = request.headers.origin ?? "*"
|
const origin = request.headers.origin ?? "*"
|
||||||
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
|
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
|
||||||
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
|
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
|
||||||
@@ -19,6 +26,10 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
reply.hijack()
|
reply.hijack()
|
||||||
|
|
||||||
const send = (event: WorkspaceEventPayload) => {
|
const send = (event: WorkspaceEventPayload) => {
|
||||||
|
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
|
||||||
|
if (deps.logger.isLevelEnabled("trace")) {
|
||||||
|
deps.logger.trace({ clientId, event }, "SSE event payload")
|
||||||
|
}
|
||||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +45,7 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
clearInterval(heartbeat)
|
clearInterval(heartbeat)
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
reply.raw.end?.()
|
reply.raw.end?.()
|
||||||
|
deps.logger.debug({ clientId }, "SSE client disconnected")
|
||||||
}
|
}
|
||||||
|
|
||||||
const unregister = deps.registerClient(close)
|
const unregister = deps.registerClient(close)
|
||||||
104
packages/server/src/server/routes/meta.ts
Normal file
104
packages/server/src/server/routes/meta.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import os from "os"
|
||||||
|
import { NetworkAddress, ServerMeta } from "../../api-types"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
serverMeta: ServerMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||||
|
const port = resolvePort(meta)
|
||||||
|
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
port,
|
||||||
|
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
||||||
|
addresses,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePort(meta: ServerMeta): number {
|
||||||
|
if (Number.isInteger(meta.port) && meta.port > 0) {
|
||||||
|
return meta.port
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(meta.httpBaseUrl)
|
||||||
|
const port = Number(parsed.port)
|
||||||
|
return Number.isInteger(port) && port > 0 ? port : 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||||
|
const interfaces = os.networkInterfaces()
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const results: NetworkAddress[] = []
|
||||||
|
|
||||||
|
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||||
|
if (!ip || ip === "0.0.0.0") return
|
||||||
|
const key = `ipv4-${ip}`
|
||||||
|
if (seen.has(key)) return
|
||||||
|
seen.add(key)
|
||||||
|
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeFamily = (value: string | number) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const lowered = value.toLowerCase()
|
||||||
|
if (lowered === "ipv4") {
|
||||||
|
return "ipv4" as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value === 4) return "ipv4" as const
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === "0.0.0.0") {
|
||||||
|
// Enumerate system interfaces (IPv4 only)
|
||||||
|
for (const entries of Object.values(interfaces)) {
|
||||||
|
if (!entries) continue
|
||||||
|
for (const entry of entries) {
|
||||||
|
const family = normalizeFamily(entry.family)
|
||||||
|
if (!family) continue
|
||||||
|
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||||
|
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||||
|
addAddress(entry.address, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include loopback address
|
||||||
|
addAddress("127.0.0.1", "loopback")
|
||||||
|
|
||||||
|
// Include explicitly configured host if it was IPv4
|
||||||
|
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||||
|
const isLoopback = host.startsWith("127.")
|
||||||
|
addAddress(host, isLoopback ? "loopback" : "external")
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||||
|
|
||||||
|
return results.sort((a, b) => {
|
||||||
|
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||||
|
if (scopeDelta !== 0) return scopeDelta
|
||||||
|
return a.ip.localeCompare(b.ip)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPv4Address(value: string | undefined): value is string {
|
||||||
|
if (!value) return false
|
||||||
|
const parts = value.split(".")
|
||||||
|
if (parts.length !== 4) return false
|
||||||
|
return parts.every((part) => {
|
||||||
|
if (part.length === 0 || part.length > 3) return false
|
||||||
|
if (!/^[0-9]+$/.test(part)) return false
|
||||||
|
const num = Number(part)
|
||||||
|
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,19 +1,37 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { InstanceStore } from "../../storage/instance-store"
|
import { InstanceStore } from "../../storage/instance-store"
|
||||||
|
import { EventBus } from "../../events/bus"
|
||||||
|
import { ModelPreferenceSchema } from "../../config/schema"
|
||||||
|
import type { InstanceData } from "../../api-types"
|
||||||
|
import { WorkspaceManager } from "../../workspaces/manager"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
|
eventBus: EventBus
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceDataSchema = z.object({
|
const InstanceDataSchema = z.object({
|
||||||
messageHistory: z.array(z.string()).default([]),
|
messageHistory: z.array(z.string()).default([]),
|
||||||
|
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const EMPTY_INSTANCE_DATA: InstanceData = {
|
||||||
|
messageHistory: [],
|
||||||
|
agentModelSelections: {},
|
||||||
|
}
|
||||||
|
|
||||||
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
const resolveStorageKey = (instanceId: string): string => {
|
||||||
|
const workspace = deps.workspaceManager.get(instanceId)
|
||||||
|
return workspace?.path ?? instanceId
|
||||||
|
}
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const data = await deps.instanceStore.read(request.params.id)
|
const storageId = resolveStorageKey(request.params.id)
|
||||||
|
const data = await deps.instanceStore.read(storageId)
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(500)
|
reply.code(500)
|
||||||
@@ -24,7 +42,9 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = InstanceDataSchema.parse(request.body ?? {})
|
const body = InstanceDataSchema.parse(request.body ?? {})
|
||||||
await deps.instanceStore.write(request.params.id, body)
|
const storageId = resolveStorageKey(request.params.id)
|
||||||
|
await deps.instanceStore.write(storageId, body)
|
||||||
|
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: body })
|
||||||
reply.code(204)
|
reply.code(204)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(400)
|
reply.code(400)
|
||||||
@@ -34,7 +54,9 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
await deps.instanceStore.delete(request.params.id)
|
const storageId = resolveStorageKey(request.params.id)
|
||||||
|
await deps.instanceStore.delete(storageId)
|
||||||
|
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA })
|
||||||
reply.code(204)
|
reply.code(204)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(500)
|
reply.code(500)
|
||||||
@@ -19,6 +19,16 @@ const WorkspaceFileContentQuerySchema = z.object({
|
|||||||
path: z.string(),
|
path: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const WorkspaceFileSearchQuerySchema = z.object({
|
||||||
|
q: z.string().trim().min(1, "Query is required"),
|
||||||
|
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||||
|
type: z.enum(["all", "file", "directory"]).optional(),
|
||||||
|
refresh: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => (value === undefined ? undefined : value === "true")),
|
||||||
|
})
|
||||||
|
|
||||||
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/workspaces", async () => {
|
app.get("/api/workspaces", async () => {
|
||||||
return deps.workspaceManager.list()
|
return deps.workspaceManager.list()
|
||||||
@@ -57,6 +67,22 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get<{
|
||||||
|
Params: { id: string }
|
||||||
|
Querystring: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
|
||||||
|
}>("/api/workspaces/:id/files/search", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
|
||||||
|
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
|
||||||
|
limit: query.limit,
|
||||||
|
type: query.type,
|
||||||
|
refresh: query.refresh,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get<{
|
app.get<{
|
||||||
Params: { id: string }
|
Params: { id: string }
|
||||||
Querystring: { path?: string }
|
Querystring: { path?: string }
|
||||||
@@ -70,6 +96,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||||
if (error instanceof Error && error.message === "Workspace not found") {
|
if (error instanceof Error && error.message === "Workspace not found") {
|
||||||
reply.code(404)
|
reply.code(404)
|
||||||
@@ -6,6 +6,7 @@ import type { InstanceData } from "../api-types"
|
|||||||
|
|
||||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||||
messageHistory: [],
|
messageHistory: [],
|
||||||
|
agentModelSelections: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstanceStore {
|
export class InstanceStore {
|
||||||
195
packages/server/src/workspaces/instance-events.ts
Normal file
195
packages/server/src/workspaces/instance-events.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Agent, fetch } from "undici"
|
||||||
|
import { Agent as UndiciAgent } from "undici"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
import { WorkspaceManager } from "./manager"
|
||||||
|
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
|
||||||
|
|
||||||
|
const INSTANCE_HOST = "127.0.0.1"
|
||||||
|
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
|
||||||
|
const RECONNECT_DELAY_MS = 1000
|
||||||
|
|
||||||
|
interface InstanceEventBridgeOptions {
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveStream {
|
||||||
|
controller: AbortController
|
||||||
|
task: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InstanceEventBridge {
|
||||||
|
private readonly streams = new Map<string, ActiveStream>()
|
||||||
|
|
||||||
|
constructor(private readonly options: InstanceEventBridgeOptions) {
|
||||||
|
const bus = this.options.eventBus
|
||||||
|
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
|
||||||
|
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
|
||||||
|
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
for (const [id, active] of this.streams) {
|
||||||
|
active.controller.abort()
|
||||||
|
this.publishStatus(id, "disconnected")
|
||||||
|
}
|
||||||
|
this.streams.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private startStream(workspaceId: string) {
|
||||||
|
if (this.streams.has(workspaceId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const task = this.runStream(workspaceId, controller.signal)
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
|
||||||
|
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
const active = this.streams.get(workspaceId)
|
||||||
|
if (active?.controller === controller) {
|
||||||
|
this.streams.delete(workspaceId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.streams.set(workspaceId, { controller, task })
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopStream(workspaceId: string, reason?: string) {
|
||||||
|
const active = this.streams.get(workspaceId)
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active.controller.abort()
|
||||||
|
this.streams.delete(workspaceId)
|
||||||
|
this.publishStatus(workspaceId, "disconnected", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runStream(workspaceId: string, signal: AbortSignal) {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const port = this.options.workspaceManager.getInstancePort(workspaceId)
|
||||||
|
if (!port) {
|
||||||
|
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishStatus(workspaceId, "connecting")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.consumeStream(workspaceId, port, signal)
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
|
||||||
|
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||||
|
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||||
|
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Accept: "text/event-stream" },
|
||||||
|
signal,
|
||||||
|
dispatcher: STREAM_AGENT,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Instance event stream unavailable (${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishStatus(workspaceId, "connected")
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ""
|
||||||
|
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done || !value) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
buffer = this.flushEvents(buffer, workspaceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushEvents(buffer: string, workspaceId: string) {
|
||||||
|
let separatorIndex = buffer.indexOf("\n\n")
|
||||||
|
|
||||||
|
while (separatorIndex >= 0) {
|
||||||
|
const chunk = buffer.slice(0, separatorIndex)
|
||||||
|
buffer = buffer.slice(separatorIndex + 2)
|
||||||
|
this.processChunk(chunk, workspaceId)
|
||||||
|
separatorIndex = buffer.indexOf("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
private processChunk(chunk: string, workspaceId: string) {
|
||||||
|
const lines = chunk.split(/\r?\n/)
|
||||||
|
const dataLines: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith(":")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice(5).trimStart())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = dataLines.join("\n").trim()
|
||||||
|
if (!payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||||
|
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
||||||
|
if (this.options.logger.isLevelEnabled("trace")) {
|
||||||
|
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||||
|
}
|
||||||
|
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
|
||||||
|
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
|
||||||
|
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(duration: number, signal: AbortSignal) {
|
||||||
|
if (duration <= 0) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort)
|
||||||
|
resolve()
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { ConfigStore } from "../config/store"
|
import { ConfigStore } from "../config/store"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||||
import { WorkspaceRuntime } from "./runtime"
|
import { WorkspaceRuntime } from "./runtime"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
@@ -43,6 +46,11 @@ export class WorkspaceManager {
|
|||||||
return browser.list(relativePath)
|
return browser.list(relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
|
||||||
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
|
return searchWorkspaceFiles(workspace.path, query, options)
|
||||||
|
}
|
||||||
|
|
||||||
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
||||||
const workspace = this.requireWorkspace(workspaceId)
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
@@ -55,28 +63,38 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryRegistry.resolveDefault()
|
||||||
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
|
|
||||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
|
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||||
|
|
||||||
const proxyPath = `/workspaces/${id}/instance`
|
const proxyPath = `/workspaces/${id}/instance`
|
||||||
|
|
||||||
|
|
||||||
const descriptor: WorkspaceRecord = {
|
const descriptor: WorkspaceRecord = {
|
||||||
id,
|
id,
|
||||||
path: workspacePath,
|
path: workspacePath,
|
||||||
name,
|
name,
|
||||||
status: "starting",
|
status: "starting",
|
||||||
proxyPath,
|
proxyPath,
|
||||||
binaryId: binary.id,
|
binaryId: resolvedBinaryPath,
|
||||||
binaryLabel: binary.label,
|
binaryLabel: binary.label,
|
||||||
binaryVersion: binary.version,
|
binaryVersion: binary.version,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!descriptor.binaryVersion) {
|
||||||
|
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||||
|
}
|
||||||
|
|
||||||
this.workspaces.set(id, descriptor)
|
this.workspaces.set(id, descriptor)
|
||||||
|
|
||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||||
@@ -85,7 +103,7 @@ export class WorkspaceManager {
|
|||||||
const { pid, port } = await this.runtime.launch({
|
const { pid, port } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: binary.path,
|
binaryPath: resolvedBinaryPath,
|
||||||
environment,
|
environment,
|
||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
@@ -120,6 +138,7 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.workspaces.delete(id)
|
this.workspaces.delete(id)
|
||||||
|
clearWorkspaceSearchCache(workspace.path)
|
||||||
if (!wasRunning) {
|
if (!wasRunning) {
|
||||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||||
}
|
}
|
||||||
@@ -150,6 +169,70 @@ export class WorkspaceManager {
|
|||||||
return workspace
|
return workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveBinaryPath(identifier: string): string {
|
||||||
|
if (!identifier) {
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
|
||||||
|
if (path.isAbsolute(identifier) || looksLikePath) {
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
const locator = process.platform === "win32" ? "where" : "which"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const resolved = result.stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0)
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
} else if (result.error) {
|
||||||
|
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||||
|
if (!resolvedPath) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
|
||||||
|
if (line) {
|
||||||
|
const normalized = line.trim()
|
||||||
|
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||||
|
if (versionMatch) {
|
||||||
|
const version = versionMatch[1]
|
||||||
|
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
} else if (result.error) {
|
||||||
|
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
if (!workspace) return
|
if (!workspace) return
|
||||||
@@ -37,7 +37,10 @@ export class WorkspaceRuntime {
|
|||||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
|
this.logger.info(
|
||||||
|
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
|
||||||
|
"Launching OpenCode process",
|
||||||
|
)
|
||||||
const child = spawn(options.binaryPath, args, {
|
const child = spawn(options.binaryPath, args, {
|
||||||
cwd: options.folder,
|
cwd: options.folder,
|
||||||
env,
|
env,
|
||||||
7
packages/tauri-app/.gitignore
vendored
Normal file
7
packages/tauri-app/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
src-tauri/target
|
||||||
|
src-tauri/Cargo.lock
|
||||||
|
src-tauri/resources/
|
||||||
|
target
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
5588
packages/tauri-app/Cargo.lock
generated
Normal file
5588
packages/tauri-app/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
packages/tauri-app/Cargo.toml
Normal file
3
packages/tauri-app/Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["src-tauri"]
|
||||||
|
resolver = "2"
|
||||||
17
packages/tauri-app/package.json
Normal file
17
packages/tauri-app/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@codenomad/tauri-app",
|
||||||
|
"version": "0.2.8",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
||||||
|
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||||
|
"dev:prep": "node ./scripts/dev-prep.js",
|
||||||
|
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||||
|
"prebuild": "node ./scripts/prebuild.js",
|
||||||
|
"bundle:server": "npm run prebuild",
|
||||||
|
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/tauri-app/scripts/dev-prep.js
Normal file
46
packages/tauri-app/scripts/dev-prep.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
const { execSync } = require("child_process")
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
|
const uiRoot = path.resolve(root, "..", "ui")
|
||||||
|
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||||
|
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||||
|
|
||||||
|
function ensureUiBuild() {
|
||||||
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
|
if (fs.existsSync(loadingHtml)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[dev-prep] UI loader build missing; running workspace build…")
|
||||||
|
execSync("npm --workspace @codenomad/ui run build", {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fs.existsSync(loadingHtml)) {
|
||||||
|
throw new Error("[dev-prep] failed to produce loading.html after UI build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUiLoadingAssets() {
|
||||||
|
const loadingSource = path.join(uiDist, "loading.html")
|
||||||
|
const assetsSource = path.join(uiDist, "assets")
|
||||||
|
|
||||||
|
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||||
|
|
||||||
|
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||||
|
if (fs.existsSync(assetsSource)) {
|
||||||
|
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureUiBuild()
|
||||||
|
copyUiLoadingAssets()
|
||||||
195
packages/tauri-app/scripts/prebuild.js
Normal file
195
packages/tauri-app/scripts/prebuild.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
const { execSync } = require("child_process")
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
|
const serverRoot = path.resolve(root, "..", "server")
|
||||||
|
const uiRoot = path.resolve(root, "..", "ui")
|
||||||
|
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||||
|
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
|
||||||
|
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||||
|
|
||||||
|
const sources = ["dist", "public", "node_modules", "package.json"]
|
||||||
|
|
||||||
|
const serverInstallCommand =
|
||||||
|
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
|
||||||
|
const serverDevInstallCommand =
|
||||||
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
|
const uiDevInstallCommand =
|
||||||
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
|
|
||||||
|
const envWithRootBin = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const braceExpansionPath = path.join(
|
||||||
|
serverRoot,
|
||||||
|
"node_modules",
|
||||||
|
"@fastify",
|
||||||
|
"static",
|
||||||
|
"node_modules",
|
||||||
|
"brace-expansion",
|
||||||
|
"package.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||||
|
|
||||||
|
function ensureServerBuild() {
|
||||||
|
const distPath = path.join(serverRoot, "dist")
|
||||||
|
const publicPath = path.join(serverRoot, "public")
|
||||||
|
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prebuild] server build missing; running workspace build...")
|
||||||
|
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||||
|
throw new Error("[prebuild] server artifacts still missing after build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUiBuild() {
|
||||||
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
|
if (fs.existsSync(loadingHtml)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prebuild] ui build missing; running workspace build...")
|
||||||
|
execSync("npm --workspace @codenomad/ui run build", {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fs.existsSync(loadingHtml)) {
|
||||||
|
throw new Error("[prebuild] ui loading assets missing after build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerDevDependencies() {
|
||||||
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prebuild] ensuring server build dependencies (with dev)...")
|
||||||
|
execSync(serverDevInstallCommand, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: envWithRootBin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerDependencies() {
|
||||||
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prebuild] ensuring server production dependencies...")
|
||||||
|
execSync(serverInstallCommand, {
|
||||||
|
cwd: serverRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUiDevDependencies() {
|
||||||
|
if (fs.existsSync(viteBinPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prebuild] ensuring ui build dependencies...")
|
||||||
|
execSync(uiDevInstallCommand, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: envWithRootBin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRollupPlatformBinary() {
|
||||||
|
const platformKey = `${process.platform}-${process.arch}`
|
||||||
|
const platformPackages = {
|
||||||
|
"linux-x64": "@rollup/rollup-linux-x64-gnu",
|
||||||
|
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
||||||
|
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
||||||
|
"darwin-x64": "@rollup/rollup-darwin-x64",
|
||||||
|
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgName = platformPackages[platformKey]
|
||||||
|
if (!pkgName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
|
||||||
|
if (fs.existsSync(platformPackagePath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let rollupVersion = ""
|
||||||
|
try {
|
||||||
|
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
|
||||||
|
} catch (error) {
|
||||||
|
// leave version empty; fallback install will use latest compatible
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
|
||||||
|
|
||||||
|
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
|
||||||
|
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyServerArtifacts() {
|
||||||
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
|
|
||||||
|
for (const name of sources) {
|
||||||
|
const from = path.join(serverRoot, name)
|
||||||
|
const to = path.join(serverDest, name)
|
||||||
|
if (!fs.existsSync(from)) {
|
||||||
|
console.warn(`[prebuild] skipped missing ${from}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||||
|
console.log(`[prebuild] copied ${from} -> ${to}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUiLoadingAssets() {
|
||||||
|
const loadingSource = path.join(uiDist, "loading.html")
|
||||||
|
const assetsSource = path.join(uiDist, "assets")
|
||||||
|
|
||||||
|
if (!fs.existsSync(loadingSource)) {
|
||||||
|
throw new Error("[prebuild] cannot find built loading.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||||
|
|
||||||
|
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||||
|
if (fs.existsSync(assetsSource)) {
|
||||||
|
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureServerDevDependencies()
|
||||||
|
ensureUiDevDependencies()
|
||||||
|
ensureRollupPlatformBinary()
|
||||||
|
ensureServerDependencies()
|
||||||
|
ensureServerBuild()
|
||||||
|
ensureUiBuild()
|
||||||
|
copyServerArtifacts()
|
||||||
|
copyUiLoadingAssets()
|
||||||
22
packages/tauri-app/src-tauri/Cargo.toml
Normal file
22
packages/tauri-app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "codenomad-tauri"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.5.2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
regex = "1"
|
||||||
|
once_cell = "1"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
thiserror = "1"
|
||||||
|
anyhow = "1"
|
||||||
|
which = "4"
|
||||||
|
libc = "0.2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
dirs = "5"
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
3
packages/tauri-app/src-tauri/build.rs
Normal file
3
packages/tauri-app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
17
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
17
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/capabilities.json",
|
||||||
|
"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:*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"dialog:allow-open",
|
||||||
|
"opener:allow-default-urls"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +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:*"]},"local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
|
||||||
2310
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2310
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2310
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
2310
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
Binary file not shown.
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
700
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
700
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
use dirs::home_dir;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::env;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
|
fn log_line(message: &str) {
|
||||||
|
println!("[tauri-cli] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> Option<PathBuf> {
|
||||||
|
std::env::current_dir().ok().and_then(|mut dir| {
|
||||||
|
for _ in 0..3 {
|
||||||
|
if let Some(parent) = dir.parent() {
|
||||||
|
dir = parent.to_path_buf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(dir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate_main(app: &AppHandle, url: &str) {
|
||||||
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
|
log_line(&format!("navigating main to {url}"));
|
||||||
|
if let Ok(parsed) = Url::parse(url) {
|
||||||
|
let _ = win.navigate(parsed);
|
||||||
|
} else {
|
||||||
|
log_line("failed to parse URL for navigation");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log_line("main window not found for navigation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PreferencesConfig {
|
||||||
|
#[serde(rename = "listeningMode")]
|
||||||
|
listening_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AppConfig {
|
||||||
|
preferences: Option<PreferencesConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_config_path() -> PathBuf {
|
||||||
|
let raw = env::var("CLI_CONFIG")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||||
|
expand_home(&raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_home(path: &str) -> PathBuf {
|
||||||
|
if path.starts_with("~/") {
|
||||||
|
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
|
||||||
|
return home.join(path.trim_start_matches("~/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_listening_mode() -> String {
|
||||||
|
let path = resolve_config_path();
|
||||||
|
if let Ok(content) = fs::read_to_string(path) {
|
||||||
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||||
|
if let Some(mode) = config
|
||||||
|
.preferences
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
|
{
|
||||||
|
if mode == "local" {
|
||||||
|
return "local".to_string();
|
||||||
|
}
|
||||||
|
if mode == "all" {
|
||||||
|
return "all".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"local".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_listening_host() -> String {
|
||||||
|
let mode = resolve_listening_mode();
|
||||||
|
if mode == "local" {
|
||||||
|
"127.0.0.1".to_string()
|
||||||
|
} else {
|
||||||
|
"0.0.0.0".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CliState {
|
||||||
|
Starting,
|
||||||
|
Ready,
|
||||||
|
Error,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CliStatus {
|
||||||
|
pub state: CliState,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CliStatus {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state: CliState::Stopped,
|
||||||
|
pid: None,
|
||||||
|
port: None,
|
||||||
|
url: None,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CliProcessManager {
|
||||||
|
status: Arc<Mutex<CliStatus>>,
|
||||||
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
|
ready: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliProcessManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
|
child: Arc::new(Mutex::new(None)),
|
||||||
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
|
||||||
|
log_line(&format!("start requested (dev={dev})"));
|
||||||
|
self.stop()?;
|
||||||
|
self.ready.store(false, Ordering::SeqCst);
|
||||||
|
{
|
||||||
|
let mut status = self.status.lock();
|
||||||
|
status.state = CliState::Starting;
|
||||||
|
status.port = None;
|
||||||
|
status.url = None;
|
||||||
|
status.error = None;
|
||||||
|
status.pid = None;
|
||||||
|
}
|
||||||
|
Self::emit_status(&app, &self.status.lock());
|
||||||
|
|
||||||
|
let status_arc = self.status.clone();
|
||||||
|
let child_arc = self.child.clone();
|
||||||
|
let ready_flag = self.ready.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
||||||
|
log_line(&format!("cli spawn failed: {err}"));
|
||||||
|
let mut locked = status_arc.lock();
|
||||||
|
locked.state = CliState::Error;
|
||||||
|
locked.error = Some(err.to_string());
|
||||||
|
let snapshot = locked.clone();
|
||||||
|
drop(locked);
|
||||||
|
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
|
||||||
|
let _ = app.emit("cli:status", snapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) -> anyhow::Result<()> {
|
||||||
|
let mut child_opt = self.child.lock();
|
||||||
|
if let Some(mut child) = child_opt.take() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_)) => break,
|
||||||
|
Ok(None) => {
|
||||||
|
if start.elapsed() > Duration::from_secs(4) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut status = self.status.lock();
|
||||||
|
status.state = CliState::Stopped;
|
||||||
|
status.pid = None;
|
||||||
|
status.port = None;
|
||||||
|
status.url = None;
|
||||||
|
status.error = None;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> CliStatus {
|
||||||
|
self.status.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_cli(
|
||||||
|
app: AppHandle,
|
||||||
|
status: Arc<Mutex<CliStatus>>,
|
||||||
|
child_holder: Arc<Mutex<Option<Child>>>,
|
||||||
|
ready: Arc<AtomicBool>,
|
||||||
|
dev: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
log_line("resolving CLI entry");
|
||||||
|
let resolution = CliEntry::resolve(&app, dev)?;
|
||||||
|
let host = resolve_listening_host();
|
||||||
|
log_line(&format!(
|
||||||
|
"resolved CLI entry runner={:?} entry={} host={}",
|
||||||
|
resolution.runner, resolution.entry, host
|
||||||
|
));
|
||||||
|
let args = resolution.build_args(dev, &host);
|
||||||
|
log_line(&format!("CLI args: {:?}", args));
|
||||||
|
if dev {
|
||||||
|
log_line("development mode: will prefer tsx + source if present");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd = workspace_root();
|
||||||
|
if let Some(ref c) = cwd {
|
||||||
|
log_line(&format!("using cwd={}", c.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_info = if supports_user_shell() {
|
||||||
|
log_line("spawning via user shell");
|
||||||
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
|
} else {
|
||||||
|
log_line("spawning directly with node");
|
||||||
|
ShellCommandType::Direct(DirectCommand {
|
||||||
|
program: resolution.node_binary.clone(),
|
||||||
|
args: resolution.runner_args(&args),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !supports_user_shell() {
|
||||||
|
if which::which(&resolution.node_binary).is_err() {
|
||||||
|
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = match &command_info {
|
||||||
|
ShellCommandType::UserShell(cmd) => {
|
||||||
|
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||||
|
let mut c = Command::new(&cmd.shell);
|
||||||
|
c.args(&cmd.args)
|
||||||
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if let Some(ref cwd) = cwd {
|
||||||
|
c.current_dir(cwd);
|
||||||
|
}
|
||||||
|
c.spawn()?
|
||||||
|
}
|
||||||
|
ShellCommandType::Direct(cmd) => {
|
||||||
|
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
||||||
|
let mut c = Command::new(&cmd.program);
|
||||||
|
c.args(&cmd.args)
|
||||||
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if let Some(ref cwd) = cwd {
|
||||||
|
c.current_dir(cwd);
|
||||||
|
}
|
||||||
|
c.spawn()?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pid = child.id();
|
||||||
|
log_line(&format!("spawned pid={pid}"));
|
||||||
|
{
|
||||||
|
let mut locked = status.lock();
|
||||||
|
locked.pid = Some(pid);
|
||||||
|
}
|
||||||
|
Self::emit_status(&app, &status.lock());
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut holder = child_holder.lock();
|
||||||
|
*holder = Some(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let child_clone = child_holder.clone();
|
||||||
|
let status_clone = status.clone();
|
||||||
|
let app_clone = app.clone();
|
||||||
|
let ready_clone = ready.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let stdout = child_clone
|
||||||
|
.lock()
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|c| c.stdout.take())
|
||||||
|
.map(BufReader::new);
|
||||||
|
let stderr = child_clone
|
||||||
|
.lock()
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|c| c.stderr.take())
|
||||||
|
.map(BufReader::new);
|
||||||
|
|
||||||
|
if let Some(reader) = stdout {
|
||||||
|
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
||||||
|
}
|
||||||
|
if let Some(reader) = stderr {
|
||||||
|
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_clone = app.clone();
|
||||||
|
let status_clone = status.clone();
|
||||||
|
let ready_clone = ready.clone();
|
||||||
|
let child_holder_clone = child_holder.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let timeout = Duration::from_secs(60);
|
||||||
|
thread::sleep(timeout);
|
||||||
|
if ready_clone.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut locked = status_clone.lock();
|
||||||
|
locked.state = CliState::Error;
|
||||||
|
locked.error = Some("CLI did not start in time".to_string());
|
||||||
|
log_line("timeout waiting for CLI readiness");
|
||||||
|
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||||
|
Self::emit_status(&app_clone, &locked);
|
||||||
|
});
|
||||||
|
|
||||||
|
let status_clone = status.clone();
|
||||||
|
let app_clone = app.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let code = {
|
||||||
|
let mut guard = child_holder.lock();
|
||||||
|
if let Some(child) = guard.as_mut() {
|
||||||
|
child.wait().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut locked = status_clone.lock();
|
||||||
|
let failed = locked.state != CliState::Ready;
|
||||||
|
let err_msg = if failed {
|
||||||
|
Some(match code {
|
||||||
|
Some(status) => format!("CLI exited early: {status}"),
|
||||||
|
None => "CLI exited early".to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if failed {
|
||||||
|
locked.state = CliState::Error;
|
||||||
|
if locked.error.is_none() {
|
||||||
|
locked.error = err_msg.clone();
|
||||||
|
}
|
||||||
|
log_line(&format!("cli process exited before ready: {:?}", locked.error));
|
||||||
|
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
|
||||||
|
} else {
|
||||||
|
locked.state = CliState::Stopped;
|
||||||
|
log_line("cli process stopped cleanly");
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::emit_status(&app_clone, &locked);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_stream<R: BufRead>(
|
||||||
|
mut reader: R,
|
||||||
|
stream: &str,
|
||||||
|
app: &AppHandle,
|
||||||
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
|
ready: &Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||||
|
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
buffer.clear();
|
||||||
|
match reader.read_line(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => {
|
||||||
|
let line = buffer.trim_end();
|
||||||
|
if !line.is_empty() {
|
||||||
|
log_line(&format!("[cli][{}] {}", stream, line));
|
||||||
|
|
||||||
|
if ready.load(Ordering::SeqCst) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(port) = port_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, port);
|
||||||
|
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, 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, port as u16);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
||||||
|
ready.store(true, Ordering::SeqCst);
|
||||||
|
let mut locked = status.lock();
|
||||||
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
locked.port = Some(port);
|
||||||
|
locked.url = Some(url.clone());
|
||||||
|
locked.state = CliState::Ready;
|
||||||
|
locked.error = None;
|
||||||
|
log_line(&format!("cli ready on {url}"));
|
||||||
|
navigate_main(app, &url);
|
||||||
|
let _ = app.emit("cli:ready", locked.clone());
|
||||||
|
Self::emit_status(app, &locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_status(app: &AppHandle, status: &CliStatus) {
|
||||||
|
let _ = app.emit("cli:status", status.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_user_shell() -> bool {
|
||||||
|
cfg!(unix)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ShellCommand {
|
||||||
|
shell: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct DirectCommand {
|
||||||
|
program: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum ShellCommandType {
|
||||||
|
UserShell(ShellCommand),
|
||||||
|
Direct(DirectCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CliEntry {
|
||||||
|
entry: String,
|
||||||
|
runner: Runner,
|
||||||
|
runner_path: Option<String>,
|
||||||
|
node_binary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Runner {
|
||||||
|
Node,
|
||||||
|
Tsx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliEntry {
|
||||||
|
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
|
||||||
|
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
|
||||||
|
|
||||||
|
if dev {
|
||||||
|
if let Some(tsx_path) = resolve_tsx(app) {
|
||||||
|
if let Some(entry) = resolve_dev_entry(app) {
|
||||||
|
return Ok(Self {
|
||||||
|
entry,
|
||||||
|
runner: Runner::Tsx,
|
||||||
|
runner_path: Some(tsx_path),
|
||||||
|
node_binary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(entry) = resolve_dist_entry(app) {
|
||||||
|
return Ok(Self {
|
||||||
|
entry,
|
||||||
|
runner: Runner::Node,
|
||||||
|
runner_path: None,
|
||||||
|
node_binary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||||
|
let mut args = vec![
|
||||||
|
"serve".to_string(),
|
||||||
|
"--host".to_string(),
|
||||||
|
host.to_string(),
|
||||||
|
"--port".to_string(),
|
||||||
|
"0".to_string(),
|
||||||
|
];
|
||||||
|
if dev {
|
||||||
|
args.push("--ui-dev-server".to_string());
|
||||||
|
args.push("http://localhost:3000".to_string());
|
||||||
|
args.push("--log-level".to_string());
|
||||||
|
args.push("debug".to_string());
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||||
|
let mut args = VecDeque::new();
|
||||||
|
if self.runner == Runner::Tsx {
|
||||||
|
if let Some(path) = &self.runner_path {
|
||||||
|
args.push_back(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.push_back(self.entry.clone());
|
||||||
|
for arg in cli_args {
|
||||||
|
args.push_back(arg.clone());
|
||||||
|
}
|
||||||
|
args.into_iter().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||||
|
let candidates = vec![
|
||||||
|
std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.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"))),
|
||||||
|
];
|
||||||
|
|
||||||
|
first_existing(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let candidates = vec![
|
||||||
|
std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.join("packages/server/src/index.ts")),
|
||||||
|
std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.join("../server/src/index.ts")),
|
||||||
|
];
|
||||||
|
|
||||||
|
first_existing(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let base = workspace_root();
|
||||||
|
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||||
|
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||||
|
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
||||||
|
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||||
|
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
let resources = dir.join("../Resources");
|
||||||
|
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||||
|
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||||
|
candidates.push(Some(resources.join("server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(resources.join("server/dist/server/index.js")));
|
||||||
|
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||||
|
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||||
|
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
first_existing(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
|
||||||
|
|
||||||
|
let shell = default_shell();
|
||||||
|
let mut quoted: Vec<String> = Vec::new();
|
||||||
|
quoted.push(shell_escape(&entry.node_binary));
|
||||||
|
for arg in entry.runner_args(cli_args) {
|
||||||
|
quoted.push(shell_escape(&arg));
|
||||||
|
}
|
||||||
|
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
|
||||||
|
let args = build_shell_args(&shell, &command);
|
||||||
|
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||||
|
Ok(ShellCommand { shell, args })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_shell() -> String {
|
||||||
|
if let Ok(shell) = std::env::var("SHELL") {
|
||||||
|
if !shell.trim().is_empty() {
|
||||||
|
return shell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
"/bin/zsh".to_string()
|
||||||
|
} else {
|
||||||
|
"/bin/bash".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_escape(input: &str) -> String {
|
||||||
|
if input.is_empty() {
|
||||||
|
"''".to_string()
|
||||||
|
} else if !input
|
||||||
|
.chars()
|
||||||
|
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
|
||||||
|
{
|
||||||
|
input.to_string()
|
||||||
|
} else {
|
||||||
|
let escaped = input.replace('\'', "'\\''");
|
||||||
|
format!("'{}'", escaped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||||
|
let shell_name = std::path::Path::new(shell)
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
if shell_name.contains("zsh") {
|
||||||
|
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||||
|
} else {
|
||||||
|
vec!["-l".into(), "-c".into(), command.into()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.find(|p| p.exists())
|
||||||
|
.map(|p| normalize_path(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path(path: PathBuf) -> String {
|
||||||
|
if let Ok(clean) = path.canonicalize() {
|
||||||
|
clean.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
path.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
125
packages/tauri-app/src-tauri/src/main.rs
Normal file
125
packages/tauri-app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod cli_manager;
|
||||||
|
|
||||||
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
|
use serde_json::json;
|
||||||
|
use tauri::menu::Menu;
|
||||||
|
use tauri::plugin::Builder as PluginBuilder;
|
||||||
|
use tauri::webview::Webview;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub manager: CliProcessManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
||||||
|
state.manager.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
|
||||||
|
let dev_mode = is_dev_mode();
|
||||||
|
state.manager.stop().map_err(|e| e.to_string())?;
|
||||||
|
state
|
||||||
|
.manager
|
||||||
|
.start(app, dev_mode)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(state.manager.status())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_dev_mode() -> bool {
|
||||||
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_allow_internal(url: &Url) -> bool {
|
||||||
|
match url.scheme() {
|
||||||
|
"tauri" | "asset" | "file" => true,
|
||||||
|
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||||
|
if should_allow_internal(url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = webview
|
||||||
|
.app_handle()
|
||||||
|
.opener()
|
||||||
|
.open_url(url.as_str(), None::<&str>)
|
||||||
|
{
|
||||||
|
eprintln!("[tauri] failed to open external link {}: {}", url, err);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let navigation_guard = PluginBuilder::new("external-link-guard")
|
||||||
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(navigation_guard)
|
||||||
|
.manage(AppState {
|
||||||
|
manager: CliProcessManager::new(),
|
||||||
|
})
|
||||||
|
.setup(|app| {
|
||||||
|
build_menu(&app.handle())?;
|
||||||
|
let dev_mode = is_dev_mode();
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
let manager = app.state::<AppState>().manager.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
|
||||||
|
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||||
|
.on_menu_event(|_app_handle, _event| {
|
||||||
|
// No menu items defined currently
|
||||||
|
})
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while building tauri application")
|
||||||
|
.run(|app_handle, event| match event {
|
||||||
|
tauri::RunEvent::ExitRequested { .. } => {
|
||||||
|
let app = app_handle.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
let _ = state.manager.stop();
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
event: tauri::WindowEvent::Destroyed,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if app_handle.webview_windows().len() <= 1 {
|
||||||
|
let app = app_handle.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
|
let _ = state.manager.stop();
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||||
|
// Minimal empty menu for now (Tauri v2 menu API differs from v1 roles).
|
||||||
|
let menu = Menu::new(app)?;
|
||||||
|
app.set_menu(menu)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
49
packages/tauri-app/src-tauri/tauri.conf.json
Normal file
49
packages/tauri-app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "CodeNomad",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "ai.opencode.client",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
|
"beforeBuildCommand": "npm run bundle:server",
|
||||||
|
"frontendDist": "resources/ui-loading"
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "CodeNomad",
|
||||||
|
"url": "loading.html",
|
||||||
|
"width": 1400,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600,
|
||||||
|
"center": true,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"decorations": true,
|
||||||
|
"theme": "Dark",
|
||||||
|
"backgroundColor": "#1a1a1a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"assetProtocol": {
|
||||||
|
"scope": ["**"]
|
||||||
|
},
|
||||||
|
"capabilities": ["main-window-native-dialogs"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"resources": [
|
||||||
|
"resources/server",
|
||||||
|
"resources/ui-loading"
|
||||||
|
],
|
||||||
|
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||||
|
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/ui/README.md
Normal file
54
packages/ui/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# CodeNomad UI
|
||||||
|
|
||||||
|
This package contains the frontend user interface for CodeNomad, built with [SolidJS](https://www.solidjs.com/) and [Tailwind CSS](https://tailwindcss.com/).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The UI is designed to be a high-performance, low-latency cockpit for managing OpenCode sessions. It connects to the CodeNomad server (either running locally via CLI or embedded in the Electron app).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **SolidJS**: Fine-grained reactivity for high performance.
|
||||||
|
- **Tailwind CSS**: Utility-first styling for rapid development.
|
||||||
|
- **Vite**: Fast build tool and dev server.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
To run the UI in standalone mode (connected to a running server):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the Vite dev server at `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the production assets:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
|
||||||
|
|
||||||
|
## Debug Logging
|
||||||
|
|
||||||
|
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
|
||||||
|
|
||||||
|
- `sse` – Server-sent event transport and handlers
|
||||||
|
- `api` – HTTP/API calls and workspace lifecycle
|
||||||
|
- `session` – Session/model state, prompt handling, tool calls
|
||||||
|
- `actions` – User-driven interactions in UI components
|
||||||
|
|
||||||
|
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
|
||||||
|
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
|
||||||
|
window.codenomadLogger?.disableLogger("sse") // turn them off again
|
||||||
|
window.codenomadLogger?.enableAllLoggers() // optional helper
|
||||||
|
```
|
||||||
|
|
||||||
|
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.1.2",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,11 +12,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.0.68",
|
"@opencode-ai/sdk": "^1.0.133",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0"
|
"solid-toast": "^0.5.0"
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
|
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Toaster } from "solid-toast"
|
import { Toaster } from "solid-toast"
|
||||||
|
import AlertDialog from "./components/alert-dialog"
|
||||||
import FolderSelectionView from "./components/folder-selection-view"
|
import FolderSelectionView from "./components/folder-selection-view"
|
||||||
|
import { showConfirmDialog } from "./stores/alerts"
|
||||||
import InstanceTabs from "./components/instance-tabs"
|
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-shell"
|
import InstanceShell from "./components/instance/instance-shell"
|
||||||
|
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initMarkdown } from "./lib/markdown"
|
||||||
import { useTheme } from "./lib/theme"
|
import { useTheme } from "./lib/theme"
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
|
import { getLogger } from "./lib/logger"
|
||||||
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
@@ -39,22 +44,32 @@ import {
|
|||||||
updateSessionModel,
|
updateSessionModel,
|
||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
addRecentFolder,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
|
setThinkingBlocksExpansion,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||||
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
void initMarkdown(isDark()).catch(console.error)
|
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
initReleaseNotifications()
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeInstance = createMemo(() => getActiveInstance())
|
const activeInstance = createMemo(() => getActiveInstance())
|
||||||
@@ -92,20 +107,23 @@ const App: Component = () => {
|
|||||||
setIsSelectingFolder(true)
|
setIsSelectingFolder(true)
|
||||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||||
try {
|
try {
|
||||||
addRecentFolder(folderPath)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setHasInstances(true)
|
setHasInstances(true)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
setIsAdvancedSettingsOpen(false)
|
||||||
|
|
||||||
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
|
log.info("Created instance", {
|
||||||
|
instanceId,
|
||||||
|
port: instances().get(instanceId)?.port,
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
if (isMissingBinaryError(error)) {
|
if (isMissingBinaryError(error)) {
|
||||||
setLaunchErrorBinary(selectedBinary)
|
setLaunchErrorBinary(selectedBinary)
|
||||||
}
|
}
|
||||||
console.error("Failed to create instance:", error)
|
log.error("Failed to create instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSelectingFolder(false)
|
setIsSelectingFolder(false)
|
||||||
}
|
}
|
||||||
@@ -130,16 +148,26 @@ const App: Component = () => {
|
|||||||
try {
|
try {
|
||||||
await acknowledgeDisconnectedInstance()
|
await acknowledgeDisconnectedInstance()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to finalize disconnected instance:", error)
|
log.error("Failed to finalize disconnected instance", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCloseInstance(instanceId: string) {
|
async function handleCloseInstance(instanceId: string) {
|
||||||
if (confirm("Stop OpenCode instance? This will stop the server.")) {
|
const confirmed = await showConfirmDialog(
|
||||||
await stopInstance(instanceId)
|
"Stop OpenCode instance? This will stop the server.",
|
||||||
if (instances().size === 0) {
|
{
|
||||||
setHasInstances(false)
|
title: "Stop instance",
|
||||||
}
|
variant: "warning",
|
||||||
|
confirmLabel: "Stop",
|
||||||
|
cancelLabel: "Keep running",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
await stopInstance(instanceId)
|
||||||
|
if (instances().size === 0) {
|
||||||
|
setHasInstances(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +176,7 @@ const App: Component = () => {
|
|||||||
const session = await createSession(instanceId)
|
const session = await createSession(instanceId)
|
||||||
setActiveParentSession(instanceId, session.id)
|
setActiveParentSession(instanceId, session.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create session:", error)
|
log.error("Failed to create session", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +200,7 @@ const App: Component = () => {
|
|||||||
try {
|
try {
|
||||||
await fetchSessions(instanceId)
|
await fetchSessions(instanceId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to refresh sessions after closing:", error)
|
log.error("Failed to refresh sessions after closing", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,10 +220,13 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const { commands: paletteCommands, executeCommand } = useCommands({
|
const { commands: paletteCommands, executeCommand } = useCommands({
|
||||||
preferences,
|
preferences,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
|
setThinkingBlocksExpansion,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
@@ -232,7 +263,7 @@ const App: Component = () => {
|
|||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
||||||
Advanced Settings.
|
Advanced Settings.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
@@ -266,6 +297,7 @@ const App: Component = () => {
|
|||||||
onSelect={setActiveInstanceId}
|
onSelect={setActiveInstanceId}
|
||||||
onClose={handleCloseInstance}
|
onClose={handleCloseInstance}
|
||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Show when={activeInstance()} keyed>
|
<Show when={activeInstance()} keyed>
|
||||||
@@ -291,6 +323,7 @@ const App: Component = () => {
|
|||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||||
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -320,6 +353,10 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||||
|
|
||||||
|
<AlertDialog />
|
||||||
|
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
@@ -334,4 +371,5 @@ const App: Component = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
|||||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
import Kbd from "./kbd"
|
import { getLogger } from "../lib/logger"
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
interface AgentSelectorProps {
|
interface AgentSelectorProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -50,10 +52,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (instanceAgents().length === 0) {
|
if (instanceAgents().length === 0) {
|
||||||
fetchAgents(props.instanceId).catch(console.error)
|
fetchAgents(props.instanceId).catch((error) => log.error("Failed to fetch agents", error))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const handleChange = async (value: Agent | null) => {
|
const handleChange = async (value: Agent | null) => {
|
||||||
if (value && value.name !== props.currentAgent) {
|
if (value && value.name !== props.currentAgent) {
|
||||||
await props.onAgentChange(value.name)
|
await props.onAgentChange(value.name)
|
||||||
@@ -116,9 +119,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
</Select>
|
</Select>
|
||||||
<span class="hint sidebar-selector-hint">
|
|
||||||
<Kbd shortcut="cmd+shift+a" />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
132
packages/ui/src/components/alert-dialog.tsx
Normal file
132
packages/ui/src/components/alert-dialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { Component, Show, createEffect } from "solid-js"
|
||||||
|
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
||||||
|
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
|
||||||
|
|
||||||
|
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
|
||||||
|
info: {
|
||||||
|
badgeBg: "var(--badge-neutral-bg)",
|
||||||
|
badgeBorder: "var(--border-base)",
|
||||||
|
badgeText: "var(--accent-primary)",
|
||||||
|
symbol: "i",
|
||||||
|
fallbackTitle: "Heads up",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
badgeBg: "rgba(255, 152, 0, 0.14)",
|
||||||
|
badgeBorder: "var(--status-warning)",
|
||||||
|
badgeText: "var(--status-warning)",
|
||||||
|
symbol: "!",
|
||||||
|
fallbackTitle: "Please review",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
badgeBg: "var(--danger-soft-bg)",
|
||||||
|
badgeBorder: "var(--status-error)",
|
||||||
|
badgeText: "var(--status-error)",
|
||||||
|
symbol: "!",
|
||||||
|
fallbackTitle: "Something went wrong",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
|
||||||
|
const current = payload ?? alertDialogState()
|
||||||
|
if (current?.type === "confirm") {
|
||||||
|
if (confirmed) {
|
||||||
|
current.onConfirm?.()
|
||||||
|
} else {
|
||||||
|
current.onCancel?.()
|
||||||
|
}
|
||||||
|
current.resolve?.(confirmed)
|
||||||
|
} else if (confirmed) {
|
||||||
|
current?.onConfirm?.()
|
||||||
|
}
|
||||||
|
dismissAlertDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertDialog: Component = () => {
|
||||||
|
let primaryButtonRef: HTMLButtonElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (alertDialogState()) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
primaryButtonRef?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={alertDialogState()} keyed>
|
||||||
|
{(payload) => {
|
||||||
|
const variant = payload.variant ?? "info"
|
||||||
|
const accent = variantAccent[variant]
|
||||||
|
const title = payload.title || accent.fallbackTitle
|
||||||
|
const isConfirm = payload.type === "confirm"
|
||||||
|
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
|
||||||
|
const cancelLabel = payload.cancelLabel || "Cancel"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
modal
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
dismiss(false, payload)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||||
|
style={{
|
||||||
|
"background-color": accent.badgeBg,
|
||||||
|
"border-color": accent.badgeBorder,
|
||||||
|
color: accent.badgeText,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{accent.symbol}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||||
|
{payload.message}
|
||||||
|
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
{isConfirm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-secondary"
|
||||||
|
onClick={() => dismiss(false, payload)}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-primary"
|
||||||
|
ref={(el) => {
|
||||||
|
primaryButtonRef = el
|
||||||
|
}}
|
||||||
|
onClick={() => dismiss(true, payload)}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertDialog
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { createMemo, Show, onMount, createEffect } from "solid-js"
|
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
|
import { disableCache } from "@git-diff-view/core"
|
||||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||||
|
import { ErrorBoundary } from "solid-js"
|
||||||
import { getLanguageFromPath } from "../lib/markdown"
|
import { getLanguageFromPath } from "../lib/markdown"
|
||||||
import { normalizeDiffText } from "../lib/diff-utils"
|
import { normalizeDiffText } from "../lib/diff-utils"
|
||||||
import { setToolRenderCache } from "../lib/tool-render-cache"
|
import { setCacheEntry } from "../lib/global-cache"
|
||||||
|
import type { CacheEntryParams } from "../lib/global-cache"
|
||||||
import type { DiffViewMode } from "../stores/preferences"
|
import type { DiffViewMode } from "../stores/preferences"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
|
disableCache()
|
||||||
|
|
||||||
interface ToolCallDiffViewerProps {
|
interface ToolCallDiffViewerProps {
|
||||||
diffText: string
|
diffText: string
|
||||||
@@ -13,7 +21,7 @@ interface ToolCallDiffViewerProps {
|
|||||||
mode: DiffViewMode
|
mode: DiffViewMode
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
cachedHtml?: string
|
cachedHtml?: string
|
||||||
cacheKey?: string
|
cacheEntryParams?: CacheEntryParams
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiffData = {
|
type DiffData = {
|
||||||
@@ -22,16 +30,23 @@ type DiffData = {
|
|||||||
hunks: string[]
|
hunks: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CaptureContext = {
|
||||||
|
theme: ToolCallDiffViewerProps["theme"]
|
||||||
|
mode: DiffViewMode
|
||||||
|
diffText: string
|
||||||
|
cacheEntryParams?: CacheEntryParams
|
||||||
|
}
|
||||||
|
|
||||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||||
const diffData = createMemo<DiffData | null>(() => {
|
const diffData = createMemo<DiffData | null>(() => {
|
||||||
const normalized = normalizeDiffText(props.diffText)
|
const normalized = normalizeDiffText(props.diffText)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const language = getLanguageFromPath(props.filePath) || "text"
|
const language = getLanguageFromPath(props.filePath) || "text"
|
||||||
const fileName = props.filePath || "diff"
|
const fileName = props.filePath || "diff"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oldFile: {
|
oldFile: {
|
||||||
fileName,
|
fileName,
|
||||||
@@ -44,34 +59,48 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
hunks: [normalized],
|
hunks: [normalized],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let diffContainerRef: HTMLDivElement | undefined
|
let diffContainerRef: HTMLDivElement | undefined
|
||||||
|
let lastCapturedKey: string | undefined
|
||||||
const captureAndCacheHtml = () => {
|
|
||||||
if (diffContainerRef && props.cacheKey && !props.cachedHtml) {
|
const contextKey = createMemo(() => {
|
||||||
// Extract the rendered HTML from DiffView container
|
const data = diffData()
|
||||||
const renderedHtml = diffContainerRef.innerHTML
|
if (!data) return ""
|
||||||
if (renderedHtml) {
|
return `${props.theme}|${props.mode}|${props.diffText}`
|
||||||
setToolRenderCache(props.cacheKey, {
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const cachedHtml = props.cachedHtml
|
||||||
|
if (cachedHtml) {
|
||||||
|
// When we are given cached HTML, we rely on the caller's cache
|
||||||
|
// and simply notify once rendered.
|
||||||
|
props.onRendered?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = contextKey()
|
||||||
|
if (!key) return
|
||||||
|
if (!diffContainerRef) return
|
||||||
|
if (lastCapturedKey === key) return
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!diffContainerRef) return
|
||||||
|
const markup = diffContainerRef.innerHTML
|
||||||
|
if (!markup) return
|
||||||
|
lastCapturedKey = key
|
||||||
|
if (props.cacheEntryParams) {
|
||||||
|
setCacheEntry(props.cacheEntryParams, {
|
||||||
text: props.diffText,
|
text: props.diffText,
|
||||||
html: renderedHtml,
|
html: markup,
|
||||||
theme: props.theme,
|
theme: props.theme,
|
||||||
mode: props.mode,
|
mode: props.mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
props.onRendered?.()
|
||||||
props.onRendered?.()
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// Also capture HTML when diff data changes
|
|
||||||
createEffect(() => {
|
|
||||||
const data = diffData()
|
|
||||||
if (data && !props.cachedHtml) {
|
|
||||||
// Delay to allow DiffView to re-render with new data
|
|
||||||
setTimeout(captureAndCacheHtml, 100)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-diff-viewer">
|
<div class="tool-call-diff-viewer">
|
||||||
<Show
|
<Show
|
||||||
@@ -83,14 +112,19 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
|
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
|
||||||
>
|
>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<DiffView
|
<ErrorBoundary fallback={(error) => {
|
||||||
data={data()}
|
log.warn("Failed to render diff view", error)
|
||||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
|
||||||
diffViewTheme={props.theme}
|
}}>
|
||||||
diffViewHighlight
|
<DiffView
|
||||||
diffViewWrap={false}
|
data={data()}
|
||||||
diffViewFontSize={13}
|
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||||
/>
|
diffViewTheme={props.theme}
|
||||||
|
diffViewHighlight
|
||||||
|
diffViewWrap={false}
|
||||||
|
diffViewFontSize={13}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||||
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
|
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
|
||||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../cli/src/api-types"
|
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||||
import { WINDOWS_DRIVES_ROOT } from "../../../cli/src/api-types"
|
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||||
import { cliApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
|
|
||||||
function normalizePathKey(input?: string | null) {
|
function normalizePathKey(input?: string | null) {
|
||||||
if (!input || input === "." || input === "./") {
|
if (!input || input === "." || input === "./") {
|
||||||
@@ -144,7 +144,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await cliApi.listFileSystem(targetPath, { includeFiles: false })
|
const response = await serverApi.listFileSystem(targetPath, { includeFiles: false })
|
||||||
const canonicalKey = normalizePathKey(response.metadata.currentPath)
|
const canonicalKey = normalizePathKey(response.metadata.currentPath)
|
||||||
const directories = response.entries
|
const directories = response.entries
|
||||||
.filter((entry) => entry.type === "directory")
|
.filter((entry) => entry.type === "directory")
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
|
||||||
|
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
|
||||||
import { cliApi } from "../lib/api-client"
|
|
||||||
|
|
||||||
interface FileItem {
|
|
||||||
path: string
|
|
||||||
added?: number
|
|
||||||
removed?: number
|
|
||||||
isGitFile: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilePickerProps {
|
|
||||||
open: boolean
|
|
||||||
onSelect: (path: string) => void
|
|
||||||
onNavigate: (direction: "up" | "down") => void
|
|
||||||
onClose: () => void
|
|
||||||
instanceClient: OpencodeClient
|
|
||||||
searchQuery: string
|
|
||||||
textareaRef?: HTMLTextAreaElement
|
|
||||||
workspaceId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilePicker: Component<FilePickerProps> = (props) => {
|
|
||||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
|
||||||
const [loading, setLoading] = createSignal(false)
|
|
||||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
|
||||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
|
||||||
|
|
||||||
let containerRef: HTMLDivElement | undefined
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
async function fetchFiles(searchQuery: string) {
|
|
||||||
console.log(`[FilePicker] Fetching files for query: "${searchQuery}"`)
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (allFiles().length === 0) {
|
|
||||||
console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`)
|
|
||||||
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
|
||||||
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
|
|
||||||
path: entry.path,
|
|
||||||
isGitFile: false,
|
|
||||||
}))
|
|
||||||
setAllFiles(scannedFiles)
|
|
||||||
console.log(`[FilePicker] Found ${scannedFiles.length} files`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredFiles = searchQuery.trim()
|
|
||||||
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
||||||
: allFiles()
|
|
||||||
|
|
||||||
console.log(`[FilePicker] Showing ${filteredFiles.length} files`)
|
|
||||||
setFiles(filteredFiles)
|
|
||||||
setSelectedIndex(0)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (scrollContainerRef) {
|
|
||||||
scrollContainerRef.scrollTop = 0
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[FilePicker] Failed to fetch files:`, error)
|
|
||||||
setFiles([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastQuery = ""
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
console.log(
|
|
||||||
`[FilePicker] Effect triggered - open: ${props.open}, query: "${props.searchQuery}", isInitialized: ${isInitialized()}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (props.open && !isInitialized()) {
|
|
||||||
setIsInitialized(true)
|
|
||||||
console.log("[FilePicker] First open - fetching files")
|
|
||||||
fetchFiles(props.searchQuery)
|
|
||||||
lastQuery = props.searchQuery
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.open && props.searchQuery !== lastQuery) {
|
|
||||||
console.log(`[FilePicker] Query changed from "${lastQuery}" to "${props.searchQuery}"`)
|
|
||||||
lastQuery = props.searchQuery
|
|
||||||
fetchFiles(props.searchQuery)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function scrollToSelected() {
|
|
||||||
setTimeout(() => {
|
|
||||||
const selectedElement = containerRef?.querySelector('[data-file-selected="true"]')
|
|
||||||
if (selectedElement) {
|
|
||||||
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelect(path: string) {
|
|
||||||
props.onSelect(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNavigateUp() {
|
|
||||||
setSelectedIndex((prev) => {
|
|
||||||
const next = Math.max(prev - 1, 0)
|
|
||||||
scrollToSelected()
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNavigateDown() {
|
|
||||||
setSelectedIndex((prev) => {
|
|
||||||
const next = Math.min(prev + 1, files().length - 1)
|
|
||||||
scrollToSelected()
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!props.open) return
|
|
||||||
const listener = (e: KeyboardEvent) => {
|
|
||||||
if (!props.open) return
|
|
||||||
const fileList = files()
|
|
||||||
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
props.onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileList.length === 0) return
|
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
handleNavigateDown()
|
|
||||||
props.onNavigate("down")
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
handleNavigateUp()
|
|
||||||
props.onNavigate("up")
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
if (fileList[selectedIndex()]) {
|
|
||||||
handleSelect(fileList[selectedIndex()].path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", listener, true)
|
|
||||||
onCleanup(() => document.removeEventListener("keydown", listener, true))
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={props.open}>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class="dropdown-surface bottom-full left-0 mb-2 max-w-2xl rounded-lg"
|
|
||||||
style={{ "z-index": 100 }}
|
|
||||||
>
|
|
||||||
<div ref={scrollContainerRef} class="dropdown-content max-h-96">
|
|
||||||
<Show
|
|
||||||
when={!loading() && isInitialized()}
|
|
||||||
fallback={
|
|
||||||
<div class="dropdown-loading">
|
|
||||||
<div class="spinner inline-block h-4 w-4 mr-2"></div>
|
|
||||||
<span>Loading files...</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={files().length > 0}
|
|
||||||
fallback={<div class="dropdown-empty">No matching files</div>}
|
|
||||||
>
|
|
||||||
<For each={files()}>
|
|
||||||
{(file, index) => (
|
|
||||||
<div
|
|
||||||
data-file-selected={index() === selectedIndex()}
|
|
||||||
class={`dropdown-item border-b px-4 py-2 font-mono text-sm ${
|
|
||||||
index() === selectedIndex() ? "dropdown-item-highlight" : ""
|
|
||||||
}`}
|
|
||||||
style="border-color: var(--border-muted)"
|
|
||||||
onClick={() => handleSelect(file.path)}
|
|
||||||
onMouseEnter={() => setSelectedIndex(index())}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span>{file.path}</span>
|
|
||||||
<Show when={file.isGitFile && (file.added || file.removed)}>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Show when={file.added}>
|
|
||||||
<span class="dropdown-diff-added">+{file.added}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={file.removed}>
|
|
||||||
<span class="dropdown-diff-removed">-{file.removed}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dropdown-footer p-2">
|
|
||||||
<div class="flex items-center justify-between px-2">
|
|
||||||
<span>↑↓ Navigate • Enter Select • Esc Close</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FilePicker
|
|
||||||
@@ -1,197 +1,26 @@
|
|||||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup, onMount } from "solid-js"
|
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
|
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
|
||||||
import type { FileSystemEntry } from "../../../cli/src/api-types"
|
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||||
import { cliApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { getServerMeta } from "../lib/server-meta"
|
import { getLogger } from "../lib/logger"
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
const MAX_RESULTS = 200
|
const MAX_RESULTS = 200
|
||||||
|
|
||||||
type CacheListener = (entries: FileSystemEntry[]) => void
|
function normalizeEntryPath(path: string | undefined): string {
|
||||||
|
if (!path || path === "." || path === "./") {
|
||||||
interface FileSystemCacheState {
|
|
||||||
entriesMap: Map<string, FileSystemEntry>
|
|
||||||
entriesList: FileSystemEntry[]
|
|
||||||
loadedDirectories: Set<string>
|
|
||||||
loadingPromises: Map<string, Promise<void>>
|
|
||||||
pendingDirectories: string[]
|
|
||||||
listeners: Set<CacheListener>
|
|
||||||
queueActive: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileSystemCache: FileSystemCacheState = {
|
|
||||||
entriesMap: new Map(),
|
|
||||||
entriesList: [],
|
|
||||||
loadedDirectories: new Set(),
|
|
||||||
loadingPromises: new Map(),
|
|
||||||
pendingDirectories: [],
|
|
||||||
listeners: new Set(),
|
|
||||||
queueActive: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
let cacheWorkspaceRoot: string | null = null
|
|
||||||
|
|
||||||
function normalizeEntryPath(path: string): string {
|
|
||||||
if (!path || path === ".") {
|
|
||||||
return "."
|
return "."
|
||||||
}
|
}
|
||||||
const cleaned = path.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/")
|
let cleaned = path.replace(/\\/g, "/")
|
||||||
return cleaned || "."
|
if (cleaned.startsWith("./")) {
|
||||||
}
|
cleaned = cleaned.replace(/^\.\/+/, "")
|
||||||
|
|
||||||
function updateCache(entries: FileSystemEntry[]): boolean {
|
|
||||||
let changed = false
|
|
||||||
for (const entry of entries) {
|
|
||||||
const normalizedPath = normalizeEntryPath(entry.path)
|
|
||||||
const normalizedEntry = normalizedPath === entry.path ? entry : { ...entry, path: normalizedPath }
|
|
||||||
const existing = fileSystemCache.entriesMap.get(normalizedPath)
|
|
||||||
|
|
||||||
if (
|
|
||||||
!existing ||
|
|
||||||
existing.name !== normalizedEntry.name ||
|
|
||||||
existing.type !== normalizedEntry.type ||
|
|
||||||
existing.size !== normalizedEntry.size ||
|
|
||||||
existing.modifiedAt !== normalizedEntry.modifiedAt
|
|
||||||
) {
|
|
||||||
fileSystemCache.entriesMap.set(normalizedPath, normalizedEntry)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (cleaned.startsWith("/")) {
|
||||||
if (changed) {
|
cleaned = cleaned.replace(/^\/+/, "")
|
||||||
fileSystemCache.entriesList = Array.from(fileSystemCache.entriesMap.values()).sort((a, b) =>
|
|
||||||
a.path.localeCompare(b.path),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
cleaned = cleaned.replace(/\/+/g, "/")
|
||||||
return changed
|
return cleaned === "" ? "." : cleaned
|
||||||
}
|
|
||||||
|
|
||||||
function notifyCacheListeners() {
|
|
||||||
for (const listener of fileSystemCache.listeners) {
|
|
||||||
listener(fileSystemCache.entriesList)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeToCache(listener: CacheListener) {
|
|
||||||
fileSystemCache.listeners.add(listener)
|
|
||||||
listener(fileSystemCache.entriesList)
|
|
||||||
return () => fileSystemCache.listeners.delete(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFileSystemCache() {
|
|
||||||
fileSystemCache.entriesMap.clear()
|
|
||||||
fileSystemCache.entriesList = []
|
|
||||||
fileSystemCache.loadedDirectories.clear()
|
|
||||||
fileSystemCache.loadingPromises.clear()
|
|
||||||
fileSystemCache.pendingDirectories = []
|
|
||||||
fileSystemCache.queueActive = false
|
|
||||||
notifyCacheListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
function enqueueDirectory(path: string, priority = false) {
|
|
||||||
const normalized = normalizeEntryPath(path)
|
|
||||||
if (normalized === "." || fileSystemCache.loadedDirectories.has(normalized) || fileSystemCache.loadingPromises.has(normalized)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIndex = fileSystemCache.pendingDirectories.indexOf(normalized)
|
|
||||||
if (existingIndex !== -1) {
|
|
||||||
if (priority) {
|
|
||||||
fileSystemCache.pendingDirectories.splice(existingIndex, 1)
|
|
||||||
fileSystemCache.pendingDirectories.unshift(normalized)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (priority) {
|
|
||||||
fileSystemCache.pendingDirectories.unshift(normalized)
|
|
||||||
} else {
|
|
||||||
fileSystemCache.pendingDirectories.push(normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDirectory(path: string): Promise<void> {
|
|
||||||
const normalized = normalizeEntryPath(path)
|
|
||||||
if (fileSystemCache.loadedDirectories.has(normalized)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = fileSystemCache.loadingPromises.get(normalized)
|
|
||||||
if (existing) {
|
|
||||||
await existing
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = cliApi
|
|
||||||
.listFileSystem(normalized === "." ? "." : normalized)
|
|
||||||
.then(({ entries }) => {
|
|
||||||
const changed = updateCache(entries)
|
|
||||||
fileSystemCache.loadedDirectories.add(normalized)
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.type === "directory") {
|
|
||||||
enqueueDirectory(entry.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) {
|
|
||||||
notifyCacheListeners()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
fileSystemCache.loadingPromises.delete(normalized)
|
|
||||||
})
|
|
||||||
|
|
||||||
fileSystemCache.loadingPromises.set(normalized, promise)
|
|
||||||
await promise
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processDirectoryQueue() {
|
|
||||||
if (fileSystemCache.queueActive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileSystemCache.queueActive = true
|
|
||||||
try {
|
|
||||||
while (fileSystemCache.pendingDirectories.length > 0) {
|
|
||||||
const next = fileSystemCache.pendingDirectories.shift()
|
|
||||||
if (!next) continue
|
|
||||||
try {
|
|
||||||
await loadDirectory(next)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to load directory", next, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
fileSystemCache.queueActive = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startBackgroundLoading() {
|
|
||||||
void processDirectoryQueue()
|
|
||||||
}
|
|
||||||
|
|
||||||
function prioritizeDirectoriesForQuery(query: string) {
|
|
||||||
const normalized = query.replace(/\\/g, "/").trim()
|
|
||||||
if (!normalized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const segments = normalized.split("/").filter(Boolean)
|
|
||||||
let prefix = ""
|
|
||||||
for (const segment of segments) {
|
|
||||||
prefix = prefix ? `${prefix}/${segment}` : segment
|
|
||||||
enqueueDirectory(prefix, true)
|
|
||||||
}
|
|
||||||
startBackgroundLoading()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureWorkspaceFilesystemLoaded(workspaceRoot: string) {
|
|
||||||
if (cacheWorkspaceRoot && cacheWorkspaceRoot !== workspaceRoot) {
|
|
||||||
cacheWorkspaceRoot = workspaceRoot
|
|
||||||
resetFileSystemCache()
|
|
||||||
} else if (!cacheWorkspaceRoot) {
|
|
||||||
cacheWorkspaceRoot = workspaceRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadDirectory(".")
|
|
||||||
startBackgroundLoading()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAbsolutePath(root: string, relativePath: string): string {
|
function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||||
@@ -207,11 +36,6 @@ function resolveAbsolutePath(root: string, relativePath: string): string {
|
|||||||
return `${trimmedRoot}${normalized}`
|
return `${trimmedRoot}${normalized}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRootLabel(root: string): string {
|
|
||||||
if (!root) return "Workspace Root"
|
|
||||||
const parts = root.split(/[/\\]/).filter(Boolean)
|
|
||||||
return parts[parts.length - 1] || root || "Workspace Root"
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileSystemBrowserDialogProps {
|
interface FileSystemBrowserDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -222,73 +46,174 @@ interface FileSystemBrowserDialogProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
|
||||||
|
|
||||||
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
||||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
|
||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||||
|
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
||||||
|
const [loadingPath, setLoadingPath] = createSignal<string | null>(null)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
const [searchQuery, setSearchQuery] = createSignal("")
|
const [searchQuery, setSearchQuery] = createSignal("")
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
|
|
||||||
let searchInputRef: HTMLInputElement | undefined
|
let searchInputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
onMount(() => {
|
const directoryCache = new Map<string, FileSystemEntry[]>()
|
||||||
const unsubscribe = subscribeToCache((items) => setEntries(items))
|
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||||
onCleanup(unsubscribe)
|
const inFlightLoads = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
function resetDialogState() {
|
||||||
const query = searchQuery().trim()
|
directoryCache.clear()
|
||||||
if (!query) {
|
metadataCache.clear()
|
||||||
return
|
inFlightLoads.clear()
|
||||||
|
setEntries([])
|
||||||
|
setCurrentMetadata(null)
|
||||||
|
setLoadingPath(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDirectory(path: string, makeCurrent = false): Promise<FileSystemListingMetadata> {
|
||||||
|
const normalized = normalizeEntryPath(path)
|
||||||
|
|
||||||
|
if (directoryCache.has(normalized) && metadataCache.has(normalized)) {
|
||||||
|
if (makeCurrent) {
|
||||||
|
setCurrentMetadata(metadataCache.get(normalized) ?? null)
|
||||||
|
setEntries(directoryCache.get(normalized) ?? [])
|
||||||
|
}
|
||||||
|
return metadataCache.get(normalized) as FileSystemListingMetadata
|
||||||
}
|
}
|
||||||
prioritizeDirectoriesForQuery(query)
|
|
||||||
})
|
if (inFlightLoads.has(normalized)) {
|
||||||
|
const metadata = await inFlightLoads.get(normalized)!
|
||||||
|
if (makeCurrent) {
|
||||||
|
setCurrentMetadata(metadata)
|
||||||
|
setEntries(directoryCache.get(normalized) ?? [])
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPromise = (async () => {
|
||||||
|
setLoadingPath(normalized)
|
||||||
|
const response = await serverApi.listFileSystem(normalized === "." ? "." : normalized, {
|
||||||
|
includeFiles: props.mode === "files",
|
||||||
|
})
|
||||||
|
directoryCache.set(normalized, response.entries)
|
||||||
|
metadataCache.set(normalized, response.metadata)
|
||||||
|
if (!rootPath()) {
|
||||||
|
setRootPath(response.metadata.rootPath)
|
||||||
|
}
|
||||||
|
if (loadingPath() === normalized) {
|
||||||
|
setLoadingPath(null)
|
||||||
|
}
|
||||||
|
return response.metadata
|
||||||
|
})().catch((err) => {
|
||||||
|
if (loadingPath() === normalized) {
|
||||||
|
setLoadingPath(null)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
|
||||||
|
inFlightLoads.set(normalized, loadPromise)
|
||||||
|
try {
|
||||||
|
const metadata = await loadPromise
|
||||||
|
if (makeCurrent) {
|
||||||
|
const key = normalizeEntryPath(metadata.currentPath)
|
||||||
|
setCurrentMetadata(metadata)
|
||||||
|
setEntries(directoryCache.get(key) ?? directoryCache.get(normalized) ?? [])
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
} finally {
|
||||||
|
inFlightLoads.delete(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshEntries() {
|
async function refreshEntries() {
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
resetDialogState()
|
||||||
try {
|
try {
|
||||||
const meta = await getServerMeta()
|
const metadata = await fetchDirectory(".", true)
|
||||||
setRootPath(meta.workspaceRoot)
|
setRootPath(metadata.rootPath)
|
||||||
await ensureWorkspaceFilesystemLoaded(meta.workspaceRoot)
|
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||||
setError(message)
|
setError(message)
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeLoadingPath() {
|
||||||
|
const path = loadingPath()
|
||||||
|
if (!path) {
|
||||||
|
return "filesystem"
|
||||||
|
}
|
||||||
|
if (path === ".") {
|
||||||
|
return rootPath() || "workspace root"
|
||||||
|
}
|
||||||
|
return resolveAbsolutePath(rootPath(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentAbsolutePath(): string {
|
||||||
|
const metadata = currentMetadata()
|
||||||
|
if (!metadata) {
|
||||||
|
return rootPath()
|
||||||
|
}
|
||||||
|
if (metadata.pathKind === "relative") {
|
||||||
|
return resolveAbsolutePath(rootPath(), metadata.currentPath)
|
||||||
|
}
|
||||||
|
return metadata.displayPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEntrySelect(entry: FileSystemEntry) {
|
||||||
|
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||||
|
props.onSelect(absolute)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavigateTo(path: string) {
|
||||||
|
void fetchDirectory(path, true).catch((err) => {
|
||||||
|
log.error("Failed to open directory", err)
|
||||||
|
setError(err instanceof Error ? err.message : "Unable to open directory")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavigateUp() {
|
||||||
|
const parent = currentMetadata()?.parentPath
|
||||||
|
if (!parent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleNavigateTo(parent)
|
||||||
|
}
|
||||||
|
|
||||||
const filteredEntries = createMemo(() => {
|
const filteredEntries = createMemo(() => {
|
||||||
const query = searchQuery().trim().toLowerCase()
|
const query = searchQuery().trim().toLowerCase()
|
||||||
const mode = props.mode
|
const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true))
|
||||||
const root = rootPath()
|
|
||||||
const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file"))
|
|
||||||
|
|
||||||
const baseEntries = mode === "directories" && root
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: formatRootLabel(root),
|
|
||||||
path: ".",
|
|
||||||
type: "directory" as const,
|
|
||||||
},
|
|
||||||
...matchesType,
|
|
||||||
]
|
|
||||||
: matchesType
|
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return baseEntries
|
return subset
|
||||||
}
|
}
|
||||||
|
return subset.filter((entry) => {
|
||||||
return baseEntries.filter((entry) => {
|
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||||
const absolute = resolveAbsolutePath(root, entry.path)
|
|
||||||
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
|
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
|
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
|
||||||
|
|
||||||
|
const folderRows = createMemo<FolderRow[]>(() => {
|
||||||
|
const rows: FolderRow[] = []
|
||||||
|
const metadata = currentMetadata()
|
||||||
|
if (metadata?.parentPath) {
|
||||||
|
rows.push({ type: "up", path: metadata.parentPath })
|
||||||
|
}
|
||||||
|
for (const entry of visibleEntries()) {
|
||||||
|
rows.push({ type: "entry", entry })
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const list = visibleEntries()
|
const list = visibleEntries()
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
@@ -338,20 +263,12 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("keydown", handleKeyDown)
|
window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
resetDialogState()
|
||||||
|
setRootPath("")
|
||||||
|
setError(null)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleEntrySelect(entry: FileSystemEntry) {
|
|
||||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
|
||||||
props.onSelect(absolute)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOverlayClick(event: MouseEvent) {
|
|
||||||
if (event.target === event.currentTarget) {
|
|
||||||
props.onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
|
||||||
@@ -360,9 +277,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<div class="panel-header flex items-start justify-between gap-4">
|
<div class="panel-header flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="panel-title">{props.title}</h3>
|
<h3 class="panel-title">{props.title}</h3>
|
||||||
<p class="panel-subtitle">
|
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
|
||||||
{props.description || "Search for a path under the configured workspace root."}
|
|
||||||
</p>
|
|
||||||
<Show when={rootPath()}>
|
<Show when={rootPath()}>
|
||||||
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
|
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -392,56 +307,117 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={props.mode === "directories"}>
|
||||||
|
<div class="px-4 pb-2">
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
|
||||||
|
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary whitespace-nowrap"
|
||||||
|
onClick={() => props.onSelect(currentAbsolutePath())}
|
||||||
|
>
|
||||||
|
Select Current
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
|
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
|
||||||
<Show
|
<Show
|
||||||
when={!loading() && !error()}
|
when={entries().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center justify-center py-6 text-sm text-secondary">
|
<div class="flex items-center justify-center py-6 text-sm text-secondary">
|
||||||
<Show
|
<Show
|
||||||
when={loading()}
|
when={loadingPath() !== null}
|
||||||
fallback={<span class="text-red-500">{error()}</span>}
|
fallback={<span class="text-red-500">{error()}</span>}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Loader2 class="w-4 h-4 animate-spin" />
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
<span>Loading filesystem…</span>
|
<span>Loading {describeLoadingPath()}…</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Show when={loadingPath()}>
|
||||||
|
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
|
||||||
|
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<span>Loading {describeLoadingPath()}…</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={visibleEntries().length > 0}
|
when={folderRows().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
||||||
<p>No matches.</p>
|
<p>No entries found.</p>
|
||||||
<Show when={searchQuery().trim().length === 0}>
|
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||||
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
Retry
|
||||||
Retry
|
</button>
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={visibleEntries()}>
|
<For each={folderRows()}>
|
||||||
{(entry, index) => (
|
{(row) => {
|
||||||
<button
|
if (row.type === "up") {
|
||||||
type="button"
|
return (
|
||||||
class="panel-list-item flex items-center gap-3 text-left"
|
<div class="panel-list-item" role="button">
|
||||||
classList={{ "panel-list-item-highlight": selectedIndex() === index() }}
|
<div class="panel-list-item-content directory-browser-row">
|
||||||
onMouseEnter={() => setSelectedIndex(index())}
|
<button type="button" class="directory-browser-row-main" onClick={handleNavigateUp}>
|
||||||
onClick={() => handleEntrySelect(entry)}
|
<div class="directory-browser-row-icon">
|
||||||
>
|
<ArrowUpLeft class="w-4 h-4" />
|
||||||
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-secondary text-muted">
|
</div>
|
||||||
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
|
<div class="directory-browser-row-text">
|
||||||
<FolderIcon class="w-4 h-4" />
|
<span class="directory-browser-row-name">Up one level</span>
|
||||||
</Show>
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = row.entry
|
||||||
|
const selectEntry = () => handleEntrySelect(entry)
|
||||||
|
const activateEntry = () => {
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
handleNavigateTo(entry.path)
|
||||||
|
} else {
|
||||||
|
selectEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="panel-list-item" role="listitem">
|
||||||
|
<div class="panel-list-item-content directory-browser-row">
|
||||||
|
<button type="button" class="directory-browser-row-main" onClick={activateEntry}>
|
||||||
|
<div class="directory-browser-row-icon">
|
||||||
|
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
|
||||||
|
<FolderIcon class="w-4 h-4" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="directory-browser-row-text">
|
||||||
|
<span class="directory-browser-row-name">{entry.name || entry.path}</span>
|
||||||
|
<span class="directory-browser-row-sub">
|
||||||
|
{resolveAbsolutePath(rootPath(), entry.path)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary directory-browser-select"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
selectEntry()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
)
|
||||||
<span class="text-sm font-medium text-primary">{entry.name || entry.path}</span>
|
}}
|
||||||
<span class="text-xs font-mono text-muted">{resolveAbsolutePath(rootPath(), entry.path)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -472,3 +448,4 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default FileSystemBrowserDialog
|
export default FileSystemBrowserDialog
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
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 } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
advancedSettingsOpen?: boolean
|
advancedSettingsOpen?: boolean
|
||||||
onAdvancedSettingsOpen?: () => void
|
onAdvancedSettingsOpen?: () => void
|
||||||
onAdvancedSettingsClose?: () => void
|
onAdvancedSettingsClose?: () => void
|
||||||
|
onOpenRemoteAccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig()
|
const { recentFolders, removeRecentFolder, preferences } = useConfig()
|
||||||
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(preferences().lastUsedBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
@@ -29,9 +33,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = preferences().lastUsedBinary
|
const lastUsed = preferences().lastUsedBinary
|
||||||
if (lastUsed && lastUsed !== selectedBinary()) {
|
if (!lastUsed) return
|
||||||
setSelectedBinary(lastUsed)
|
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
if (isBrowseShortcut) {
|
if (isBrowseShortcut) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleBrowse()
|
void handleBrowse()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,13 +172,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
function handleFolderSelect(path: string) {
|
function handleFolderSelect(path: string) {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
updateLastUsedBinary(selectedBinary())
|
|
||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
|
if (nativeDialogsAvailable) {
|
||||||
|
const fallbackPath = folders()[0]?.path
|
||||||
|
const selected = await openNativeFolderDialog({
|
||||||
|
title: "Select Workspace",
|
||||||
|
defaultPath: fallbackPath,
|
||||||
|
})
|
||||||
|
if (selected) {
|
||||||
|
handleFolderSelect(selected)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsFolderBrowserOpen(true)
|
setIsFolderBrowserOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +231,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
|
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
|
||||||
aria-busy={isLoading() ? "true" : "false"}
|
aria-busy={isLoading() ? "true" : "false"}
|
||||||
>
|
>
|
||||||
|
<Show when={props.onOpenRemoteAccess}>
|
||||||
|
<div class="absolute top-4 right-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
||||||
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
|
>
|
||||||
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<div class="mb-6 text-center shrink-0">
|
<div class="mb-6 text-center shrink-0">
|
||||||
<div class="mb-3 flex justify-center">
|
<div class="mb-3 flex justify-center">
|
||||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||||
@@ -230,6 +254,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
|
|
||||||
|
|
||||||
when={folders().length > 0}
|
when={folders().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="panel panel-empty-state flex-1">
|
<div class="panel panel-empty-state flex-1">
|
||||||
@@ -307,14 +333,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="panel shrink-0">
|
<div class="panel shrink-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header hidden sm:block">
|
||||||
<h2 class="panel-title">Browse for Folder</h2>
|
<h2 class="panel-title">Browse for Folder</h2>
|
||||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<button
|
<button
|
||||||
onClick={handleBrowse}
|
onClick={() => void handleBrowse()}
|
||||||
disabled={props.isLoading}
|
disabled={props.isLoading}
|
||||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||||
onMouseEnter={() => setFocusMode("new")}
|
onMouseEnter={() => setFocusMode("new")}
|
||||||
@@ -343,7 +369,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 panel panel-footer shrink-0">
|
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<Show when={folders().length > 0}>
|
<Show when={folders().length > 0}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
|
|||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
{folderLabel} can no longer be reached. Close the tab to continue working.
|
{folderLabel} can no longer be reached. Close the tab to continue working.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
|
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
|
||||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||||
import { fetchLspStatus, updateInstance } from "../stores/instances"
|
import { fetchLspStatus, updateInstance } from "../stores/instances"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
interface InstanceInfoProps {
|
interface InstanceInfoProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
@@ -48,6 +51,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
||||||
|
|
||||||
const metadata = () => props.instance.metadata
|
const metadata = () => props.instance.metadata
|
||||||
|
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
|
||||||
const mcpServers = () => {
|
const mcpServers = () => {
|
||||||
const status = metadata()?.mcpStatus
|
const status = metadata()?.mcpStatus
|
||||||
return status ? parseMcpStatus(status) : []
|
return status ? parseMcpStatus(status) : []
|
||||||
@@ -104,14 +108,15 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
...(lspStatus ? { lspStatus } : {}),
|
...(lspStatus ? { lspStatus } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextMetadata.version) {
|
if (!nextMetadata.version && instance.binaryVersion) {
|
||||||
nextMetadata.version = "0.15.8"
|
nextMetadata.version = instance.binaryVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
updateInstance(instanceId, { metadata: nextMetadata })
|
updateInstance(instanceId, { metadata: nextMetadata })
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error("Failed to load instance metadata:", error)
|
log.error("Failed to load instance metadata", error)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingMetadataRequests.delete(instanceId)
|
pendingMetadataRequests.delete(instanceId)
|
||||||
@@ -173,13 +178,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={metadata()?.version}>
|
<Show when={binaryVersion()}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
OpenCode Version
|
OpenCode Version
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
v{metadata()?.version}
|
v{binaryVersion()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js"
|
|||||||
import type { Instance } from "../types/instance"
|
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 } from "lucide-solid"
|
import { Plus, MonitorUp } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
@@ -11,43 +11,60 @@ interface InstanceTabsProps {
|
|||||||
onSelect: (instanceId: string) => void
|
onSelect: (instanceId: string) => void
|
||||||
onClose: (instanceId: string) => void
|
onClose: (instanceId: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
|
onOpenRemoteAccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div class="tab-bar tab-bar-instance">
|
<div class="tab-bar tab-bar-instance">
|
||||||
<div class="tab-container" role="tablist">
|
<div class="tab-container" role="tablist">
|
||||||
<div class="flex items-center gap-1 overflow-x-auto">
|
<div class="tab-scroll">
|
||||||
<For each={Array.from(props.instances.entries())}>
|
<div class="tab-strip">
|
||||||
{([id, instance]) => (
|
<div class="tab-strip-tabs">
|
||||||
<InstanceTab
|
<For each={Array.from(props.instances.entries())}>
|
||||||
instance={instance}
|
{([id, instance]) => (
|
||||||
active={id === props.activeInstanceId}
|
<InstanceTab
|
||||||
onSelect={() => props.onSelect(id)}
|
instance={instance}
|
||||||
onClose={() => props.onClose(id)}
|
active={id === props.activeInstanceId}
|
||||||
/>
|
onSelect={() => props.onSelect(id)}
|
||||||
)}
|
onClose={() => props.onClose(id)}
|
||||||
</For>
|
/>
|
||||||
<button
|
)}
|
||||||
class="new-tab-button"
|
</For>
|
||||||
onClick={props.onNew}
|
<button
|
||||||
title="New instance (Cmd/Ctrl+N)"
|
class="new-tab-button"
|
||||||
aria-label="New instance"
|
onClick={props.onNew}
|
||||||
>
|
title="New instance (Cmd/Ctrl+N)"
|
||||||
<Plus class="w-4 h-4" />
|
aria-label="New instance"
|
||||||
</button>
|
>
|
||||||
</div>
|
<Plus class="w-4 h-4" />
|
||||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
</button>
|
||||||
<div class="flex-shrink-0 ml-4">
|
</div>
|
||||||
<KeyboardHint
|
<div class="tab-strip-spacer" />
|
||||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||||
Boolean,
|
<div class="tab-shortcuts">
|
||||||
)}
|
<KeyboardHint
|
||||||
/>
|
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||||
|
Boolean,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||||
|
<button
|
||||||
|
class="new-tab-button tab-remote-button"
|
||||||
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
|
title="Remote connect"
|
||||||
|
aria-label="Remote connect"
|
||||||
|
>
|
||||||
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
||||||
|
import { Loader2, Trash2 } from "lucide-solid"
|
||||||
|
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
|
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading } from "../stores/sessions"
|
||||||
import InstanceInfo from "./instance-info"
|
import InstanceInfo from "./instance-info"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||||
import { isMac } from "../lib/keyboard-utils"
|
import { isMac } from "../lib/keyboard-utils"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface InstanceWelcomeViewProps {
|
interface InstanceWelcomeViewProps {
|
||||||
@@ -16,8 +20,17 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
const [isCreating, setIsCreating] = createSignal(false)
|
const [isCreating, setIsCreating] = createSignal(false)
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
||||||
|
const [showInstanceInfoOverlay, setShowInstanceInfoOverlay] = createSignal(false)
|
||||||
|
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
|
||||||
|
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
|
||||||
|
)
|
||||||
|
|
||||||
const parentSessions = () => getParentSessions(props.instance.id)
|
const parentSessions = () => getParentSessions(props.instance.id)
|
||||||
|
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
|
||||||
|
const isSessionDeleting = (sessionId: string) => {
|
||||||
|
const deleting = loading().deletingSession.get(props.instance.id)
|
||||||
|
return deleting ? deleting.has(sessionId) : false
|
||||||
|
}
|
||||||
const newSessionShortcut = createMemo<KeyboardShortcut>(() => {
|
const newSessionShortcut = createMemo<KeyboardShortcut>(() => {
|
||||||
const registered = keyboardRegistry.get("session-new")
|
const registered = keyboardRegistry.get("session-new")
|
||||||
if (registered) return registered
|
if (registered) return registered
|
||||||
@@ -47,6 +60,12 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const openInstanceInfoOverlay = () => {
|
||||||
|
if (isDesktopLayout()) return
|
||||||
|
setShowInstanceInfoOverlay(true)
|
||||||
|
}
|
||||||
|
const closeInstanceInfoOverlay = () => setShowInstanceInfoOverlay(false)
|
||||||
|
|
||||||
function scrollToIndex(index: number) {
|
function scrollToIndex(index: number) {
|
||||||
const element = document.querySelector(`[data-session-index="${index}"]`)
|
const element = document.querySelector(`[data-session-index="${index}"]`)
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -55,6 +74,14 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (showInstanceInfoOverlay()) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
closeInstanceInfoOverlay()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const sessions = parentSessions()
|
const sessions = parentSessions()
|
||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||||
@@ -104,26 +131,79 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleEnterKey()
|
void handleEnterKey()
|
||||||
|
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
|
e.preventDefault()
|
||||||
|
void handleDeleteKey()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnterKey() {
|
async function handleEnterKey() {
|
||||||
const sessions = parentSessions()
|
const sessions = parentSessions()
|
||||||
const index = selectedIndex()
|
const index = selectedIndex()
|
||||||
|
|
||||||
if (index < sessions.length) {
|
if (index < sessions.length) {
|
||||||
await handleSessionSelect(sessions[index].id)
|
await handleSessionSelect(sessions[index].id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
async function handleDeleteKey() {
|
||||||
|
const sessions = parentSessions()
|
||||||
|
const index = selectedIndex()
|
||||||
|
|
||||||
|
if (index >= sessions.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleSessionDelete(sessions[index].id)
|
||||||
|
|
||||||
|
const updatedSessions = parentSessions()
|
||||||
|
if (updatedSessions.length === 0) {
|
||||||
|
setFocusMode("new-session")
|
||||||
|
setSelectedIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
||||||
|
setSelectedIndex(nextIndex)
|
||||||
|
setFocusMode("sessions")
|
||||||
|
scrollToIndex(nextIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("keydown", handleKeyDown)
|
window.removeEventListener("keydown", handleKeyDown)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const mediaQuery = window.matchMedia("(min-width: 1024px)")
|
||||||
|
const handleMediaChange = (matches: boolean) => {
|
||||||
|
setIsDesktopLayout(matches)
|
||||||
|
if (matches) {
|
||||||
|
closeInstanceInfoOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listener = (event: MediaQueryListEvent) => handleMediaChange(event.matches)
|
||||||
|
|
||||||
|
if (typeof mediaQuery.addEventListener === "function") {
|
||||||
|
mediaQuery.addEventListener("change", listener)
|
||||||
|
onCleanup(() => {
|
||||||
|
mediaQuery.removeEventListener("change", listener)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
mediaQuery.addListener(listener)
|
||||||
|
onCleanup(() => {
|
||||||
|
mediaQuery.removeListener(listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMediaChange(mediaQuery.matches)
|
||||||
|
})
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: number): string {
|
function formatRelativeTime(timestamp: number): string {
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
@@ -144,15 +224,26 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
setActiveParentSession(props.instance.id, sessionId)
|
setActiveParentSession(props.instance.id, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSessionDelete(sessionId: string) {
|
||||||
|
if (isSessionDeleting(sessionId)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteSession(props.instance.id, sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to delete session:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleNewSession() {
|
async function handleNewSession() {
|
||||||
if (isCreating()) return
|
if (isCreating()) return
|
||||||
|
|
||||||
setIsCreating(true)
|
setIsCreating(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await createSession(props.instance.id)
|
const session = await createSession(props.instance.id)
|
||||||
setActiveParentSession(props.instance.id, session.id)
|
setActiveParentSession(props.instance.id, session.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create session:", error)
|
log.error("Failed to create session:", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false)
|
setIsCreating(false)
|
||||||
}
|
}
|
||||||
@@ -165,73 +256,138 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<Show
|
<Show
|
||||||
when={parentSessions().length > 0}
|
when={parentSessions().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
<Show
|
||||||
<div class="panel-empty-state-icon">
|
when={isFetchingSessions()}
|
||||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
fallback={
|
||||||
<path
|
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||||
stroke-linecap="round"
|
<div class="panel-empty-state-icon">
|
||||||
stroke-linejoin="round"
|
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
stroke-width="2"
|
<path
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
stroke-linecap="round"
|
||||||
/>
|
stroke-linejoin="round"
|
||||||
</svg>
|
stroke-width="2"
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="panel-empty-state-title">No Previous Sessions</p>
|
||||||
|
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
||||||
|
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||||
|
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
||||||
|
View Instance Info
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||||
|
<div class="panel-empty-state-icon">
|
||||||
|
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
||||||
|
</div>
|
||||||
|
<p class="panel-empty-state-title">Loading Sessions</p>
|
||||||
|
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
</Show>
|
||||||
<p class="panel-empty-state-description">Create a new session below to get started</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">
|
||||||
<h2 class="panel-title">Resume Session</h2>
|
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
||||||
<p class="panel-subtitle">
|
<div>
|
||||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
<h2 class="panel-title">Resume Session</h2>
|
||||||
</p>
|
<p class="panel-subtitle">
|
||||||
|
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tertiary lg:hidden flex-shrink-0"
|
||||||
|
onClick={openInstanceInfoOverlay}
|
||||||
|
>
|
||||||
|
View Instance Info
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
|
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
|
||||||
<For each={parentSessions()}>
|
<For each={parentSessions()}>
|
||||||
{(session, index) => (
|
{(session, index) => {
|
||||||
<div
|
const isFocused = () => focusMode() === "sessions" && selectedIndex() === index()
|
||||||
class="panel-list-item"
|
return (
|
||||||
classList={{
|
<div
|
||||||
"panel-list-item-highlight": focusMode() === "sessions" && selectedIndex() === index(),
|
class="panel-list-item"
|
||||||
}}
|
classList={{
|
||||||
>
|
"panel-list-item-highlight": isFocused(),
|
||||||
<button
|
|
||||||
data-session-index={index()}
|
|
||||||
class="panel-list-item-content group w-full"
|
|
||||||
onClick={() => handleSessionSelect(session.id)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setFocusMode("sessions")
|
|
||||||
setSelectedIndex(index())
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3 w-full">
|
<div class="flex items-center gap-2 w-full px-1">
|
||||||
<div class="flex-1 min-w-0">
|
<button
|
||||||
<div class="flex items-center gap-2">
|
type="button"
|
||||||
<span
|
data-session-index={index()}
|
||||||
class="text-sm font-medium text-primary truncate transition-colors"
|
class="panel-list-item-content group flex-1"
|
||||||
classList={{
|
onClick={() => handleSessionSelect(session.id)}
|
||||||
"text-accent":
|
onMouseEnter={() => {
|
||||||
focusMode() === "sessions" && selectedIndex() === index(),
|
setFocusMode("sessions")
|
||||||
|
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">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-primary truncate transition-colors"
|
||||||
|
classList={{
|
||||||
|
"text-accent": isFocused(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.title || "Untitled Session"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||||
|
<span>{session.agent}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatRelativeTime(session.time.updated)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<Show when={isFocused()}>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
title="Delete session"
|
||||||
|
disabled={isSessionDeleting(session.id)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
void handleSessionDelete(session.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{session.title || "Untitled Session"}
|
<Show
|
||||||
</span>
|
when={!isSessionDeleting(session.id)}
|
||||||
|
fallback={
|
||||||
|
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
|
||||||
<span>{session.agent}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{formatRelativeTime(session.time.updated)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={focusMode() === "sessions" && selectedIndex() === index()}>
|
|
||||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,14 +430,38 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:w-80 flex-shrink-0">
|
<div class="hidden lg:block lg:w-80 flex-shrink-0">
|
||||||
<div class="sticky top-0">
|
<div class="sticky top-0">
|
||||||
<InstanceInfo instance={props.instance} />
|
<InstanceInfo instance={props.instance} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-footer">
|
<Show when={!isDesktopLayout() && showInstanceInfoOverlay()}>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||||
|
onClick={closeInstanceInfoOverlay}
|
||||||
|
>
|
||||||
|
<div class="flex min-h-full items-start justify-center p-4 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-md space-y-3"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
||||||
|
<InstanceInfo instance={props.instance} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="panel-footer hidden sm:block">
|
||||||
|
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
@@ -302,12 +482,16 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Resume</span>
|
<span>Resume</span>
|
||||||
</div>
|
</div>
|
||||||
<KeyboardHint shortcuts={[newSessionShortcut()]} separator="" />
|
<div class="flex items-center gap-1.5">
|
||||||
|
<kbd class="kbd">Del</kbd>
|
||||||
|
<span>Delete</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default InstanceWelcomeView
|
||||||
|
|
||||||
export default InstanceWelcomeView
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Show, createMemo, createSignal, type Component } from "solid-js"
|
import { Show, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { Instance } from "../../types/instance"
|
import type { Instance } from "../../types/instance"
|
||||||
import type { Command } from "../../lib/commands"
|
import type { Command } from "../../lib/commands"
|
||||||
@@ -14,8 +14,12 @@ import InfoView from "../info-view"
|
|||||||
import AgentSelector from "../agent-selector"
|
import AgentSelector from "../agent-selector"
|
||||||
import ModelSelector from "../model-selector"
|
import ModelSelector from "../model-selector"
|
||||||
import CommandPalette from "../command-palette"
|
import CommandPalette from "../command-palette"
|
||||||
|
import Kbd from "../kbd"
|
||||||
import ContextUsagePanel from "../session/context-usage-panel"
|
import ContextUsagePanel from "../session/context-usage-panel"
|
||||||
import SessionView from "../session/session-view"
|
import SessionView from "../session/session-view"
|
||||||
|
import { getLogger } from "../../lib/logger"
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
interface InstanceShellProps {
|
interface InstanceShellProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
@@ -28,10 +32,39 @@ interface InstanceShellProps {
|
|||||||
onExecuteCommand: (command: Command) => void
|
onExecuteCommand: (command: Command) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
||||||
|
const MOBILE_SIDEBAR_BREAKPOINT = 1024
|
||||||
|
|
||||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||||
|
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||||
|
const sidebarId = `session-sidebar-${props.instance.id}`
|
||||||
|
let previousIsCompact = false
|
||||||
|
|
||||||
|
const shouldShowSidebarToggle = () => isCompactLayout() && !isSidebarOpen()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
const compact = window.innerWidth < MOBILE_SIDEBAR_BREAKPOINT
|
||||||
|
setIsCompactLayout(compact)
|
||||||
|
if (!compact) {
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
} else if (!previousIsCompact && compact) {
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
}
|
||||||
|
previousIsCompact = compact
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize()
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("resize", handleResize)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const activeSessions = createMemo(() => {
|
const activeSessions = createMemo(() => {
|
||||||
const parentId = activeParentSessionId().get(props.instance.id)
|
const parentId = activeParentSessionId().get(props.instance.id)
|
||||||
@@ -67,8 +100,20 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||||
<div class="flex flex-1 min-h-0">
|
<div
|
||||||
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
|
class="flex flex-1 min-h-0 relative"
|
||||||
|
classList={{ "session-layout-compact": isCompactLayout() }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id={sidebarId}
|
||||||
|
class="session-sidebar flex flex-col bg-surface-secondary"
|
||||||
|
classList={{
|
||||||
|
"session-sidebar-overlay": isCompactLayout(),
|
||||||
|
"session-sidebar-collapsed": isCompactLayout() && !isSidebarOpen(),
|
||||||
|
}}
|
||||||
|
style={!isCompactLayout() ? { width: `${sessionSidebarWidth()}px` } : undefined}
|
||||||
|
aria-hidden={isCompactLayout() && !isSidebarOpen()}
|
||||||
|
>
|
||||||
<SessionList
|
<SessionList
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
sessions={activeSessions()}
|
sessions={activeSessions()}
|
||||||
@@ -77,20 +122,32 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
onClose={(id) => {
|
onClose={(id) => {
|
||||||
const result = props.onCloseSession(id)
|
const result = props.onCloseSession(id)
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
void result.catch((error) => console.error("Failed to close session:", error))
|
void result.catch((error) => log.error("Failed to close session:", error))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onNew={() => {
|
onNew={() => {
|
||||||
const result = props.onNewSession()
|
const result = props.onNewSession()
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
void result.catch((error) => console.error("Failed to create session:", error))
|
void result.catch((error) => log.error("Failed to create session:", error))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
showHeader
|
showHeader
|
||||||
showFooter={false}
|
showFooter={false}
|
||||||
headerContent={
|
headerContent={
|
||||||
<div class="session-sidebar-header">
|
<div class="session-sidebar-header">
|
||||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
<div class="session-sidebar-header-row">
|
||||||
|
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||||
|
<Show when={isCompactLayout()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-sidebar-close"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
aria-label="Close session sidebar"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
<div class="session-sidebar-shortcuts">
|
<div class="session-sidebar-shortcuts">
|
||||||
{keyboardShortcuts().length ? (
|
{keyboardShortcuts().length ? (
|
||||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||||
@@ -114,12 +171,22 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="sidebar-selector-hints" aria-hidden="true">
|
||||||
|
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
||||||
|
<Kbd shortcut="cmd+shift+a" />
|
||||||
|
</span>
|
||||||
|
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
||||||
|
<Kbd shortcut="cmd+shift+m" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
sessionId={activeSession().id}
|
sessionId={activeSession().id}
|
||||||
currentModel={activeSession().model}
|
currentModel={activeSession().model}
|
||||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -127,6 +194,20 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
|
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||||
|
<Show
|
||||||
|
when={shouldShowSidebarToggle() && (!activeSessionIdForInstance() || activeSessionIdForInstance() === "info")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-sidebar-menu-button session-sidebar-menu-button--floating"
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
aria-controls={sidebarId}
|
||||||
|
aria-expanded={isSidebarOpen()}
|
||||||
|
aria-label="Open session list"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={activeSessionIdForInstance() === "info"}
|
when={activeSessionIdForInstance() === "info"}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -149,6 +230,9 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
instanceFolder={props.instance.folder}
|
instanceFolder={props.instance.folder}
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
|
showSidebarToggle={shouldShowSidebarToggle()}
|
||||||
|
onSidebarToggle={() => setIsSidebarOpen(true)}
|
||||||
|
forceCompactStatusLayout={shouldShowSidebarToggle()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -157,6 +241,15 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
<InfoView instanceId={props.instance.id} />
|
<InfoView instanceId={props.instance.id} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={isCompactLayout() && isSidebarOpen()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-sidebar-backdrop"
|
||||||
|
aria-label="Close session sidebar"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||||
import type { TextPart } from "../types/message"
|
import type { TextPart } from "../types/message"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
part: TextPart
|
part: TextPart
|
||||||
@@ -29,7 +32,8 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
|
|
||||||
latestRequestedText = text
|
latestRequestedText = text
|
||||||
|
|
||||||
await initMarkdown(dark)
|
// Markdown initialization is now handled globally in App.
|
||||||
|
// initMarkdown is idempotent but we avoid per-part calls here.
|
||||||
|
|
||||||
if (!highlightEnabled) {
|
if (!highlightEnabled) {
|
||||||
part.renderCache = undefined
|
part.renderCache = undefined
|
||||||
@@ -42,7 +46,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to render markdown:", error)
|
log.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
setHtml(text)
|
setHtml(text)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
@@ -67,7 +71,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to render markdown:", error)
|
log.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
setHtml(text)
|
setHtml(text)
|
||||||
part.renderCache = { text, html: text, theme: themeKey }
|
part.renderCache = { text, html: text, theme: themeKey }
|
||||||
@@ -123,7 +127,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to re-render markdown after language load:", error)
|
log.error("Failed to re-render markdown after language load:", error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
105
packages/ui/src/components/message-block-list.tsx
Normal file
105
packages/ui/src/components/message-block-list.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Index, createEffect, createSignal, type Accessor } from "solid-js"
|
||||||
|
import VirtualItem from "./virtual-item"
|
||||||
|
import MessageBlock from "./message-block"
|
||||||
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
|
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||||
|
const ESTIMATED_MESSAGE_HEIGHT = 320
|
||||||
|
const INITIAL_FORCE_MIN_ITEMS = 12
|
||||||
|
const INITIAL_FORCE_OVERSCAN = 6
|
||||||
|
|
||||||
|
interface MessageBlockListProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
store: () => InstanceMessageStore
|
||||||
|
messageIds: () => string[]
|
||||||
|
messageIndexMap: () => Map<string, number>
|
||||||
|
lastAssistantIndex: () => number
|
||||||
|
showThinking: () => boolean
|
||||||
|
thinkingDefaultExpanded: () => boolean
|
||||||
|
showUsageMetrics: () => boolean
|
||||||
|
scrollContainer: Accessor<HTMLDivElement | undefined>
|
||||||
|
loading?: boolean
|
||||||
|
onRevert?: (messageId: string) => void
|
||||||
|
onFork?: (messageId?: string) => void
|
||||||
|
onContentRendered?: () => void
|
||||||
|
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||||
|
const [initialForceActive, setInitialForceActive] = createSignal(true)
|
||||||
|
const [initialForceInitialized, setInitialForceInitialized] = createSignal(false)
|
||||||
|
const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0)
|
||||||
|
const [, setInitialForceRemaining] = createSignal(0)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.instanceId
|
||||||
|
props.sessionId
|
||||||
|
setInitialForceActive(true)
|
||||||
|
setInitialForceInitialized(false)
|
||||||
|
setInitialForceStartIndex(0)
|
||||||
|
setInitialForceRemaining(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!initialForceActive() || initialForceInitialized()) return
|
||||||
|
const ids = props.messageIds()
|
||||||
|
if (ids.length === 0) return
|
||||||
|
const viewportHeight = props.scrollContainer()?.clientHeight ?? (typeof window !== "undefined" ? window.innerHeight : 800)
|
||||||
|
const estimatedCount = Math.min(
|
||||||
|
ids.length,
|
||||||
|
Math.max(INITIAL_FORCE_MIN_ITEMS, Math.ceil(viewportHeight / ESTIMATED_MESSAGE_HEIGHT) + INITIAL_FORCE_OVERSCAN),
|
||||||
|
)
|
||||||
|
setInitialForceStartIndex(Math.max(0, ids.length - estimatedCount))
|
||||||
|
setInitialForceRemaining(estimatedCount)
|
||||||
|
setInitialForceInitialized(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Index each={props.messageIds()}>
|
||||||
|
{(messageId) => {
|
||||||
|
const messageIndex = () => props.messageIndexMap().get(messageId()) ?? 0
|
||||||
|
const forceVisible = () => initialForceActive() && messageIndex() >= initialForceStartIndex()
|
||||||
|
const handleMeasured = () => {
|
||||||
|
if (!forceVisible()) return
|
||||||
|
setInitialForceRemaining((value) => {
|
||||||
|
const next = value > 0 ? value - 1 : 0
|
||||||
|
if (next === 0) {
|
||||||
|
setInitialForceActive(false)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<VirtualItem
|
||||||
|
cacheKey={messageId()}
|
||||||
|
scrollContainer={props.scrollContainer}
|
||||||
|
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||||
|
placeholderClass="message-stream-placeholder"
|
||||||
|
virtualizationEnabled={() => !props.loading}
|
||||||
|
forceVisible={forceVisible}
|
||||||
|
onMeasured={handleMeasured}
|
||||||
|
>
|
||||||
|
<MessageBlock
|
||||||
|
messageId={messageId()}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={props.store}
|
||||||
|
messageIndexMap={props.messageIndexMap}
|
||||||
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
|
showThinking={props.showThinking}
|
||||||
|
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||||
|
showUsageMetrics={props.showUsageMetrics}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
</VirtualItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Index>
|
||||||
|
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
718
packages/ui/src/components/message-block.tsx
Normal file
718
packages/ui/src/components/message-block.tsx
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
|
import MessageItem from "./message-item"
|
||||||
|
import ToolCall from "./tool-call"
|
||||||
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
import type { ClientPart, MessageInfo } from "../types/message"
|
||||||
|
import { partHasRenderableText } from "../types/message"
|
||||||
|
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||||
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
|
|
||||||
|
const TOOL_ICON = "🔧"
|
||||||
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
|
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||||
|
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||||
|
|
||||||
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
|
|
||||||
|
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||||
|
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||||
|
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||||
|
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||||
|
|
||||||
|
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
||||||
|
return Boolean(state && state.status === "running")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted {
|
||||||
|
return Boolean(state && state.status === "completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolStateError(state: ToolState | undefined): state is ToolStateError {
|
||||||
|
return Boolean(state && state.status === "error")
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTaskSessionId(state: ToolState | undefined): string {
|
||||||
|
if (!state) return ""
|
||||||
|
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
|
||||||
|
const directId = metadata?.sessionId ?? metadata?.sessionID
|
||||||
|
return typeof directId === "string" ? directId : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningHasRenderableContent(part: ClientPart): boolean {
|
||||||
|
if (!part || part.type !== "reasoning") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const checkSegment = (segment: unknown): boolean => {
|
||||||
|
if (typeof segment === "string") {
|
||||||
|
return segment.trim().length > 0
|
||||||
|
}
|
||||||
|
if (segment && typeof segment === "object") {
|
||||||
|
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||||
|
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (Array.isArray(candidate.content)) {
|
||||||
|
return candidate.content.some((entry) => checkSegment(entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkSegment((part as any).text)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (Array.isArray((part as any).content)) {
|
||||||
|
return (part as any).content.some((entry: unknown) => checkSegment(entry))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskSessionLocation {
|
||||||
|
sessionId: string
|
||||||
|
instanceId: string
|
||||||
|
parentId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
|
||||||
|
if (!sessionId) return null
|
||||||
|
const allSessions = sessions()
|
||||||
|
for (const [instanceId, sessionMap] of allSessions) {
|
||||||
|
const session = sessionMap?.get(sessionId)
|
||||||
|
if (session) {
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
instanceId,
|
||||||
|
parentId: session.parentId ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||||
|
setActiveInstanceId(location.instanceId)
|
||||||
|
const parentToActivate = location.parentId ?? location.sessionId
|
||||||
|
setActiveParentSession(location.instanceId, parentToActivate)
|
||||||
|
if (location.parentId) {
|
||||||
|
setActiveSession(location.instanceId, location.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedBlockEntry {
|
||||||
|
signature: string
|
||||||
|
block: MessageDisplayBlock
|
||||||
|
contentKeys: string[]
|
||||||
|
toolKeys: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionRenderCache {
|
||||||
|
messageItems: Map<string, ContentDisplayItem>
|
||||||
|
toolItems: Map<string, ToolDisplayItem>
|
||||||
|
messageBlocks: Map<string, CachedBlockEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCaches = new Map<string, SessionRenderCache>()
|
||||||
|
|
||||||
|
function makeSessionCacheKey(instanceId: string, sessionId: string) {
|
||||||
|
return `${instanceId}:${sessionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
|
||||||
|
const key = makeSessionCacheKey(instanceId, sessionId)
|
||||||
|
let cache = renderCaches.get(key)
|
||||||
|
if (!cache) {
|
||||||
|
cache = {
|
||||||
|
messageItems: new Map(),
|
||||||
|
toolItems: new Map(),
|
||||||
|
messageBlocks: new Map(),
|
||||||
|
}
|
||||||
|
renderCaches.set(key, cache)
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInstanceCaches(instanceId: string) {
|
||||||
|
clearRecordDisplayCacheForInstance(instanceId)
|
||||||
|
const prefix = `${instanceId}:`
|
||||||
|
for (const key of renderCaches.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
renderCaches.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
||||||
|
|
||||||
|
interface ContentDisplayItem {
|
||||||
|
type: "content"
|
||||||
|
key: string
|
||||||
|
record: MessageRecord
|
||||||
|
parts: ClientPart[]
|
||||||
|
messageInfo?: MessageInfo
|
||||||
|
isQueued: boolean
|
||||||
|
showAgentMeta?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolDisplayItem {
|
||||||
|
type: "tool"
|
||||||
|
key: string
|
||||||
|
toolPart: ToolCallPart
|
||||||
|
messageInfo?: MessageInfo
|
||||||
|
messageId: string
|
||||||
|
messageVersion: number
|
||||||
|
partVersion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepDisplayItem {
|
||||||
|
type: "step-start" | "step-finish"
|
||||||
|
key: string
|
||||||
|
part: ClientPart
|
||||||
|
messageInfo?: MessageInfo
|
||||||
|
accentColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReasoningDisplayItem = {
|
||||||
|
type: "reasoning"
|
||||||
|
key: string
|
||||||
|
part: ClientPart
|
||||||
|
messageInfo?: MessageInfo
|
||||||
|
showAgentMeta?: boolean
|
||||||
|
defaultExpanded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem
|
||||||
|
|
||||||
|
interface MessageDisplayBlock {
|
||||||
|
record: MessageRecord
|
||||||
|
items: MessageBlockItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageBlockProps {
|
||||||
|
messageId: string
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
store: () => InstanceMessageStore
|
||||||
|
messageIndexMap: () => Map<string, number>
|
||||||
|
lastAssistantIndex: () => number
|
||||||
|
showThinking: () => boolean
|
||||||
|
thinkingDefaultExpanded: () => boolean
|
||||||
|
showUsageMetrics: () => boolean
|
||||||
|
onRevert?: (messageId: string) => void
|
||||||
|
onFork?: (messageId?: string) => void
|
||||||
|
onContentRendered?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBlock(props: MessageBlockProps) {
|
||||||
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
|
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||||
|
|
||||||
|
const block = createMemo<MessageDisplayBlock | null>(() => {
|
||||||
|
const current = record()
|
||||||
|
if (!current) return null
|
||||||
|
|
||||||
|
const index = props.messageIndexMap().get(current.id) ?? 0
|
||||||
|
const lastAssistantIdx = props.lastAssistantIndex()
|
||||||
|
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||||
|
const info = messageInfo()
|
||||||
|
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
||||||
|
const infoTimestamp =
|
||||||
|
typeof infoTime.completed === "number"
|
||||||
|
? infoTime.completed
|
||||||
|
: typeof infoTime.updated === "number"
|
||||||
|
? infoTime.updated
|
||||||
|
: infoTime.created ?? 0
|
||||||
|
const infoError = (info as { error?: { name?: string } } | undefined)?.error
|
||||||
|
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
|
||||||
|
const cacheSignature = [
|
||||||
|
current.id,
|
||||||
|
current.revision,
|
||||||
|
isQueued ? 1 : 0,
|
||||||
|
props.showThinking() ? 1 : 0,
|
||||||
|
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||||
|
props.showUsageMetrics() ? 1 : 0,
|
||||||
|
infoTimestamp,
|
||||||
|
infoErrorName,
|
||||||
|
].join("|")
|
||||||
|
|
||||||
|
const cachedBlock = sessionCache.messageBlocks.get(current.id)
|
||||||
|
if (cachedBlock && cachedBlock.signature === cacheSignature) {
|
||||||
|
return cachedBlock.block
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
||||||
|
const items: MessageBlockItem[] = []
|
||||||
|
const blockContentKeys: string[] = []
|
||||||
|
const blockToolKeys: string[] = []
|
||||||
|
let segmentIndex = 0
|
||||||
|
let pendingParts: ClientPart[] = []
|
||||||
|
let agentMetaAttached = current.role !== "assistant"
|
||||||
|
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
|
||||||
|
let lastAccentColor = defaultAccentColor
|
||||||
|
|
||||||
|
const flushContent = () => {
|
||||||
|
if (pendingParts.length === 0) return
|
||||||
|
const segmentKey = `${current.id}:segment:${segmentIndex}`
|
||||||
|
segmentIndex += 1
|
||||||
|
const shouldShowAgentMeta =
|
||||||
|
current.role === "assistant" &&
|
||||||
|
!agentMetaAttached &&
|
||||||
|
pendingParts.some((part) => partHasRenderableText(part))
|
||||||
|
let cached = sessionCache.messageItems.get(segmentKey)
|
||||||
|
if (!cached) {
|
||||||
|
cached = {
|
||||||
|
type: "content",
|
||||||
|
key: segmentKey,
|
||||||
|
record: current,
|
||||||
|
parts: pendingParts.slice(),
|
||||||
|
messageInfo: info,
|
||||||
|
isQueued,
|
||||||
|
showAgentMeta: shouldShowAgentMeta,
|
||||||
|
}
|
||||||
|
sessionCache.messageItems.set(segmentKey, cached)
|
||||||
|
} else {
|
||||||
|
cached.record = current
|
||||||
|
cached.parts = pendingParts.slice()
|
||||||
|
cached.messageInfo = info
|
||||||
|
cached.isQueued = isQueued
|
||||||
|
cached.showAgentMeta = shouldShowAgentMeta
|
||||||
|
}
|
||||||
|
if (shouldShowAgentMeta) {
|
||||||
|
agentMetaAttached = true
|
||||||
|
}
|
||||||
|
items.push(cached)
|
||||||
|
blockContentKeys.push(segmentKey)
|
||||||
|
lastAccentColor = defaultAccentColor
|
||||||
|
pendingParts = []
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedParts.forEach((part, partIndex) => {
|
||||||
|
if (part.type === "tool") {
|
||||||
|
flushContent()
|
||||||
|
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
|
||||||
|
const messageVersion = current.revision
|
||||||
|
const key = `${current.id}:${part.id ?? partIndex}`
|
||||||
|
let toolItem = sessionCache.toolItems.get(key)
|
||||||
|
if (!toolItem) {
|
||||||
|
toolItem = {
|
||||||
|
type: "tool",
|
||||||
|
key,
|
||||||
|
toolPart: part as ToolCallPart,
|
||||||
|
messageInfo: info,
|
||||||
|
messageId: current.id,
|
||||||
|
messageVersion,
|
||||||
|
partVersion,
|
||||||
|
}
|
||||||
|
sessionCache.toolItems.set(key, toolItem)
|
||||||
|
} else {
|
||||||
|
toolItem.key = key
|
||||||
|
toolItem.toolPart = part as ToolCallPart
|
||||||
|
toolItem.messageInfo = info
|
||||||
|
toolItem.messageId = current.id
|
||||||
|
toolItem.messageVersion = messageVersion
|
||||||
|
toolItem.partVersion = partVersion
|
||||||
|
}
|
||||||
|
items.push(toolItem)
|
||||||
|
blockToolKeys.push(key)
|
||||||
|
lastAccentColor = TOOL_BORDER_COLOR
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "step-start") {
|
||||||
|
flushContent()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "step-finish") {
|
||||||
|
flushContent()
|
||||||
|
if (props.showUsageMetrics()) {
|
||||||
|
const key = `${current.id}:${part.id ?? partIndex}:${part.type}`
|
||||||
|
const accentColor = lastAccentColor || defaultAccentColor
|
||||||
|
items.push({ type: part.type, key, part, messageInfo: info, accentColor })
|
||||||
|
lastAccentColor = accentColor
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
flushContent()
|
||||||
|
if (props.showThinking() && reasoningHasRenderableContent(part)) {
|
||||||
|
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
|
||||||
|
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
|
||||||
|
if (showAgentMeta) {
|
||||||
|
agentMetaAttached = true
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
type: "reasoning",
|
||||||
|
key,
|
||||||
|
part,
|
||||||
|
messageInfo: info,
|
||||||
|
showAgentMeta,
|
||||||
|
defaultExpanded: props.thinkingDefaultExpanded(),
|
||||||
|
})
|
||||||
|
lastAccentColor = ASSISTANT_BORDER_COLOR
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingParts.push(part)
|
||||||
|
})
|
||||||
|
|
||||||
|
flushContent()
|
||||||
|
|
||||||
|
const resultBlock: MessageDisplayBlock = { record: current, items }
|
||||||
|
sessionCache.messageBlocks.set(current.id, {
|
||||||
|
signature: cacheSignature,
|
||||||
|
block: resultBlock,
|
||||||
|
contentKeys: blockContentKeys.slice(),
|
||||||
|
toolKeys: blockToolKeys.slice(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const messagePrefix = `${current.id}:`
|
||||||
|
for (const [key] of sessionCache.messageItems) {
|
||||||
|
if (key.startsWith(messagePrefix) && !blockContentKeys.includes(key)) {
|
||||||
|
sessionCache.messageItems.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key] of sessionCache.toolItems) {
|
||||||
|
if (key.startsWith(messagePrefix) && !blockToolKeys.includes(key)) {
|
||||||
|
sessionCache.toolItems.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultBlock
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={block()} keyed>
|
||||||
|
{(resolvedBlock) => (
|
||||||
|
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
|
||||||
|
<For each={resolvedBlock.items}>
|
||||||
|
{(item) => (
|
||||||
|
<Switch>
|
||||||
|
<Match when={item.type === "content"}>
|
||||||
|
<MessageItem
|
||||||
|
record={(item as ContentDisplayItem).record}
|
||||||
|
messageInfo={(item as ContentDisplayItem).messageInfo}
|
||||||
|
parts={(item as ContentDisplayItem).parts}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
isQueued={(item as ContentDisplayItem).isQueued}
|
||||||
|
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={item.type === "tool"}>
|
||||||
|
{(() => {
|
||||||
|
const toolItem = item as ToolDisplayItem
|
||||||
|
const toolState = toolItem.toolPart.state as ToolState | undefined
|
||||||
|
const hasToolState =
|
||||||
|
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||||
|
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||||
|
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||||
|
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!taskLocation) return
|
||||||
|
navigateToTaskSession(taskLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="tool-call-message" data-key={toolItem.key}>
|
||||||
|
<div class="tool-call-header-label">
|
||||||
|
<div class="tool-call-header-meta">
|
||||||
|
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||||
|
<span>Tool Call</span>
|
||||||
|
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={taskSessionId}>
|
||||||
|
<button
|
||||||
|
class="tool-call-header-button"
|
||||||
|
type="button"
|
||||||
|
disabled={!taskLocation}
|
||||||
|
onClick={handleGoToTaskSession}
|
||||||
|
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
||||||
|
>
|
||||||
|
Go to Session
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<ToolCall
|
||||||
|
toolCall={toolItem.toolPart}
|
||||||
|
toolCallId={toolItem.key}
|
||||||
|
messageId={toolItem.messageId}
|
||||||
|
messageVersion={toolItem.messageVersion}
|
||||||
|
partVersion={toolItem.partVersion}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Match>
|
||||||
|
<Match when={item.type === "step-start"}>
|
||||||
|
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
|
||||||
|
</Match>
|
||||||
|
<Match when={item.type === "step-finish"}>
|
||||||
|
<StepCard
|
||||||
|
kind="finish"
|
||||||
|
part={(item as StepDisplayItem).part}
|
||||||
|
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||||
|
showUsage={props.showUsageMetrics()}
|
||||||
|
borderColor={(item as StepDisplayItem).accentColor}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={item.type === "reasoning"}>
|
||||||
|
<ReasoningCard
|
||||||
|
part={(item as ReasoningDisplayItem).part}
|
||||||
|
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||||
|
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepCardProps {
|
||||||
|
kind: "start" | "finish"
|
||||||
|
part: ClientPart
|
||||||
|
messageInfo?: MessageInfo
|
||||||
|
showAgentMeta?: boolean
|
||||||
|
showUsage?: boolean
|
||||||
|
borderColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepCard(props: StepCardProps) {
|
||||||
|
const timestamp = () => {
|
||||||
|
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentIdentifier = () => {
|
||||||
|
if (!props.showAgentMeta) return ""
|
||||||
|
const info = props.messageInfo
|
||||||
|
if (!info || info.role !== "assistant") return ""
|
||||||
|
return info.mode || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelIdentifier = () => {
|
||||||
|
if (!props.showAgentMeta) return ""
|
||||||
|
const info = props.messageInfo
|
||||||
|
if (!info || info.role !== "assistant") return ""
|
||||||
|
const modelID = info.modelID || ""
|
||||||
|
const providerID = info.providerID || ""
|
||||||
|
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||||
|
return modelID
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageStats = () => {
|
||||||
|
if (props.kind !== "finish" || !props.showUsage) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const info = props.messageInfo
|
||||||
|
if (!info || info.role !== "assistant" || !info.tokens) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const tokens = info.tokens
|
||||||
|
return {
|
||||||
|
input: tokens.input ?? 0,
|
||||||
|
output: tokens.output ?? 0,
|
||||||
|
reasoning: tokens.reasoning ?? 0,
|
||||||
|
cacheRead: tokens.cache?.read ?? 0,
|
||||||
|
cacheWrite: tokens.cache?.write ?? 0,
|
||||||
|
cost: info.cost ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
|
||||||
|
|
||||||
|
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||||
|
const entries = [
|
||||||
|
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
|
||||||
|
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
|
||||||
|
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
|
||||||
|
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
|
||||||
|
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
|
||||||
|
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-step-usage">
|
||||||
|
<For each={entries}>
|
||||||
|
{(entry) => (
|
||||||
|
<span class="message-step-usage-chip" data-label={entry.label}>
|
||||||
|
{entry.formatter(entry.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.kind === "finish") {
|
||||||
|
const usage = usageStats()
|
||||||
|
if (!usage) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
|
||||||
|
{renderUsageChips(usage)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`message-step-card message-step-start`}>
|
||||||
|
<div class="message-step-heading">
|
||||||
|
<div class="message-step-title">
|
||||||
|
<div class="message-step-title-left">
|
||||||
|
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||||
|
<span class="message-step-meta-inline">
|
||||||
|
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
||||||
|
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<span class="message-step-time">{timestamp()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCostValue(value: number) {
|
||||||
|
if (!value) return "$0.00"
|
||||||
|
if (value < 0.01) return `$${value.toPrecision(2)}`
|
||||||
|
return `$${value.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReasoningCardProps {
|
||||||
|
part: ClientPart
|
||||||
|
messageInfo?: MessageInfo
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
showAgentMeta?: boolean
|
||||||
|
defaultExpanded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasoningCard(props: ReasoningCardProps) {
|
||||||
|
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
|
})
|
||||||
|
|
||||||
|
const timestamp = () => {
|
||||||
|
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||||
|
const date = new Date(value)
|
||||||
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentIdentifier = () => {
|
||||||
|
const info = props.messageInfo
|
||||||
|
if (!info || info.role !== "assistant") return ""
|
||||||
|
return info.mode || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelIdentifier = () => {
|
||||||
|
const info = props.messageInfo
|
||||||
|
if (!info || info.role !== "assistant") return ""
|
||||||
|
const modelID = info.modelID || ""
|
||||||
|
const providerID = info.providerID || ""
|
||||||
|
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||||
|
return modelID
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasoningText = () => {
|
||||||
|
const part = props.part as any
|
||||||
|
if (!part) return ""
|
||||||
|
|
||||||
|
const stringifySegment = (segment: unknown): string => {
|
||||||
|
if (typeof segment === "string") {
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
if (segment && typeof segment === "object") {
|
||||||
|
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||||
|
const pieces: string[] = []
|
||||||
|
if (typeof obj.text === "string") {
|
||||||
|
pieces.push(obj.text)
|
||||||
|
}
|
||||||
|
if (typeof obj.value === "string") {
|
||||||
|
pieces.push(obj.value)
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj.content)) {
|
||||||
|
pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
|
||||||
|
}
|
||||||
|
return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const textValue = stringifySegment(part.text)
|
||||||
|
if (textValue.trim().length > 0) {
|
||||||
|
return textValue
|
||||||
|
}
|
||||||
|
if (Array.isArray(part.content)) {
|
||||||
|
return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = () => setExpanded((prev) => !prev)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-reasoning-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-reasoning-toggle"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-expanded={expanded()}
|
||||||
|
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}
|
||||||
|
>
|
||||||
|
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||||
|
<span>Thinking</span>
|
||||||
|
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||||
|
<span class="message-step-meta-inline">
|
||||||
|
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
|
||||||
|
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
<span class="message-reasoning-meta">
|
||||||
|
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span>
|
||||||
|
<span class="message-reasoning-time">{timestamp()}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<div class="message-reasoning-expanded">
|
||||||
|
<div class="message-reasoning-body">
|
||||||
|
<div class="message-reasoning-output" role="region" aria-label="Reasoning details">
|
||||||
|
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,27 +1,108 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
import type { MessageInfo, ClientPart } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
message: Message
|
record: MessageRecord
|
||||||
messageInfo?: MessageInfo
|
messageInfo?: MessageInfo
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
isQueued?: boolean
|
isQueued?: boolean
|
||||||
parts?: ClientPart[]
|
parts: ClientPart[]
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
}
|
showAgentMeta?: boolean
|
||||||
|
onContentRendered?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
|
|
||||||
|
const isUser = () => props.record.role === "user"
|
||||||
|
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
|
||||||
const isUser = () => props.message.type === "user"
|
|
||||||
const timestamp = () => {
|
const timestamp = () => {
|
||||||
const date = new Date(props.message.timestamp)
|
const date = new Date(createdTimestamp())
|
||||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageParts = () => props.parts ?? props.message.parts
|
const timestampIso = () => new Date(createdTimestamp()).toISOString()
|
||||||
|
|
||||||
|
type FilePart = Extract<ClientPart, { type: "file" }> & {
|
||||||
|
url?: string
|
||||||
|
mime?: string
|
||||||
|
filename?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageParts = () => props.parts
|
||||||
|
|
||||||
|
const fileAttachments = () =>
|
||||||
|
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||||
|
|
||||||
|
|
||||||
|
const getAttachmentName = (part: FilePart) => {
|
||||||
|
if (part.filename && part.filename.trim().length > 0) {
|
||||||
|
return part.filename
|
||||||
|
}
|
||||||
|
const url = part.url || ""
|
||||||
|
if (url.startsWith("data:")) {
|
||||||
|
return "attachment"
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const segments = parsed.pathname.split("/")
|
||||||
|
return segments.pop() || "attachment"
|
||||||
|
} catch (error) {
|
||||||
|
const fallback = url.split("/").pop()
|
||||||
|
return fallback && fallback.length > 0 ? fallback : "attachment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isImageAttachment = (part: FilePart) => {
|
||||||
|
if (part.mime && typeof part.mime === "string" && part.mime.startsWith("image/")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return typeof part.url === "string" && part.url.startsWith("data:image/")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAttachmentDownload = async (part: FilePart) => {
|
||||||
|
const url = part.url
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
const filename = getAttachmentName(part)
|
||||||
|
const directDownload = (href: string) => {
|
||||||
|
const anchor = document.createElement("a")
|
||||||
|
anchor.href = href
|
||||||
|
anchor.download = filename
|
||||||
|
anchor.target = "_blank"
|
||||||
|
anchor.rel = "noopener"
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
document.body.removeChild(anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("data:")) {
|
||||||
|
directDownload(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("file://")) {
|
||||||
|
window.open(url, "_blank", "noopener")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch attachment: ${response.status}`)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
directDownload(objectUrl)
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
} catch (error) {
|
||||||
|
directDownload(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = () => {
|
const errorMessage = () => {
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
@@ -58,15 +139,21 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
const handleRevert = () => {
|
const handleRevert = () => {
|
||||||
if (props.onRevert && isUser()) {
|
if (props.onRevert && isUser()) {
|
||||||
props.onRevert(props.message.id)
|
props.onRevert(props.record.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isUser() && !hasContent()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const containerClass = () =>
|
const containerClass = () =>
|
||||||
isUser()
|
isUser()
|
||||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
||||||
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||||
|
|
||||||
|
const speakerLabel = () => (isUser() ? "You" : "Assistant")
|
||||||
|
|
||||||
const agentIdentifier = () => {
|
const agentIdentifier = () => {
|
||||||
if (isUser()) return ""
|
if (isUser()) return ""
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
@@ -83,50 +170,64 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||||
return modelID
|
return modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentMeta = () => {
|
||||||
|
if (isUser() || !props.showAgentMeta) return ""
|
||||||
|
const segments: string[] = []
|
||||||
|
const agent = agentIdentifier()
|
||||||
|
const model = modelIdentifier()
|
||||||
|
if (agent) {
|
||||||
|
segments.push(`Agent: ${agent}`)
|
||||||
|
}
|
||||||
|
if (model) {
|
||||||
|
segments.push(`Model: ${model}`)
|
||||||
|
}
|
||||||
|
return segments.join(" • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div class={containerClass()}>
|
<div class={containerClass()}>
|
||||||
<div class="flex justify-between items-center gap-2.5 pb-0.5">
|
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||||
<div class="flex flex-col">
|
<div class="message-speaker">
|
||||||
|
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||||
|
{speakerLabel()}
|
||||||
|
</span>
|
||||||
|
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
|
||||||
|
</div>
|
||||||
|
<div class="message-item-actions">
|
||||||
<Show when={isUser()}>
|
<Show when={isUser()}>
|
||||||
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
|
<div class="message-action-group">
|
||||||
</Show>
|
<Show when={props.onRevert}>
|
||||||
|
<button
|
||||||
<Show when={!isUser()}>
|
class="message-action-button"
|
||||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-[var(--message-assistant-border)]">
|
onClick={handleRevert}
|
||||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
title="Revert to this message"
|
||||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
aria-label="Revert to this message"
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.onFork}>
|
||||||
|
<button
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={() => props.onFork?.(props.record.id)}
|
||||||
|
title="Fork from this message"
|
||||||
|
aria-label="Fork from this message"
|
||||||
|
>
|
||||||
|
Fork
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Show when={isUser() && props.onRevert}>
|
</header>
|
||||||
<button
|
|
||||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
|
||||||
onClick={handleRevert}
|
|
||||||
title="Revert to this message"
|
|
||||||
aria-label="Revert to this message"
|
|
||||||
>
|
|
||||||
Revert to
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<Show when={isUser() && props.onFork}>
|
|
||||||
<button
|
|
||||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
|
||||||
onClick={() => props.onFork?.(props.message.id)}
|
|
||||||
title="Fork from this message"
|
|
||||||
aria-label="Fork from this message"
|
|
||||||
>
|
|
||||||
Fork
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||||
|
|
||||||
|
|
||||||
<Show when={props.isQueued && isUser()}>
|
<Show when={props.isQueued && isUser()}>
|
||||||
<div class="message-queued-badge">QUEUED</div>
|
<div class="message-queued-badge">QUEUED</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -141,25 +242,72 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={messageParts()}>{(part) => (
|
<For each={messageParts()}>
|
||||||
<MessagePart
|
{(part) => (
|
||||||
part={part}
|
<MessagePart
|
||||||
messageType={props.message.type}
|
part={part}
|
||||||
instanceId={props.instanceId}
|
messageType={props.record.role}
|
||||||
sessionId={props.sessionId}
|
instanceId={props.instanceId}
|
||||||
/>
|
sessionId={props.sessionId}
|
||||||
)}</For>
|
onRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={fileAttachments().length > 0}>
|
||||||
|
<div class="message-attachments mt-1">
|
||||||
|
<For each={fileAttachments()}>
|
||||||
|
{(attachment) => {
|
||||||
|
const name = getAttachmentName(attachment)
|
||||||
|
const isImage = isImageAttachment(attachment)
|
||||||
|
return (
|
||||||
|
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||||
|
<Show when={isImage} fallback={
|
||||||
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}>
|
||||||
|
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||||
|
</Show>
|
||||||
|
<span class="truncate max-w-[180px]">{name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleAttachmentDownload(attachment)}
|
||||||
|
class="attachment-download"
|
||||||
|
aria-label={`Download ${name}`}
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Show when={isImage}>
|
||||||
|
<div class="attachment-chip-preview">
|
||||||
|
<img src={attachment.url} alt={name} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.record.status === "sending"}>
|
||||||
|
<div class="message-sending">
|
||||||
|
<span class="generating-spinner">●</span> Sending...
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.record.status === "error"}>
|
||||||
|
<div class="message-error">⚠ Message failed to send</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.message.status === "sending"}>
|
|
||||||
<div class="message-sending">
|
|
||||||
<span class="generating-spinner">●</span> Sending...
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.message.status === "error"}>
|
|
||||||
<div class="message-error">⚠ Message failed to send</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
85
packages/ui/src/components/message-list-header.tsx
Normal file
85
packages/ui/src/components/message-list-header.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import Kbd from "./kbd"
|
||||||
|
|
||||||
|
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||||
|
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||||
|
|
||||||
|
interface MessageListHeaderProps {
|
||||||
|
usedTokens: number
|
||||||
|
|
||||||
|
availableTokens?: number | null
|
||||||
|
connectionStatus: "connected" | "connecting" | "error" | "disconnected" | "unknown" | null
|
||||||
|
onCommandPalette: () => void
|
||||||
|
formatTokens: (value: number) => string
|
||||||
|
showSidebarToggle?: boolean
|
||||||
|
onSidebarToggle?: () => void
|
||||||
|
forceCompactStatusLayout?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||||
|
|
||||||
|
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||||
|
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
||||||
|
<Show when={props.showSidebarToggle}>
|
||||||
|
<div class="connection-status-menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-sidebar-menu-button"
|
||||||
|
onClick={() => props.onSidebarToggle?.()}
|
||||||
|
aria-label="Open session list"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="connection-status-text connection-status-info">
|
||||||
|
<div class="connection-status-usage">
|
||||||
|
<div class={METRIC_CHIP_CLASS}>
|
||||||
|
<span class={METRIC_LABEL_CLASS}>Used</span>
|
||||||
|
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={METRIC_CHIP_CLASS}>
|
||||||
|
<span class={METRIC_LABEL_CLASS}>Avail</span>
|
||||||
|
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connection-status-text connection-status-shortcut">
|
||||||
|
<div class="connection-status-shortcut-action">
|
||||||
|
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
|
||||||
|
Command Palette
|
||||||
|
</button>
|
||||||
|
<span class="connection-status-shortcut-hint">
|
||||||
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||||
|
<Show when={props.connectionStatus === "connected"}>
|
||||||
|
<span class="status-indicator connected">
|
||||||
|
<span class="status-dot" />
|
||||||
|
<span class="status-text">Connected</span>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.connectionStatus === "connecting"}>
|
||||||
|
<span class="status-indicator connecting">
|
||||||
|
<span class="status-dot" />
|
||||||
|
<span class="status-text">Connecting...</span>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||||
|
<span class="status-indicator disconnected">
|
||||||
|
<span class="status-dot" />
|
||||||
|
<span class="status-text">Disconnected</span>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ interface MessagePartProps {
|
|||||||
messageType?: "user" | "assistant"
|
messageType?: "user" | "assistant"
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
onRendered?: () => void
|
||||||
export default function MessagePart(props: MessagePartProps) {
|
}
|
||||||
|
export default function MessagePart(props: MessagePartProps) {
|
||||||
|
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
const partType = () => props.part?.type || ""
|
const partType = () => props.part?.type || ""
|
||||||
@@ -33,6 +35,38 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reasoningSegmentHasText(segment: unknown): boolean {
|
||||||
|
if (typeof segment === "string") {
|
||||||
|
return segment.trim().length > 0
|
||||||
|
}
|
||||||
|
if (segment && typeof segment === "object") {
|
||||||
|
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||||
|
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (Array.isArray(candidate.content)) {
|
||||||
|
return candidate.content.some((entry) => reasoningSegmentHasText(entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasReasoningContent = () => {
|
||||||
|
if (props.part?.type !== "reasoning") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (reasoningSegmentHasText((props.part as any).text)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (Array.isArray((props.part as any).content)) {
|
||||||
|
return (props.part as any).content.some((entry: unknown) => reasoningSegmentHasText(entry))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const createTextPartForMarkdown = (): TextPart => {
|
const createTextPartForMarkdown = (): TextPart => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
||||||
@@ -63,11 +97,17 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
|
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
|
||||||
<div class={textContainerClass()}>
|
<div class={textContainerClass()}>
|
||||||
<Show
|
<Show
|
||||||
when={isAssistantMessage()}
|
when={isAssistantMessage()}
|
||||||
fallback={<span>{plainTextContent()}</span>}
|
fallback={<span>{plainTextContent()}</span>}
|
||||||
>
|
>
|
||||||
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
|
<Markdown
|
||||||
</Show>
|
part={createTextPartForMarkdown()}
|
||||||
|
isDark={isDark()}
|
||||||
|
size={isAssistantMessage() ? "tight" : "base"}
|
||||||
|
onRendered={props.onRendered}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -83,23 +123,7 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Match when={partType() === "reasoning"}>
|
|
||||||
<Show when={preferences().showThinkingBlocks && partHasRenderableText(props.part)}>
|
|
||||||
<div class="message-reasoning">
|
|
||||||
<div class="reasoning-container">
|
|
||||||
<div class="reasoning-header" onClick={handleReasoningClick}>
|
|
||||||
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : "▶"}</span>
|
|
||||||
<span class="reasoning-label">Reasoning</span>
|
|
||||||
</div>
|
|
||||||
<Show when={isReasoningExpanded()}>
|
|
||||||
<div class={`${textContainerClass()} mt-2`}>
|
|
||||||
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
437
packages/ui/src/components/message-section.tsx
Normal file
437
packages/ui/src/components/message-section.tsx
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
|
import Kbd from "./kbd"
|
||||||
|
import MessageBlockList from "./message-block-list"
|
||||||
|
import MessageListHeader from "./message-list-header"
|
||||||
|
import { useConfig } from "../stores/preferences"
|
||||||
|
import { getSessionInfo } from "../stores/sessions"
|
||||||
|
import { showCommandPalette } from "../stores/command-palette"
|
||||||
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
|
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||||
|
import { sseManager } from "../lib/sse-manager"
|
||||||
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
|
const SCROLL_SCOPE = "session"
|
||||||
|
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
|
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
|
function formatTokens(tokens: number): string {
|
||||||
|
return formatTokenTotal(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageSectionProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
loading?: boolean
|
||||||
|
onRevert?: (messageId: string) => void
|
||||||
|
onFork?: (messageId?: string) => void
|
||||||
|
registerScrollToBottom?: (fn: () => void) => void
|
||||||
|
showSidebarToggle?: boolean
|
||||||
|
onSidebarToggle?: () => void
|
||||||
|
forceCompactStatusLayout?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageSection(props: MessageSectionProps) {
|
||||||
|
const { preferences } = useConfig()
|
||||||
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||||
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
|
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||||
|
|
||||||
|
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||||
|
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||||
|
const sessionInfo = createMemo(() =>
|
||||||
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
|
cost: 0,
|
||||||
|
contextWindow: 0,
|
||||||
|
isSubscriptionModel: false,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: 0,
|
||||||
|
contextAvailableTokens: null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokenStats = createMemo(() => {
|
||||||
|
const usage = usageSnapshot()
|
||||||
|
const info = sessionInfo()
|
||||||
|
return {
|
||||||
|
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
||||||
|
avail: info.contextAvailableTokens,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const preferenceSignature = createMemo(() => {
|
||||||
|
const pref = preferences()
|
||||||
|
const showThinking = pref.showThinkingBlocks ? 1 : 0
|
||||||
|
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
|
||||||
|
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
|
||||||
|
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||||
|
const handleCommandPaletteClick = () => {
|
||||||
|
showCommandPalette(props.instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIndexMap = createMemo(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
const ids = messageIds()
|
||||||
|
ids.forEach((id, index) => map.set(id, index))
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastAssistantIndex = createMemo(() => {
|
||||||
|
const ids = messageIds()
|
||||||
|
const resolvedStore = store()
|
||||||
|
for (let index = ids.length - 1; index >= 0; index--) {
|
||||||
|
const record = resolvedStore.getMessage(ids[index])
|
||||||
|
if (record?.role === "assistant") {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeToken = createMemo(() => String(sessionRevision()))
|
||||||
|
|
||||||
|
const scrollCache = useScrollCache({
|
||||||
|
instanceId: () => props.instanceId,
|
||||||
|
sessionId: () => props.sessionId,
|
||||||
|
scope: SCROLL_SCOPE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
|
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||||
|
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||||
|
|
||||||
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
let pendingScrollFrame: number | null = null
|
||||||
|
let pendingAnchorScroll: number | null = null
|
||||||
|
let pendingScrollPersist: number | null = null
|
||||||
|
let userScrollIntentUntil = 0
|
||||||
|
let detachScrollIntentListeners: (() => void) | undefined
|
||||||
|
let hasRestoredScroll = false
|
||||||
|
let suppressAutoScrollOnce = false
|
||||||
|
|
||||||
|
function markUserScrollIntent() {
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserScrollIntent() {
|
||||||
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
return now <= userScrollIntentUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
if (!element) return
|
||||||
|
const handlePointerIntent = () => markUserScrollIntent()
|
||||||
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||||
|
if (SCROLL_INTENT_KEYS.has(event.key)) {
|
||||||
|
markUserScrollIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("keydown", handleKeyIntent)
|
||||||
|
detachScrollIntentListeners = () => {
|
||||||
|
element.removeEventListener("wheel", handlePointerIntent)
|
||||||
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.removeEventListener("touchstart", handlePointerIntent)
|
||||||
|
element.removeEventListener("keydown", handleKeyIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContainerRef(element: HTMLDivElement | null) {
|
||||||
|
containerRef = element || undefined
|
||||||
|
setScrollElement(containerRef)
|
||||||
|
attachScrollIntentListeners(containerRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollIndicatorsFromVisibility() {
|
||||||
|
const hasItems = messageIds().length > 0
|
||||||
|
setShowScrollBottomButton(hasItems && !bottomSentinelVisible())
|
||||||
|
setShowScrollTopButton(hasItems && !topSentinelVisible())
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleScrollPersist() {
|
||||||
|
if (pendingScrollPersist !== null) return
|
||||||
|
pendingScrollPersist = requestAnimationFrame(() => {
|
||||||
|
pendingScrollPersist = null
|
||||||
|
if (!containerRef) return
|
||||||
|
scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom(immediate = false) {
|
||||||
|
if (!containerRef) return
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
const behavior = immediate ? "auto" : "smooth"
|
||||||
|
if (!immediate) {
|
||||||
|
suppressAutoScrollOnce = true
|
||||||
|
}
|
||||||
|
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||||
|
setAutoScroll(true)
|
||||||
|
scheduleScrollPersist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop(immediate = false) {
|
||||||
|
if (!containerRef) return
|
||||||
|
const behavior = immediate ? "auto" : "smooth"
|
||||||
|
setAutoScroll(false)
|
||||||
|
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
|
||||||
|
scheduleScrollPersist()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function scheduleAnchorScroll(immediate = false) {
|
||||||
|
if (!autoScroll()) return
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
if (!sentinel) return
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContentRendered() {
|
||||||
|
scheduleAnchorScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (!containerRef) return
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
}
|
||||||
|
const isUserScroll = hasUserScrollIntent()
|
||||||
|
pendingScrollFrame = requestAnimationFrame(() => {
|
||||||
|
pendingScrollFrame = null
|
||||||
|
if (!containerRef) return
|
||||||
|
const atBottom = bottomSentinelVisible()
|
||||||
|
|
||||||
|
if (isUserScroll) {
|
||||||
|
if (atBottom) {
|
||||||
|
if (!autoScroll()) setAutoScroll(true)
|
||||||
|
} else if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleScrollPersist()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.registerScrollToBottom) {
|
||||||
|
props.registerScrollToBottom(() => scrollToBottom(true))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const target = containerRef
|
||||||
|
const loading = props.loading
|
||||||
|
if (!target || loading || hasRestoredScroll) return
|
||||||
|
|
||||||
|
scrollCache.restore(target, {
|
||||||
|
onApplied: (snapshot) => {
|
||||||
|
if (snapshot) {
|
||||||
|
setAutoScroll(snapshot.atBottom)
|
||||||
|
} else {
|
||||||
|
setAutoScroll(bottomSentinelVisible())
|
||||||
|
}
|
||||||
|
updateScrollIndicatorsFromVisibility()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
hasRestoredScroll = true
|
||||||
|
})
|
||||||
|
|
||||||
|
let previousToken: string | undefined
|
||||||
|
createEffect(() => {
|
||||||
|
const token = changeToken()
|
||||||
|
const loading = props.loading
|
||||||
|
if (loading || !token || token === previousToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previousToken = token
|
||||||
|
if (suppressAutoScrollOnce) {
|
||||||
|
suppressAutoScrollOnce = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (autoScroll()) {
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
preferenceSignature()
|
||||||
|
if (props.loading || !autoScroll()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (suppressAutoScrollOnce) {
|
||||||
|
suppressAutoScrollOnce = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleAnchorScroll(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (messageIds().length === 0) {
|
||||||
|
setShowScrollTopButton(false)
|
||||||
|
setShowScrollBottomButton(false)
|
||||||
|
setAutoScroll(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateScrollIndicatorsFromVisibility()
|
||||||
|
})
|
||||||
|
createEffect(() => {
|
||||||
|
const container = scrollElement()
|
||||||
|
const topTarget = topSentinel()
|
||||||
|
const bottomTarget = bottomSentinel()
|
||||||
|
if (!container || !topTarget || !bottomTarget) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let visibilityChanged = false
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.target === topTarget) {
|
||||||
|
setTopSentinelVisible(entry.isIntersecting)
|
||||||
|
visibilityChanged = true
|
||||||
|
} else if (entry.target === bottomTarget) {
|
||||||
|
setBottomSentinelVisible(entry.isIntersecting)
|
||||||
|
visibilityChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visibilityChanged) {
|
||||||
|
updateScrollIndicatorsFromVisibility()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: container, threshold: 0, rootMargin: `${SCROLL_SENTINEL_MARGIN_PX}px 0px ${SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
||||||
|
)
|
||||||
|
observer.observe(topTarget)
|
||||||
|
observer.observe(bottomTarget)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
}
|
||||||
|
if (pendingScrollPersist !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollPersist)
|
||||||
|
}
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
}
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
}
|
||||||
|
if (containerRef) {
|
||||||
|
scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-stream-container">
|
||||||
|
<MessageListHeader
|
||||||
|
usedTokens={tokenStats().used}
|
||||||
|
availableTokens={tokenStats().avail}
|
||||||
|
connectionStatus={connectionStatus()}
|
||||||
|
onCommandPalette={handleCommandPaletteClick}
|
||||||
|
formatTokens={formatTokens}
|
||||||
|
showSidebarToggle={props.showSidebarToggle}
|
||||||
|
onSidebarToggle={props.onSidebarToggle}
|
||||||
|
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll}>
|
||||||
|
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||||
|
<Show when={!props.loading && messageIds().length === 0}>
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-content">
|
||||||
|
<div class="flex flex-col items-center gap-3 mb-6">
|
||||||
|
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||||
|
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
|
</div>
|
||||||
|
<h3>Start a conversation</h3>
|
||||||
|
<p>Type a message below or open the Command Palette:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span>Command Palette</span>
|
||||||
|
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||||
|
</li>
|
||||||
|
<li>Ask about your codebase</li>
|
||||||
|
<li>
|
||||||
|
Attach files with <code>@</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.loading}>
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
<p>Loading messages...</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<MessageBlockList
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={store}
|
||||||
|
messageIds={messageIds}
|
||||||
|
messageIndexMap={messageIndexMap}
|
||||||
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
|
showThinking={() => preferences().showThinkingBlocks}
|
||||||
|
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||||
|
showUsageMetrics={showUsagePreference}
|
||||||
|
scrollContainer={scrollElement}
|
||||||
|
loading={props.loading}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
onContentRendered={handleContentRendered}
|
||||||
|
setBottomSentinel={setBottomSentinel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||||
|
<div class="message-scroll-button-wrapper">
|
||||||
|
<Show when={showScrollTopButton()}>
|
||||||
|
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={showScrollBottomButton()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => scrollToBottom()}
|
||||||
|
aria-label="Scroll to latest message"
|
||||||
|
>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user