Compare commits
5 Commits
v0.13.1-de
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1f702597 | ||
|
|
2d93d82611 | ||
|
|
4e0f064c3a | ||
|
|
e4e10cc630 | ||
|
|
8f6d4c8b09 |
240
.github/workflows/build-and-upload.yml
vendored
240
.github/workflows/build-and-upload.yml
vendored
@@ -3,11 +3,6 @@ name: Build and Upload Binaries
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
|
||||||
description: "Git ref (branch, tag, or SHA) to build from"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
version:
|
version:
|
||||||
description: "Version to apply to workspace packages (release builds)"
|
description: "Version to apply to workspace packages (release builds)"
|
||||||
required: false
|
required: false
|
||||||
@@ -28,21 +23,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
type: boolean
|
||||||
upload_actions_artifacts:
|
|
||||||
description: "Upload built artifacts to GitHub Actions run artifacts"
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
actions_artifacts_retention_days:
|
|
||||||
description: "Retention (days) for GitHub Actions artifacts"
|
|
||||||
required: false
|
|
||||||
default: 7
|
|
||||||
type: number
|
|
||||||
actions_artifacts_name_prefix:
|
|
||||||
description: "Optional prefix for Actions artifact names"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
set_versions:
|
set_versions:
|
||||||
description: "Run npm version to set workspace versions"
|
description: "Run npm version to set workspace versions"
|
||||||
required: false
|
required: false
|
||||||
@@ -65,8 +45,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -76,21 +54,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set workspace versions
|
- name: Set workspace versions
|
||||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||||
shell: bash
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
env:
|
|
||||||
NPM_CONFIG_FETCH_RETRIES: 5
|
|
||||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
|
||||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "npm version failed (attempt $attempt/3); retrying..." >&2
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
run: npm ci --workspaces --include=optional
|
||||||
@@ -101,112 +65,6 @@ jobs:
|
|||||||
- name: Build macOS binaries (Electron)
|
- name: Build macOS binaries (Electron)
|
||||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
release_root="packages/electron-app/release"
|
|
||||||
apps=()
|
|
||||||
while IFS= read -r -d '' app; do
|
|
||||||
apps+=("$app")
|
|
||||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
|
||||||
|
|
||||||
if [ "${#apps[@]}" -eq 0 ]; then
|
|
||||||
echo "No CodeNomad.app found under $release_root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# GitHub macOS runners typically have no signing identity. Without any signature,
|
|
||||||
# the shipped .app can fail Gatekeeper with:
|
|
||||||
# code has no resources but signature indicates they must be present
|
|
||||||
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
|
|
||||||
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
|
|
||||||
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
|
|
||||||
for app in "${apps[@]}"; do
|
|
||||||
echo "codesign (adhoc): $app"
|
|
||||||
codesign --force --deep --sign - "$app"
|
|
||||||
codesign --verify --deep --strict --verbose=2 "$app"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "macOS codesigning identity present; skipping ad-hoc signing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Repackage Electron macOS zips (ditto)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Prefer the workflow-provided version; fall back to package.json.
|
|
||||||
VERSION_TO_USE="${VERSION:-}"
|
|
||||||
if [ -z "$VERSION_TO_USE" ]; then
|
|
||||||
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
|
|
||||||
fi
|
|
||||||
|
|
||||||
release_root="packages/electron-app/release"
|
|
||||||
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
|
|
||||||
# Use find to locate built app bundles instead of ** globs.
|
|
||||||
apps=()
|
|
||||||
while IFS= read -r -d '' app; do
|
|
||||||
apps+=("$app")
|
|
||||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
|
||||||
if [ "${#apps[@]}" -eq 0 ]; then
|
|
||||||
echo "No CodeNomad.app found under $release_root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for app in "${apps[@]}"; do
|
|
||||||
bundle_dir=$(basename "$(dirname "$app")")
|
|
||||||
arch="x64"
|
|
||||||
if [[ "$bundle_dir" == *"arm64"* ]]; then
|
|
||||||
arch="arm64"
|
|
||||||
fi
|
|
||||||
|
|
||||||
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
|
|
||||||
rm -f "$out_zip"
|
|
||||||
echo "ditto -ck: $app -> $out_zip"
|
|
||||||
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Validate Electron macOS codesign (unzipped)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
shopt -s nullglob
|
|
||||||
|
|
||||||
tmp_dir=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
|
||||||
|
|
||||||
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
|
|
||||||
if [ "${#zips[@]}" -eq 0 ]; then
|
|
||||||
echo "No Electron macOS zip artifacts found to validate" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for zip in "${zips[@]}"; do
|
|
||||||
echo "Validating codesign for: $zip"
|
|
||||||
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
|
|
||||||
mkdir -p "$extract_dir"
|
|
||||||
|
|
||||||
# Use ditto for extraction as well to preserve bundle metadata.
|
|
||||||
ditto -x -k "$zip" "$extract_dir"
|
|
||||||
|
|
||||||
app_path=""
|
|
||||||
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
|
|
||||||
if [ -d "$candidate" ]; then
|
|
||||||
app_path="$candidate"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$app_path" ]; then
|
|
||||||
echo "No .app found after extracting $zip" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
codesign --verify --deep --strict --verbose=2 "$app_path"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -218,15 +76,6 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron macOS)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-2025
|
runs-on: windows-2025
|
||||||
env:
|
env:
|
||||||
@@ -236,8 +85,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -268,15 +115,6 @@ jobs:
|
|||||||
gh release upload $env:TAG $_.FullName --clobber
|
gh release upload $env:TAG $_.FullName --clobber
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Windows)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -286,8 +124,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -319,15 +155,6 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Linux)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-tauri-macos:
|
build-tauri-macos:
|
||||||
runs-on: macos-15-intel
|
runs-on: macos-15-intel
|
||||||
env:
|
env:
|
||||||
@@ -337,8 +164,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -381,7 +206,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -392,15 +217,6 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri macOS)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS)
|
- name: Upload Tauri release assets (macOS)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -421,8 +237,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -465,7 +279,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -476,15 +290,6 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri macOS arm64)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS arm64)
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -505,8 +310,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -552,7 +355,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
@@ -565,15 +368,6 @@ jobs:
|
|||||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri Windows)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Windows)
|
- name: Upload Tauri release assets (Windows)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -594,8 +388,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -651,7 +443,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SEARCH_ROOT="packages/tauri-app/target"
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
@@ -677,15 +469,6 @@ jobs:
|
|||||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri Linux)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
|
||||||
path: packages/tauri-app/release-tauri/*
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Linux)
|
- name: Upload Tauri release assets (Linux)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -707,8 +490,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -806,8 +587,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -844,12 +623,3 @@ jobs:
|
|||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Linux RPM)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
|
||||||
path: packages/electron-app/release/*.rpm
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|||||||
121
.github/workflows/comment-pr-artifacts.yml
vendored
121
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -1,121 +0,0 @@
|
|||||||
name: Comment PR Artifacts
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
RETENTION_DAYS: 7
|
|
||||||
steps:
|
|
||||||
- name: Check PR authorization
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "$BASE_REF" = "dev" ]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Wait for PR build and comment
|
|
||||||
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
const owner = context.repo.owner;
|
|
||||||
const repo = context.repo.repo;
|
|
||||||
const prNumber = Number(process.env.PR_NUMBER);
|
|
||||||
const headSha = process.env.HEAD_SHA;
|
|
||||||
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
|
|
||||||
const marker = '<!-- codenomad-pr-artifacts -->';
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
let matchedRun = null;
|
|
||||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
||||||
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
workflow_id: 'pr-build.yml',
|
|
||||||
event: 'pull_request',
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const matchingRuns = runs
|
|
||||||
.filter((run) => run.head_sha === headSha)
|
|
||||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
||||||
|
|
||||||
matchedRun = matchingRuns[0] || null;
|
|
||||||
if (matchedRun && matchedRun.status === 'completed') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
|
|
||||||
await sleep(10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchedRun) {
|
|
||||||
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedRun.status !== 'completed') {
|
|
||||||
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const artifacts = await github.paginate(
|
|
||||||
github.rest.actions.listWorkflowRunArtifacts,
|
|
||||||
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
|
||||||
);
|
|
||||||
const active = artifacts.filter((artifact) => !artifact.expired);
|
|
||||||
|
|
||||||
const runUrl = matchedRun.html_url;
|
|
||||||
const artifactsBlock = active.length
|
|
||||||
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
|
|
||||||
: 'Artifacts: (none found on this run)';
|
|
||||||
|
|
||||||
const body = [
|
|
||||||
marker,
|
|
||||||
'PR builds are available as GitHub Actions artifacts:',
|
|
||||||
'',
|
|
||||||
runUrl,
|
|
||||||
'',
|
|
||||||
`Artifacts expire in ${retentionDays} days.`,
|
|
||||||
artifactsBlock,
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const created = await github.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: prNumber,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
core.info(`Created artifacts comment: ${created.data.html_url}`);
|
|
||||||
61
.github/workflows/dev-release.yml
vendored
61
.github/workflows/dev-release.yml
vendored
@@ -1,13 +1,12 @@
|
|||||||
name: Develop Pre-Release
|
name: Develop Pre-Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
push:
|
||||||
# Nightly build of dev (only if dev has new commits)
|
branches:
|
||||||
- cron: "0 1 * * *"
|
- dev
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -16,63 +15,25 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
gate:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
run: ${{ steps.gate.outputs.run }}
|
version_suffix: ${{ steps.vars.outputs.version_suffix }}
|
||||||
dev_sha: ${{ steps.gate.outputs.dev_sha }}
|
|
||||||
version_suffix: ${{ steps.gate.outputs.version_suffix }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Decide whether to run
|
- name: Compute version suffix
|
||||||
id: gate
|
id: vars
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
SHA8="${GITHUB_SHA::8}"
|
||||||
api() {
|
|
||||||
curl -sS \
|
|
||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
"$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
|
|
||||||
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
|
|
||||||
echo "Failed to resolve dev head SHA" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
DATE=$(date -u +%Y%m%d)
|
DATE=$(date -u +%Y%m%d)
|
||||||
SHA8="${DEV_SHA::8}"
|
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
|
||||||
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
|
|
||||||
|
|
||||||
SHOULD_RUN="false"
|
|
||||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
|
||||||
SHOULD_RUN="true"
|
|
||||||
else
|
|
||||||
# Nightly: only run if dev has advanced since last successful dev-release build.
|
|
||||||
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
|
|
||||||
if [ -z "${LAST_SHA}" ]; then
|
|
||||||
SHOULD_RUN="true"
|
|
||||||
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
|
|
||||||
SHOULD_RUN="true"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
prerelease:
|
prerelease:
|
||||||
needs: gate
|
needs: prepare
|
||||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
|
||||||
uses: ./.github/workflows/reusable-release.yml
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.gate.outputs.dev_sha }}
|
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||||
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
|
||||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||||
dist_tag: latest
|
dist_tag: latest
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
|||||||
6
.github/workflows/manual-npm-publish.yml
vendored
6
.github/workflows/manual-npm-publish.yml
vendored
@@ -19,10 +19,6 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
version:
|
version:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
@@ -50,8 +46,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
57
.github/workflows/pr-build.yml
vendored
57
.github/workflows/pr-build.yml
vendored
@@ -1,57 +0,0 @@
|
|||||||
name: PR Build Validation
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pr-build-${{ github.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
authorize:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
allowed: ${{ steps.auth.outputs.allowed }}
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
steps:
|
|
||||||
- name: Check PR authorization
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "$BASE_REF" = "dev" ]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: authorize
|
|
||||||
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
upload: false
|
|
||||||
upload_actions_artifacts: true
|
|
||||||
actions_artifacts_retention_days: 7
|
|
||||||
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
|
||||||
set_versions: false
|
|
||||||
10
.github/workflows/release-ui.yml
vendored
10
.github/workflows/release-ui.yml
vendored
@@ -1,13 +1,7 @@
|
|||||||
name: Release UI
|
name: Release UI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call: {}
|
||||||
inputs:
|
|
||||||
ref:
|
|
||||||
description: "Git ref (branch, tag, or SHA) to build from"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,8 +18,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
54
.github/workflows/restrict-non-dev-prs.yml
vendored
54
.github/workflows/restrict-non-dev-prs.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
name: Restrict Non-Dev PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- reopened
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
restrict-non-dev-prs:
|
|
||||||
if: ${{ github.event.pull_request.base.ref != 'dev' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
steps:
|
|
||||||
- name: Check allowed actor
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Comment on unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
|
|
||||||
|
|
||||||
- name: Close unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
gh pr close "$PR_NUMBER"
|
|
||||||
|
|
||||||
- name: Fail unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
run: |
|
|
||||||
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
|
||||||
exit 1
|
|
||||||
11
.github/workflows/reusable-release.yml
vendored
11
.github/workflows/reusable-release.yml
vendored
@@ -3,11 +3,6 @@ name: Reusable Release
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
|
||||||
description: "Git ref (branch, tag, or SHA) to build from"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
version_suffix:
|
version_suffix:
|
||||||
description: "Suffix appended to package.json version"
|
description: "Suffix appended to package.json version"
|
||||||
required: false
|
required: false
|
||||||
@@ -51,8 +46,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -91,7 +84,6 @@ jobs:
|
|||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
@@ -103,8 +95,6 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
uses: ./.github/workflows/release-ui.yml
|
uses: ./.github/workflows/release-ui.yml
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
publish-server:
|
publish-server:
|
||||||
@@ -113,7 +103,6 @@ jobs:
|
|||||||
- build-and-upload
|
- build-and-upload
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
dist_tag: ${{ inputs.dist_tag }}
|
dist_tag: ${{ inputs.dist_tag }}
|
||||||
package_name: ${{ inputs.npm_package_name }}
|
package_name: ${{ inputs.npm_package_name }}
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -44,22 +44,19 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
For dev version
|
||||||
- [packages/server/README.md](packages/server/README.md)
|
|
||||||
|
|
||||||
To see all available options:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @neuralnomads/codenomad --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🧪 Dev Releases
|
|
||||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad-dev --launch
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Dev builds are published as GitHub pre-releases:
|
||||||
|
https://github.com/shantur/CodeNomad/releases
|
||||||
|
|
||||||
|
Dev releases are bleeding-edge builds, generated automatically every time a new commit is pushed to the `dev` branch.
|
||||||
|
|
||||||
|
This command starts the server and opens the web client in your default browser.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||||
@@ -123,6 +120,3 @@ To build the Desktop App from source:
|
|||||||
1. Clone the repo.
|
1. Clone the repo.
|
||||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||||
|
|
||||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
|
||||||
|
|
||||||
|
|||||||
119
package-lock.json
generated
119
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -2809,9 +2809,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.2.6",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
||||||
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
|
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
@@ -3253,9 +3253,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.10.1",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -3305,32 +3305,6 @@
|
|||||||
"node": ">= 10"
|
"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/@tauri-apps/plugin-dialog": {
|
|
||||||
"version": "2.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
|
||||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/plugin-notification": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
@@ -8240,27 +8214,6 @@
|
|||||||
"regex-recursion": "^6.0.2"
|
"regex-recursion": "^6.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openai": {
|
|
||||||
"version": "6.27.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
|
|
||||||
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"openai": "bin/cli"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ws": "^8.18.0",
|
|
||||||
"zod": "^3.25 || ^4.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"ws": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"zod": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
@@ -10265,6 +10218,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/tauri-plugin-keepawake-api": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/temp-dir": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
@@ -11005,36 +10966,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/virtua": {
|
|
||||||
"version": "0.48.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
|
||||||
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.14.0",
|
|
||||||
"react-dom": ">=16.14.0",
|
|
||||||
"solid-js": ">=1.0",
|
|
||||||
"svelte": ">=5.0",
|
|
||||||
"vue": ">=3.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"solid-js": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"svelte": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vue": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -12040,7 +11971,6 @@
|
|||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -12055,7 +11985,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12065,7 +11995,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
@@ -12092,7 +12021,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12102,7 +12031,6 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"openai": "^6.27.0",
|
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
@@ -12134,7 +12062,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12142,18 +12070,16 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"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.2.6",
|
"@opencode-ai/sdk": "1.1.11",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/api": "^2.10.1",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
@@ -12166,8 +12092,7 @@
|
|||||||
"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",
|
||||||
"virtua": "^0.48.8",
|
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||||
"yaml": "^2.4.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.13.1",
|
"minServerVersion": "0.10.3",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,4 +2,3 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.vite/
|
.vite/
|
||||||
electron/resources/server/
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
import fs from "fs"
|
|
||||||
import { requestMicrophoneAccess } from "./permissions"
|
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -67,24 +65,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { canceled: result.canceled, paths: result.filePaths }
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
|
||||||
if (!Array.isArray(paths)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = paths.filter((value): value is string => {
|
|
||||||
if (typeof value !== "string" || value.trim().length === 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return fs.statSync(value).isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return directories
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||||
const next = Boolean(enabled)
|
const next = Boolean(enabled)
|
||||||
if (next) {
|
if (next) {
|
||||||
@@ -112,11 +92,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { enabled: false }
|
return { enabled: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"media:requestMicrophoneAccess",
|
|
||||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
|
||||||
)
|
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { dirname, join } from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
import { setupCliIPC } from "./ipc"
|
import { setupCliIPC } from "./ipc"
|
||||||
import { configureMediaPermissionHandlers } from "./permissions"
|
|
||||||
import { CliProcessManager } from "./process-manager"
|
import { CliProcessManager } from "./process-manager"
|
||||||
|
|
||||||
const mainFilename = fileURLToPath(import.meta.url)
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
@@ -490,7 +489,6 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
window.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { session, systemPreferences } from "electron"
|
|
||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
|
||||||
|
|
||||||
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
|
|
||||||
if (!origin) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const normalized = new URL(origin).origin
|
|
||||||
return allowedOrigins.includes(normalized)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
|
||||||
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
|
||||||
if (permission !== "media") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
|
|
||||||
return mediaTypes.length === 0 || mediaTypes.includes("audio")
|
|
||||||
}
|
|
||||||
|
|
||||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
|
||||||
if (!isAudioMediaRequest(permission, details)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
|
|
||||||
})
|
|
||||||
|
|
||||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
|
||||||
if (!isAudioMediaRequest(permission, details)) {
|
|
||||||
callback(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
|
|
||||||
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestMicrophoneAccess(): Promise<boolean> {
|
|
||||||
if (!isMac) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = systemPreferences.getMediaAccessStatus("microphone")
|
|
||||||
if (status === "granted") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return systemPreferences.askForMediaAccess("microphone")
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { app, utilityProcess, type UtilityProcess } from "electron"
|
import { app } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { parse as parseYaml } from "yaml"
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
const mainFilename = fileURLToPath(import.meta.url)
|
|
||||||
const mainDirname = path.dirname(mainFilename)
|
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
|
||||||
@@ -41,9 +38,6 @@ interface CliEntryResolution {
|
|||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagedChild = ChildProcess | UtilityProcess
|
|
||||||
type ChildLaunchMode = "spawn" | "utility"
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
function isYamlPath(filePath: string): boolean {
|
function isYamlPath(filePath: string): boolean {
|
||||||
@@ -103,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
|||||||
return "local"
|
return "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
const mode = parsed?.preferences?.listeningMode
|
||||||
if (mode === "local" || mode === "all") {
|
if (mode === "local" || mode === "all") {
|
||||||
return mode
|
return mode
|
||||||
}
|
}
|
||||||
@@ -123,8 +117,7 @@ export declare interface CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CliProcessManager extends EventEmitter {
|
export class CliProcessManager extends EventEmitter {
|
||||||
private child?: ManagedChild
|
private child?: ChildProcess
|
||||||
private childLaunchMode: ChildLaunchMode = "spawn"
|
|
||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
@@ -142,63 +135,33 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
|
||||||
let child: ManagedChild
|
console.info(
|
||||||
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
|
)
|
||||||
|
|
||||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
const runtimePath = this.resolveShellNodeCommand()
|
env.ELECTRON_RUN_AS_NODE = "1"
|
||||||
const entryPath = this.resolveBundledProdEntry()
|
|
||||||
const supervisorPath = this.resolveCliSupervisorPath()
|
|
||||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
|
||||||
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
|
||||||
const supervisorPayload = JSON.stringify({
|
|
||||||
command: shellCommand.command,
|
|
||||||
args: shellCommand.args,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.info(
|
const spawnDetails = supportsUserShell()
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
)
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
|
||||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
|
||||||
|
|
||||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
const detached = process.platform !== "win32"
|
||||||
env: shellEnv,
|
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
stdio: "pipe",
|
cwd: process.cwd(),
|
||||||
serviceName: "CodeNomad CLI Supervisor",
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
})
|
env,
|
||||||
this.childLaunchMode = "utility"
|
shell: false,
|
||||||
} else {
|
detached,
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
})
|
||||||
console.info(
|
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
env.ELECTRON_RUN_AS_NODE = "1"
|
if (!child.pid) {
|
||||||
|
|
||||||
const spawnDetails = supportsUserShell()
|
|
||||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
|
||||||
child = spawn(spawnDetails.command, spawnDetails.args, {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
env,
|
|
||||||
shell: false,
|
|
||||||
detached,
|
|
||||||
})
|
|
||||||
|
|
||||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
|
||||||
this.childLaunchMode = "spawn"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.childLaunchMode === "spawn" && !child.pid) {
|
|
||||||
console.error("[cli] spawn failed: no pid")
|
console.error("[cli] spawn failed: no pid")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,48 +176,23 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.handleStream(data.toString(), "stderr")
|
this.handleStream(data.toString(), "stderr")
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.childLaunchMode === "utility") {
|
child.on("error", (error) => {
|
||||||
const utilityChild = child as UtilityProcess
|
console.error("[cli] failed to start CLI:", error)
|
||||||
|
this.updateStatus({ state: "error", error: error.message })
|
||||||
|
this.emit("error", error)
|
||||||
|
})
|
||||||
|
|
||||||
utilityChild.on("error", (error) => {
|
child.on("exit", (code, signal) => {
|
||||||
const message = this.describeUtilityProcessError(error)
|
const failed = this.status.state !== "ready"
|
||||||
console.error("[cli] utility supervisor failed:", error)
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||||
this.updateStatus({ state: "error", error: message })
|
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||||
this.emit("error", new Error(message))
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
})
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
utilityChild.on("exit", (code) => {
|
}
|
||||||
const failed = this.status.state !== "ready"
|
this.emit("exit", this.status)
|
||||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
this.child = undefined
|
||||||
console.info(`[cli] exit (code=${code ?? ""})${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
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const spawnedChild = child as ChildProcess
|
|
||||||
|
|
||||||
spawnedChild.on("error", (error) => {
|
|
||||||
console.error("[cli] failed to start CLI:", error)
|
|
||||||
this.updateStatus({ state: "error", error: error.message })
|
|
||||||
this.emit("error", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
spawnedChild.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) => {
|
return new Promise<CliStatus>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -281,22 +219,16 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.childLaunchMode === "utility") {
|
|
||||||
return this.stopUtilityChild(child as UtilityProcess)
|
|
||||||
}
|
|
||||||
|
|
||||||
const spawnedChild = child as ChildProcess
|
|
||||||
|
|
||||||
this.requestedStop = true
|
this.requestedStop = true
|
||||||
|
|
||||||
const pid = spawnedChild.pid
|
const pid = child.pid
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
this.updateStatus({ state: "stopped" })
|
this.updateStatus({ state: "stopped" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||||
|
|
||||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||||
try {
|
try {
|
||||||
@@ -372,7 +304,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
sendStopSignal("SIGKILL")
|
sendStopSignal("SIGKILL")
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
spawnedChild.on("exit", () => {
|
child.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
console.info("[cli] CLI process exited")
|
console.info("[cli] CLI process exited")
|
||||||
@@ -392,46 +324,6 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
|
||||||
this.requestedStop = true
|
|
||||||
|
|
||||||
const pid = child.pid
|
|
||||||
if (!pid) {
|
|
||||||
this.child = undefined
|
|
||||||
this.updateStatus({ state: "stopped" })
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const killTimeout = setTimeout(() => {
|
|
||||||
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
|
|
||||||
try {
|
|
||||||
process.kill(pid, "SIGKILL")
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}, 30000)
|
|
||||||
|
|
||||||
child.once("exit", () => {
|
|
||||||
clearTimeout(killTimeout)
|
|
||||||
this.child = undefined
|
|
||||||
console.info("[cli] CLI process exited")
|
|
||||||
this.updateStatus({ state: "stopped" })
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (child.pid === undefined) {
|
|
||||||
clearTimeout(killTimeout)
|
|
||||||
this.child = undefined
|
|
||||||
this.updateStatus({ state: "stopped" })
|
|
||||||
resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
child.kill()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus(): CliStatus {
|
getStatus(): CliStatus {
|
||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
@@ -443,22 +335,14 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private handleTimeout() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
const pid = this.child.pid
|
const pid = this.child.pid
|
||||||
if (this.childLaunchMode === "utility") {
|
if (pid && process.platform !== "win32") {
|
||||||
if (pid) {
|
|
||||||
try {
|
|
||||||
process.kill(pid, "SIGKILL")
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (pid && process.platform !== "win32") {
|
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, "SIGKILL")
|
process.kill(-pid, "SIGKILL")
|
||||||
} catch {
|
} catch {
|
||||||
;(this.child as ChildProcess).kill("SIGKILL")
|
this.child.kill("SIGKILL")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
;(this.child as ChildProcess).kill("SIGKILL")
|
this.child.kill("SIGKILL")
|
||||||
}
|
}
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
}
|
}
|
||||||
@@ -547,9 +431,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||||
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
|
args.push("--ui-dev-server", devServer, "--log-level", "debug")
|
||||||
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
|
|
||||||
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
@@ -565,10 +447,6 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return parts.join(" ")
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildExecutableCommand(command: string, args: string[]): string {
|
|
||||||
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
@@ -639,58 +517,4 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
|
||||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveCliSupervisorPath(): string {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
|
||||||
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(candidate)) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveShellNodeCommand(): string {
|
|
||||||
const configured = process.env.NODE_BINARY?.trim()
|
|
||||||
return configured && configured.length > 0 ? configured : "node"
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveBundledProdEntry(): string {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
|
||||||
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(candidate)) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private describeUtilityProcessError(error: unknown): string {
|
|
||||||
if (error instanceof Error && error.message) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && typeof error === "object") {
|
|
||||||
const typed = error as { type?: unknown; location?: unknown }
|
|
||||||
if (typeof typed.type === "string") {
|
|
||||||
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
const { contextBridge, ipcRenderer } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
@@ -12,15 +12,6 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
|
||||||
getPathForFile: (file) => {
|
|
||||||
try {
|
|
||||||
return webUtils.getPathForFile(file)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
const { spawn } = require("child_process")
|
|
||||||
|
|
||||||
const SHUTDOWN_GRACE_MS = 30_000
|
|
||||||
|
|
||||||
let child = null
|
|
||||||
let shutdownTimer = null
|
|
||||||
|
|
||||||
function log(message, error) {
|
|
||||||
if (error) {
|
|
||||||
console.error(`[cli-supervisor] ${message}`, error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.log(`[cli-supervisor] ${message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearShutdownTimer() {
|
|
||||||
if (shutdownTimer) {
|
|
||||||
clearTimeout(shutdownTimer)
|
|
||||||
shutdownTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function forwardStream(stream, target) {
|
|
||||||
if (!stream) return
|
|
||||||
stream.on("data", (chunk) => {
|
|
||||||
target.write(chunk)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function terminateChild(force) {
|
|
||||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
child.kill(force ? "SIGKILL" : "SIGTERM")
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestShutdown(force = false) {
|
|
||||||
if (!child) {
|
|
||||||
process.exit(force ? 1 : 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
terminateChild(force)
|
|
||||||
if (force) {
|
|
||||||
process.exit(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearShutdownTimer()
|
|
||||||
shutdownTimer = setTimeout(() => {
|
|
||||||
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
|
|
||||||
terminateChild(true)
|
|
||||||
}, SHUTDOWN_GRACE_MS)
|
|
||||||
shutdownTimer.unref()
|
|
||||||
}
|
|
||||||
|
|
||||||
function installShutdownHandlers() {
|
|
||||||
process.on("SIGTERM", () => requestShutdown(false))
|
|
||||||
process.on("SIGINT", () => requestShutdown(false))
|
|
||||||
process.on("disconnect", () => requestShutdown(false))
|
|
||||||
process.on("uncaughtException", (error) => {
|
|
||||||
log("uncaught exception", error)
|
|
||||||
requestShutdown(true)
|
|
||||||
})
|
|
||||||
process.on("unhandledRejection", (error) => {
|
|
||||||
log("unhandled rejection", error)
|
|
||||||
requestShutdown(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePayload() {
|
|
||||||
const raw = process.argv[2]
|
|
||||||
if (!raw) {
|
|
||||||
throw new Error("Supervisor payload is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (!parsed || typeof parsed !== "object") {
|
|
||||||
throw new Error("Supervisor payload must be an object")
|
|
||||||
}
|
|
||||||
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
|
|
||||||
throw new Error("Supervisor payload command is required")
|
|
||||||
}
|
|
||||||
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
|
|
||||||
throw new Error("Supervisor payload args must be a string array")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
command: parsed.command,
|
|
||||||
args: parsed.args,
|
|
||||||
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
installShutdownHandlers()
|
|
||||||
|
|
||||||
const payload = parsePayload()
|
|
||||||
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
|
|
||||||
|
|
||||||
child = spawn(payload.command, payload.args, {
|
|
||||||
cwd: payload.cwd,
|
|
||||||
env: process.env,
|
|
||||||
shell: false,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
})
|
|
||||||
|
|
||||||
forwardStream(child.stdout, process.stdout)
|
|
||||||
forwardStream(child.stderr, process.stderr)
|
|
||||||
|
|
||||||
child.on("error", (error) => {
|
|
||||||
log("failed to spawn shell command", error)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
|
||||||
clearShutdownTimer()
|
|
||||||
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
|
|
||||||
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
|
|
||||||
process.exit()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.audio-input</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -15,13 +15,8 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:info",
|
"dev": "electron-vite dev",
|
||||||
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
|
|
||||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
|
||||||
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
|
||||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
"prepare:resources": "node scripts/prepare-resources.js",
|
|
||||||
"prebuild": "npm run prepare:resources",
|
|
||||||
"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",
|
||||||
@@ -35,11 +30,8 @@
|
|||||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||||
"build:all": "node scripts/build.js all",
|
"build:all": "node scripts/build.js all",
|
||||||
"prepackage:mac": "npm run prepare:resources",
|
|
||||||
"package:mac": "electron-builder --mac",
|
"package:mac": "electron-builder --mac",
|
||||||
"prepackage:win": "npm run prepare:resources",
|
|
||||||
"package:win": "electron-builder --win",
|
"package:win": "electron-builder --win",
|
||||||
"prepackage:linux": "npm run prepare:resources",
|
|
||||||
"package:linux": "electron-builder --linux"
|
"package:linux": "electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -50,7 +42,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
@@ -87,12 +78,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"entitlements": "electron/resources/entitlements.mac.plist",
|
|
||||||
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
|
|
||||||
"extendInfo": {
|
|
||||||
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
|
|
||||||
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
|
|
||||||
},
|
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -111,12 +111,6 @@ async function build(platform) {
|
|||||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
|
||||||
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
|
||||||
cwd: workspaceRoot,
|
|
||||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||||
await run(npmCmd, ["run", "build"])
|
await run(npmCmd, ["run", "build"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import fs from "fs"
|
|
||||||
import path, { join } from "path"
|
|
||||||
import { spawnSync } from "child_process"
|
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
|
||||||
const appDir = join(__dirname, "..")
|
|
||||||
const workspaceRoot = join(appDir, "..", "..")
|
|
||||||
const serverRoot = join(appDir, "..", "server")
|
|
||||||
const resourcesRoot = join(appDir, "electron", "resources")
|
|
||||||
const serverDest = join(resourcesRoot, "server")
|
|
||||||
const npmExecPath = process.env.npm_execpath
|
|
||||||
const npmNodeExecPath = process.env.npm_node_execpath
|
|
||||||
|
|
||||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
|
||||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
|
||||||
|
|
||||||
function log(message) {
|
|
||||||
console.log(`[prepare-resources] ${message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureServerBuild() {
|
|
||||||
const distPath = join(serverRoot, "dist")
|
|
||||||
const publicPath = join(serverRoot, "public")
|
|
||||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
|
||||||
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureServerDependencies() {
|
|
||||||
if (fs.existsSync(serverDepsMarker)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log("installing production server dependencies")
|
|
||||||
const npmArgs = [
|
|
||||||
"install",
|
|
||||||
"--omit=dev",
|
|
||||||
"--ignore-scripts",
|
|
||||||
"--workspaces=false",
|
|
||||||
"--package-lock=false",
|
|
||||||
"--install-strategy=shallow",
|
|
||||||
"--fund=false",
|
|
||||||
"--audit=false",
|
|
||||||
]
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
||||||
npm_config_workspaces: "false",
|
|
||||||
}
|
|
||||||
|
|
||||||
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
|
||||||
const result = npmCli
|
|
||||||
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
|
||||||
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error
|
|
||||||
}
|
|
||||||
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyServerArtifacts() {
|
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
|
||||||
|
|
||||||
for (const name of serverSources) {
|
|
||||||
const from = join(serverRoot, name)
|
|
||||||
const to = join(serverDest, name)
|
|
||||||
if (!fs.existsSync(from)) {
|
|
||||||
throw new Error(`Missing required server artifact: ${from}`)
|
|
||||||
}
|
|
||||||
fs.cpSync(from, to, { recursive: true, dereference: true })
|
|
||||||
log(`copied ${name} to Electron resources`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripNodeModuleBins() {
|
|
||||||
const root = join(serverDest, "node_modules")
|
|
||||||
if (!fs.existsSync(root)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const stack = [root]
|
|
||||||
let removed = 0
|
|
||||||
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const current = stack.pop()
|
|
||||||
if (!current) break
|
|
||||||
|
|
||||||
let entries
|
|
||||||
try {
|
|
||||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const full = join(current, entry.name)
|
|
||||||
if (entry.name === ".bin") {
|
|
||||||
fs.rmSync(full, { recursive: true, force: true })
|
|
||||||
removed += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
stack.push(full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removed > 0) {
|
|
||||||
log(`removed ${removed} node_modules/.bin directories`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
ensureServerBuild()
|
|
||||||
ensureServerDependencies()
|
|
||||||
copyServerArtifacts()
|
|
||||||
stripNodeModuleBins()
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error("[prepare-resources] failed:", error)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||||
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.3.2"
|
"@opencode-ai/plugin": "1.1.53"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,21 +5,18 @@
|
|||||||
## Features & Capabilities
|
## Features & Capabilities
|
||||||
|
|
||||||
### 🌍 Deployment Freedom
|
### 🌍 Deployment Freedom
|
||||||
|
|
||||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
- **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.
|
- **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.
|
- **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.
|
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||||
|
|
||||||
### ⚡️ Workspace Power
|
### ⚡️ Workspace Power
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
- **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.
|
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||||
- Node.js 18+ and npm (for running or building from source).
|
- Node.js 18+ and npm (for running or building from source).
|
||||||
- A workspace folder on disk you want to serve.
|
- A workspace folder on disk you want to serve.
|
||||||
@@ -28,26 +25,18 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Run via npx (Recommended)
|
### Run via npx (Recommended)
|
||||||
|
|
||||||
You can run CodeNomad directly without installing it:
|
You can run CodeNomad directly without installing it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
To list all CLI options:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx @neuralnomads/codenomad --help
|
|
||||||
```
|
|
||||||
|
|
||||||
On startup, CodeNomad prints two URLs:
|
On startup, CodeNomad prints two URLs:
|
||||||
|
|
||||||
- `Local Connection URL : ...` (used by desktop shells)
|
- `Local Connection URL : ...` (used by desktop shells)
|
||||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||||
|
|
||||||
### Install Globally
|
### Install Globally
|
||||||
|
|
||||||
Or install it globally to use the `codenomad` command:
|
Or install it globally to use the `codenomad` command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -55,19 +44,7 @@ npm install -g @neuralnomads/codenomad
|
|||||||
codenomad --launch
|
codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install Locally (per-project)
|
|
||||||
|
|
||||||
If you prefer to install CodeNomad into a project and run the local binary:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install @neuralnomads/codenomad
|
|
||||||
npx codenomad --launch
|
|
||||||
```
|
|
||||||
|
|
||||||
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
|
||||||
|
|
||||||
### Common Flags
|
### Common Flags
|
||||||
|
|
||||||
You can configure the server using flags or environment variables:
|
You can configure the server using flags or environment variables:
|
||||||
|
|
||||||
| Flag | Env Variable | Description |
|
| Flag | Env Variable | Description |
|
||||||
@@ -81,36 +58,15 @@ You can configure the server using flags or environment variables:
|
|||||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. |
|
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||||
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
|
|
||||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
||||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||||
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
|
||||||
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
|
||||||
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
|
||||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
|
|
||||||
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
|
||||||
|
|
||||||
### Dev Releases (Advanced)
|
|
||||||
|
|
||||||
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx @neuralnomads/codenomad-dev --launch
|
|
||||||
```
|
|
||||||
|
|
||||||
These environment variables control how CodeNomad checks for dev updates:
|
|
||||||
|
|
||||||
| Env Variable | Description |
|
|
||||||
|-------------|-------------|
|
|
||||||
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
|
|
||||||
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
|
|
||||||
|
|
||||||
### HTTP vs HTTPS
|
### HTTP vs HTTPS
|
||||||
|
|
||||||
@@ -149,14 +105,12 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||||
|
|
||||||
### Progressive Web App (PWA)
|
### Progressive Web App (PWA)
|
||||||
|
|
||||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||||
|
|
||||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||||
@@ -168,6 +122,5 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
|||||||
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||||
|
|
||||||
### Data Storage
|
### Data Storage
|
||||||
|
|
||||||
- **Config**: `~/.config/codenomad/config.json`
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"openai": "^6.27.0",
|
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentModelSelection,
|
AgentModelSelection,
|
||||||
AgentModelSelections,
|
AgentModelSelections,
|
||||||
|
ConfigFile,
|
||||||
ModelPreference,
|
ModelPreference,
|
||||||
OpenCodeBinary,
|
OpenCodeBinary,
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -182,9 +183,9 @@ export interface BinaryRecord {
|
|||||||
validationError?: string
|
validationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsOwner = string
|
export type AppConfig = ConfigFile
|
||||||
export type SettingsBucket = Record<string, unknown>
|
export type AppConfigResponse = AppConfig
|
||||||
export type SettingsDoc = Record<string, unknown>
|
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||||
|
|
||||||
export interface BinaryListResponse {
|
export interface BinaryListResponse {
|
||||||
binaries: BinaryRecord[]
|
binaries: BinaryRecord[]
|
||||||
@@ -207,47 +208,14 @@ export interface BinaryValidationResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeechSegment {
|
|
||||||
startMs: number
|
|
||||||
endMs: number
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpeechCapabilitiesResponse {
|
|
||||||
available: boolean
|
|
||||||
configured: boolean
|
|
||||||
provider: string
|
|
||||||
supportsStt: boolean
|
|
||||||
supportsTts: boolean
|
|
||||||
supportsStreamingTts: boolean
|
|
||||||
baseUrl?: string
|
|
||||||
sttModel: string
|
|
||||||
ttsModel: string
|
|
||||||
ttsVoice: string
|
|
||||||
ttsFormats: string[]
|
|
||||||
streamingTtsFormats: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpeechTranscriptionResponse {
|
|
||||||
text: string
|
|
||||||
language?: string
|
|
||||||
durationMs?: number
|
|
||||||
segments?: SpeechSegment[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpeechSynthesisResponse {
|
|
||||||
audioBase64: string
|
|
||||||
mimeType: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
| "storage.configChanged"
|
| "config.appChanged"
|
||||||
| "storage.stateChanged"
|
| "config.binariesChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
@@ -258,8 +226,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "config.appChanged"; config: AppConfig }
|
||||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
|
|||||||
192
packages/server/src/config/binaries.ts
Normal file
192
packages/server/src/config/binaries.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
BinaryCreateRequest,
|
||||||
|
BinaryRecord,
|
||||||
|
BinaryUpdateRequest,
|
||||||
|
BinaryValidationResult,
|
||||||
|
} from "../api-types"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import { ConfigStore } from "./store"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import type { ConfigFile } from "./schema"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
import { buildSpawnSpec } from "../workspaces/runtime"
|
||||||
|
|
||||||
|
export class BinaryRegistry {
|
||||||
|
constructor(
|
||||||
|
private readonly configStore: ConfigStore,
|
||||||
|
private readonly eventBus: EventBus | undefined,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(): BinaryRecord[] {
|
||||||
|
return this.mapRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDefault(): BinaryRecord {
|
||||||
|
const binaries = this.mapRecords()
|
||||||
|
if (binaries.length === 0) {
|
||||||
|
this.logger.warn("No configured binaries found, falling back to opencode")
|
||||||
|
return this.buildFallbackRecord("opencode")
|
||||||
|
}
|
||||||
|
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
create(request: BinaryCreateRequest): BinaryRecord {
|
||||||
|
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
|
||||||
|
const entry = {
|
||||||
|
path: request.path,
|
||||||
|
version: undefined,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
label: request.label,
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = this.configStore.get()
|
||||||
|
const nextConfig = this.cloneConfig(config)
|
||||||
|
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||||
|
nextConfig.opencodeBinaries = [entry, ...deduped]
|
||||||
|
|
||||||
|
if (request.makeDefault) {
|
||||||
|
nextConfig.preferences.lastUsedBinary = request.path
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configStore.replace(nextConfig)
|
||||||
|
const record = this.getById(request.path)
|
||||||
|
this.emitChange()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||||
|
this.logger.debug({ id }, "Updating OpenCode binary")
|
||||||
|
const config = this.configStore.get()
|
||||||
|
const nextConfig = this.cloneConfig(config)
|
||||||
|
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
||||||
|
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updates.makeDefault) {
|
||||||
|
nextConfig.preferences.lastUsedBinary = id
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configStore.replace(nextConfig)
|
||||||
|
const record = this.getById(id)
|
||||||
|
this.emitChange()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
this.logger.debug({ id }, "Removing OpenCode binary")
|
||||||
|
const config = this.configStore.get()
|
||||||
|
const nextConfig = this.cloneConfig(config)
|
||||||
|
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||||
|
nextConfig.opencodeBinaries = remaining
|
||||||
|
|
||||||
|
if (nextConfig.preferences.lastUsedBinary === id) {
|
||||||
|
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
||||||
|
}
|
||||||
|
|
||||||
|
this.configStore.replace(nextConfig)
|
||||||
|
this.emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePath(path: string): BinaryValidationResult {
|
||||||
|
this.logger.debug({ path }, "Validating OpenCode binary path")
|
||||||
|
return this.validateRecord({
|
||||||
|
id: path,
|
||||||
|
path,
|
||||||
|
label: this.prettyLabel(path),
|
||||||
|
isDefault: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneConfig(config: ConfigFile): ConfigFile {
|
||||||
|
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRecords(): BinaryRecord[] {
|
||||||
|
|
||||||
|
const config = this.configStore.get()
|
||||||
|
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
||||||
|
id: binary.path,
|
||||||
|
path: binary.path,
|
||||||
|
label: binary.label ?? this.prettyLabel(binary.path),
|
||||||
|
version: binary.version,
|
||||||
|
isDefault: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
||||||
|
|
||||||
|
const annotated = configuredBinaries.map((binary) => ({
|
||||||
|
...binary,
|
||||||
|
isDefault: binary.path === defaultPath,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
||||||
|
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
return annotated
|
||||||
|
}
|
||||||
|
|
||||||
|
private getById(id: string): BinaryRecord {
|
||||||
|
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitChange() {
|
||||||
|
this.logger.debug("Emitting binaries changed event")
|
||||||
|
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||||
|
const inputPath = record.path
|
||||||
|
if (!inputPath) {
|
||||||
|
return { valid: false, error: "Missing binary path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = buildSpawnSpec(inputPath, ["--version"])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(spec.command, spec.args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { valid: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim()
|
||||||
|
const stdout = result.stdout?.trim()
|
||||||
|
const combined = stderr || stdout
|
||||||
|
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||||
|
return { valid: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = (result.stdout ?? "").trim()
|
||||||
|
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
||||||
|
const normalized = firstLine?.trim()
|
||||||
|
|
||||||
|
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||||
|
const version = versionMatch?.[1]
|
||||||
|
|
||||||
|
return { valid: true, version }
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFallbackRecord(path: string): BinaryRecord {
|
||||||
|
return {
|
||||||
|
id: path,
|
||||||
|
path,
|
||||||
|
label: this.prettyLabel(path),
|
||||||
|
isDefault: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prettyLabel(path: string) {
|
||||||
|
const parts = path.split(/[\\/]/)
|
||||||
|
const last = parts[parts.length - 1] || path
|
||||||
|
return last || path
|
||||||
|
}
|
||||||
|
}
|
||||||
244
packages/server/src/config/store.ts
Normal file
244
packages/server/src/config/store.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
import {
|
||||||
|
ConfigFile,
|
||||||
|
ConfigFileSchema,
|
||||||
|
ConfigYamlSchema,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
DEFAULT_CONFIG_YAML,
|
||||||
|
DEFAULT_STATE,
|
||||||
|
StateFile,
|
||||||
|
StateFileSchema,
|
||||||
|
} from "./schema"
|
||||||
|
import type { ConfigLocation } from "./location"
|
||||||
|
|
||||||
|
export class ConfigStore {
|
||||||
|
private cache: ConfigFile = DEFAULT_CONFIG
|
||||||
|
private state: StateFile = DEFAULT_STATE
|
||||||
|
private loaded = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly location: ConfigLocation,
|
||||||
|
private readonly eventBus: EventBus | undefined,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
load(): ConfigFile {
|
||||||
|
if (this.loaded) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configYamlPath = this.location.configYamlPath
|
||||||
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
|
const legacyJsonPath = this.location.legacyJsonPath
|
||||||
|
|
||||||
|
if (fs.existsSync(configYamlPath)) {
|
||||||
|
const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
|
||||||
|
const stateDoc = fs.existsSync(stateYamlPath)
|
||||||
|
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
|
||||||
|
: DEFAULT_STATE
|
||||||
|
|
||||||
|
this.state = stateDoc
|
||||||
|
this.cache = this.mergeDocs(configDoc, stateDoc)
|
||||||
|
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
|
||||||
|
} else if (fs.existsSync(legacyJsonPath)) {
|
||||||
|
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
|
||||||
|
this.state = migrated.state
|
||||||
|
this.cache = migrated.config
|
||||||
|
} else {
|
||||||
|
// Fresh install: write defaults.
|
||||||
|
this.state = DEFAULT_STATE
|
||||||
|
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||||
|
this.persist()
|
||||||
|
this.logger.debug(
|
||||||
|
{ configYamlPath, stateYamlPath },
|
||||||
|
"No config files found, created default YAML config/state",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
|
||||||
|
this.state = DEFAULT_STATE
|
||||||
|
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): ConfigFile {
|
||||||
|
return this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(config: ConfigFile) {
|
||||||
|
const validated = ConfigFileSchema.parse(config)
|
||||||
|
this.commit(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a merge-patch update to the current config.
|
||||||
|
* - Missing keys are preserved.
|
||||||
|
* - Object values are merged recursively.
|
||||||
|
* - Explicit `null` deletes keys.
|
||||||
|
* - Arrays are replaced.
|
||||||
|
*/
|
||||||
|
mergePatch(patch: unknown) {
|
||||||
|
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
||||||
|
throw new Error("Config patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const current = this.get()
|
||||||
|
const next = applyMergePatch(current as any, patch as any)
|
||||||
|
const validated = ConfigFileSchema.parse(next)
|
||||||
|
this.commit(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private commit(next: ConfigFile) {
|
||||||
|
this.cache = next
|
||||||
|
this.loaded = true
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
recentFolders: next.recentFolders,
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
const published = Boolean(this.eventBus)
|
||||||
|
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||||
|
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
||||||
|
this.logger.trace({ config: this.cache }, "Config payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
const configYamlPath = this.location.configYamlPath
|
||||||
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
|
|
||||||
|
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||||
|
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
|
||||||
|
|
||||||
|
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
|
||||||
|
const stateYaml = stringifyYaml(this.state as any)
|
||||||
|
|
||||||
|
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
|
||||||
|
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
|
||||||
|
|
||||||
|
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to persist config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
|
||||||
|
const merged = {
|
||||||
|
...(configDoc as any),
|
||||||
|
// State wins for recent folders.
|
||||||
|
recentFolders: stateDoc.recentFolders ?? [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigFileSchema.parse(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readYamlFile<T>(
|
||||||
|
filePath: string,
|
||||||
|
fallback: T,
|
||||||
|
schema: { parse: (value: unknown) => T },
|
||||||
|
label: string,
|
||||||
|
): T {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
const parsed = parseYaml(content)
|
||||||
|
return schema.parse(parsed ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
|
||||||
|
const configYamlPath = this.location.configYamlPath
|
||||||
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
|
|
||||||
|
const content = fs.readFileSync(legacyJsonPath, "utf-8")
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
const legacy = ConfigFileSchema.parse(parsed)
|
||||||
|
|
||||||
|
const state: StateFile = StateFileSchema.parse({
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
recentFolders: legacy.recentFolders ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
|
||||||
|
|
||||||
|
// Persist YAML docs first, then move legacy aside.
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||||
|
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
|
||||||
|
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
|
||||||
|
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bakPath = pickBackupPath(legacyJsonPath)
|
||||||
|
fs.renameSync(legacyJsonPath, bakPath)
|
||||||
|
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: merged, state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
|
||||||
|
const clone: Record<string, unknown> = { ...(config as any) }
|
||||||
|
delete clone.recentFolders
|
||||||
|
return clone as any
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
if (Array.isArray(value)) return false
|
||||||
|
const proto = Object.getPrototypeOf(value)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMergePatch(current: any, patch: any): any {
|
||||||
|
// RFC 7396-ish merge patch with explicit null deletes.
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = isPlainObject(current) ? { ...current } : {}
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete base[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value) && isPlainObject(base[key])) {
|
||||||
|
base[key] = applyMergePatch(base[key], value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrays and scalars replace.
|
||||||
|
base[key] = value
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBackupPath(legacyJsonPath: string): string {
|
||||||
|
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
|
||||||
|
const preferred = `${base}.json.bak`
|
||||||
|
if (!fs.existsSync(preferred)) {
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
return `${base}.json.bak.${Date.now()}`
|
||||||
|
}
|
||||||
@@ -24,8 +24,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
this.on("storage.configChanged", handler)
|
this.on("config.appChanged", handler)
|
||||||
this.on("storage.stateChanged", handler)
|
this.on("config.binariesChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", handler)
|
this.on("instance.eventStatus", handler)
|
||||||
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
this.off("storage.configChanged", handler)
|
this.off("config.appChanged", handler)
|
||||||
this.off("storage.stateChanged", handler)
|
this.off("config.binariesChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", handler)
|
this.off("instance.eventStatus", handler)
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { fileURLToPath } from "url"
|
|||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { createHttpServer } from "./server/http-server"
|
import { createHttpServer } from "./server/http-server"
|
||||||
import { WorkspaceManager } from "./workspaces/manager"
|
import { WorkspaceManager } from "./workspaces/manager"
|
||||||
|
import { ConfigStore } from "./config/store"
|
||||||
import { resolveConfigLocation } from "./config/location"
|
import { resolveConfigLocation } from "./config/location"
|
||||||
import { SettingsService } from "./settings/service"
|
import { BinaryRegistry } from "./config/binaries"
|
||||||
import { BinaryResolver } from "./settings/binaries"
|
|
||||||
import { FileSystemBrowser } from "./filesystem/browser"
|
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"
|
||||||
@@ -23,7 +23,6 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } fro
|
|||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||||
@@ -292,12 +291,21 @@ async function main() {
|
|||||||
|
|
||||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||||
|
|
||||||
const settings = new SettingsService(configLocation, eventBus, configLogger)
|
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
|
||||||
const binaryResolver = new BinaryResolver(settings)
|
|
||||||
|
// Eagerly load config at boot so migrations run immediately
|
||||||
|
// (instead of waiting for the first /api/config request).
|
||||||
|
try {
|
||||||
|
configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
settings,
|
configStore,
|
||||||
binaryResolver,
|
binaryRegistry,
|
||||||
eventBus,
|
eventBus,
|
||||||
logger: workspaceLogger,
|
logger: workspaceLogger,
|
||||||
getServerBaseUrl: () => serverMeta.localUrl,
|
getServerBaseUrl: () => serverMeta.localUrl,
|
||||||
@@ -305,7 +313,6 @@ async function main() {
|
|||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -385,12 +392,12 @@ async function main() {
|
|||||||
defaultPort: options.httpPort,
|
defaultPort: options.httpPort,
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
settings,
|
configStore,
|
||||||
|
binaryRegistry,
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
@@ -406,12 +413,12 @@ async function main() {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
httpsOptions: tlsResolution?.httpsOptions,
|
httpsOptions: tlsResolution?.httpsOptions,
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
settings,
|
configStore,
|
||||||
|
binaryRegistry,
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import type { Logger } from "../logger"
|
|||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
|
||||||
import type { SettingsService } from "../settings/service"
|
import { ConfigStore } from "../config/store"
|
||||||
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||||
import { registerSettingsRoutes } from "./routes/settings"
|
import { registerConfigRoutes } from "./routes/config"
|
||||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||||
import { registerMetaRoutes } from "./routes/meta"
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
import { registerEventRoutes } from "./routes/events"
|
import { registerEventRoutes } from "./routes/events"
|
||||||
@@ -21,14 +22,12 @@ import { registerStorageRoutes } from "./routes/storage"
|
|||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
import type { AuthManager } from "../auth/manager"
|
import type { AuthManager } from "../auth/manager"
|
||||||
import { registerAuthRoutes } from "./routes/auth"
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
import type { SpeechService } from "../speech/service"
|
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -38,12 +37,12 @@ interface HttpServerDeps {
|
|||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
settings: SettingsService
|
configStore: ConfigStore
|
||||||
|
binaryRegistry: BinaryRegistry
|
||||||
fileSystemBrowser: FileSystemBrowser
|
fileSystemBrowser: FileSystemBrowser
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
speechService: SpeechService
|
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
@@ -245,7 +244,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
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, logger: sseLogger })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||||
@@ -255,7 +254,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
|
||||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
@@ -371,21 +369,6 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
|||||||
|
|
||||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||||
|
|
||||||
// Special-case OpenCode directory override.
|
|
||||||
//
|
|
||||||
// UI clients may need to scope certain requests to an arbitrary directory that is not
|
|
||||||
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
|
|
||||||
// injecting per-request headers, we encode an override into the *path* and strip it
|
|
||||||
// before proxying to the instance.
|
|
||||||
//
|
|
||||||
// Example proxied request path:
|
|
||||||
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
|
|
||||||
//
|
|
||||||
// The server will decode <base64url> -> absolute directory, validate it, then set
|
|
||||||
// x-opencode-directory accordingly and forward the request to /session/create.
|
|
||||||
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
|
|
||||||
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
|
|
||||||
|
|
||||||
async function proxyWorkspaceRequest(args: {
|
async function proxyWorkspaceRequest(args: {
|
||||||
request: FastifyRequest
|
request: FastifyRequest
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
@@ -476,43 +459,19 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
const directory = await resolveWorktreeDirectory({
|
||||||
try {
|
workspaceId,
|
||||||
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
workspacePath: workspace.path,
|
||||||
} catch (error) {
|
worktreeSlug,
|
||||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
logger,
|
||||||
reply.code(400).send({ error: message })
|
})
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
reply.code(404).send({ error: "Worktree not found" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let directory: string | null = null
|
|
||||||
let forwardedSuffix = extracted.forwardedSuffix
|
|
||||||
|
|
||||||
if (extracted.overrideDirectory) {
|
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||||
try {
|
|
||||||
directory = validateAndNormalizeOverrideDirectory({
|
|
||||||
overrideDirectory: extracted.overrideDirectory,
|
|
||||||
workspaceRoot: workspace.path,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
|
||||||
reply.code(400).send({ error: message })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
directory = await resolveWorktreeDirectory({
|
|
||||||
workspaceId,
|
|
||||||
workspacePath: workspace.path,
|
|
||||||
worktreeSlug,
|
|
||||||
logger,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!directory) {
|
|
||||||
reply.code(404).send({ error: "Worktree not found" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
|
|
||||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||||
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}`
|
||||||
@@ -576,89 +535,6 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
|
||||||
overrideDirectory: string | null
|
|
||||||
forwardedSuffix: string | undefined
|
|
||||||
} {
|
|
||||||
if (!pathSuffix) {
|
|
||||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fastify wildcard param does not include a leading slash.
|
|
||||||
const trimmed = pathSuffix.replace(/^\/+/, "")
|
|
||||||
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
|
|
||||||
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
|
||||||
}
|
|
||||||
|
|
||||||
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
|
|
||||||
const slashIndex = rest.indexOf("/")
|
|
||||||
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
|
|
||||||
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
|
|
||||||
|
|
||||||
if (!encoded) {
|
|
||||||
throw new Error("Missing directory override")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
|
|
||||||
throw new Error("Directory override too large")
|
|
||||||
}
|
|
||||||
|
|
||||||
let overrideDirectory = ""
|
|
||||||
try {
|
|
||||||
overrideDirectory = decodeBase64Url(encoded)
|
|
||||||
} catch {
|
|
||||||
throw new Error("Invalid directory override")
|
|
||||||
}
|
|
||||||
const forwardedSuffix = remaining
|
|
||||||
return { overrideDirectory, forwardedSuffix }
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeBase64Url(input: string): string {
|
|
||||||
// base64url -> base64
|
|
||||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
|
|
||||||
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
|
|
||||||
const base64 = `${normalized}${padding}`
|
|
||||||
return Buffer.from(base64, "base64").toString("utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
|
|
||||||
const raw = params.overrideDirectory.trim()
|
|
||||||
if (!raw) {
|
|
||||||
throw new Error("Override directory is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!path.isAbsolute(raw)) {
|
|
||||||
throw new Error("Override directory must be an absolute path")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(raw)) {
|
|
||||||
throw new Error(`Override directory does not exist: ${raw}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = fs.statSync(raw)
|
|
||||||
if (!stats.isDirectory()) {
|
|
||||||
throw new Error(`Override path is not a directory: ${raw}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedOverride = fs.realpathSync(raw)
|
|
||||||
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
|
|
||||||
|
|
||||||
if (!isSubpath(normalizedOverride, normalizedRoot)) {
|
|
||||||
throw new Error("Override directory must be within the workspace root")
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSubpath(candidate: string, root: string): boolean {
|
|
||||||
const rel = path.relative(root, candidate)
|
|
||||||
if (rel === "") return true
|
|
||||||
if (rel === "..") return false
|
|
||||||
if (rel.startsWith(`..${path.sep}`)) return false
|
|
||||||
if (path.isAbsolute(rel)) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||||
if (!pathSuffix || pathSuffix === "/") {
|
if (!pathSuffix || pathSuffix === "/") {
|
||||||
return "/"
|
return "/"
|
||||||
|
|||||||
@@ -119,8 +119,7 @@
|
|||||||
showError(message || `Login failed (${res.status})`)
|
showError(message || `Login failed (${res.status})`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Replace history entry so Back doesn't return to /login.
|
window.location.href = "/"
|
||||||
window.location.replace("/")
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(e && e.message ? e.message : String(e))
|
showError(e && e.message ? e.message : String(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,19 +51,7 @@ function getTokenHtml(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/login", async (request, reply) => {
|
app.get("/login", async (_request, reply) => {
|
||||||
// If already authenticated, don't show the login page.
|
|
||||||
const session = deps.authManager.getSessionFromRequest(request)
|
|
||||||
if (session) {
|
|
||||||
reply.redirect("/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid caching the login page (helps with bfcache/back behavior).
|
|
||||||
reply.header("Cache-Control", "no-store")
|
|
||||||
reply.header("Pragma", "no-cache")
|
|
||||||
reply.header("Expires", "0")
|
|
||||||
|
|
||||||
const status = deps.authManager.getStatus()
|
const status = deps.authManager.getStatus()
|
||||||
reply.type("text/html").send(getLoginHtml(status.username))
|
reply.type("text/html").send(getLoginHtml(status.username))
|
||||||
})
|
})
|
||||||
@@ -79,11 +67,6 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid caching the token bootstrap page.
|
|
||||||
reply.header("Cache-Control", "no-store")
|
|
||||||
reply.header("Pragma", "no-cache")
|
|
||||||
reply.header("Expires", "0")
|
|
||||||
|
|
||||||
reply.type("text/html").send(getTokenHtml())
|
reply.type("text/html").send(getTokenHtml())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
76
packages/server/src/server/routes/config.ts
Normal file
76
packages/server/src/server/routes/config.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { ConfigStore } from "../../config/store"
|
||||||
|
import { BinaryRegistry } from "../../config/binaries"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
configStore: ConfigStore
|
||||||
|
binaryRegistry: BinaryRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
const BinaryCreateSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
label: z.string().optional(),
|
||||||
|
makeDefault: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const BinaryUpdateSchema = z.object({
|
||||||
|
label: z.string().optional(),
|
||||||
|
makeDefault: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const BinaryValidateSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/config/app", async () => deps.configStore.get())
|
||||||
|
|
||||||
|
app.put("/api/config/app", async (request, reply) => {
|
||||||
|
// Backwards compatible: treat PUT as a merge-patch update.
|
||||||
|
try {
|
||||||
|
deps.configStore.mergePatch(request.body ?? {})
|
||||||
|
return deps.configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/api/config/app", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
deps.configStore.mergePatch(request.body ?? {})
|
||||||
|
return deps.configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/config/binaries", async () => {
|
||||||
|
return { binaries: deps.binaryRegistry.list() }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/config/binaries", async (request, reply) => {
|
||||||
|
const body = BinaryCreateSchema.parse(request.body ?? {})
|
||||||
|
const binary = deps.binaryRegistry.create(body)
|
||||||
|
reply.code(201)
|
||||||
|
return { binary }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
||||||
|
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
||||||
|
const binary = deps.binaryRegistry.update(request.params.id, body)
|
||||||
|
return { binary }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
||||||
|
deps.binaryRegistry.remove(request.params.id)
|
||||||
|
reply.code(204)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/config/binaries/validate", async (request) => {
|
||||||
|
const body = BinaryValidateSchema.parse(request.body ?? {})
|
||||||
|
return deps.binaryRegistry.validatePath(body.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
|
||||||
import { z } from "zod"
|
|
||||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
|
||||||
import type { SettingsService } from "../../settings/service"
|
|
||||||
import type { Logger } from "../../logger"
|
|
||||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
|
||||||
settings: SettingsService
|
|
||||||
logger: Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
const ValidateBinarySchema = z.object({
|
|
||||||
path: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
|
|
||||||
const result = probeBinaryVersion(binaryPath)
|
|
||||||
return { valid: result.valid, version: result.version, error: result.error }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|
||||||
// Full-document access
|
|
||||||
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
|
||||||
app.patch("/api/storage/config", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
|
|
||||||
} catch (error) {
|
|
||||||
reply.code(400)
|
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
|
||||||
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
|
|
||||||
})
|
|
||||||
|
|
||||||
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
return sanitizeConfigOwner(
|
|
||||||
request.params.owner,
|
|
||||||
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
reply.code(400)
|
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/api/storage/state", async () => deps.settings.getDoc("state"))
|
|
||||||
app.patch("/api/storage/state", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
return deps.settings.mergePatchDoc("state", request.body ?? {})
|
|
||||||
} catch (error) {
|
|
||||||
reply.code(400)
|
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => {
|
|
||||||
return deps.settings.getOwner("state", request.params.owner)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {})
|
|
||||||
} catch (error) {
|
|
||||||
reply.code(400)
|
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Binary validation helper (used by UI when adding binaries)
|
|
||||||
app.post("/api/storage/binaries/validate", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const body = ValidateBinarySchema.parse(request.body ?? {})
|
|
||||||
return validateBinaryPath(body.path)
|
|
||||||
} catch (error) {
|
|
||||||
deps.logger.warn({ err: error }, "Failed to validate binary")
|
|
||||||
reply.code(400)
|
|
||||||
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { FastifyInstance } from "fastify"
|
|
||||||
import { z } from "zod"
|
|
||||||
import type { SpeechService } from "../../speech/service"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
|
||||||
speechService: SpeechService
|
|
||||||
}
|
|
||||||
|
|
||||||
const TranscribeBodySchema = z.object({
|
|
||||||
audioBase64: z.string().min(1, "Audio payload is required"),
|
|
||||||
mimeType: z.string().min(1, "Audio MIME type is required"),
|
|
||||||
filename: z.string().optional(),
|
|
||||||
language: z.string().optional(),
|
|
||||||
prompt: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const SynthesizeBodySchema = z.object({
|
|
||||||
text: z.string().trim().min(1, "Text is required"),
|
|
||||||
format: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
function getSpeechErrorStatus(error: unknown): number {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return 400
|
|
||||||
}
|
|
||||||
if (error instanceof Error && /not configured/i.test(error.message)) {
|
|
||||||
return 503
|
|
||||||
}
|
|
||||||
return 502
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSpeechErrorMessage(error: unknown, fallback: string): string {
|
|
||||||
return error instanceof Error ? error.message : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|
||||||
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
|
|
||||||
|
|
||||||
app.post("/api/speech/transcribe", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const body = TranscribeBodySchema.parse(request.body ?? {})
|
|
||||||
return await deps.speechService.transcribe(body)
|
|
||||||
} catch (error) {
|
|
||||||
request.log.error({ err: error }, "Failed to transcribe audio")
|
|
||||||
reply.code(getSpeechErrorStatus(error))
|
|
||||||
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/api/speech/synthesize", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
|
||||||
return await deps.speechService.synthesize(body)
|
|
||||||
} catch (error) {
|
|
||||||
request.log.error({ err: error }, "Failed to synthesize audio")
|
|
||||||
reply.code(getSpeechErrorStatus(error))
|
|
||||||
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/api/speech/synthesize/stream", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
|
||||||
const result = await deps.speechService.synthesizeStream(body)
|
|
||||||
reply.header("Content-Type", result.mimeType)
|
|
||||||
reply.header("Cache-Control", "no-store")
|
|
||||||
return reply.send(result.stream)
|
|
||||||
} catch (error) {
|
|
||||||
request.log.error({ err: error }, "Failed to stream synthesized audio")
|
|
||||||
reply.code(getSpeechErrorStatus(error))
|
|
||||||
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { SettingsService } from "./service"
|
|
||||||
|
|
||||||
export interface OpenCodeBinaryEntry {
|
|
||||||
path: string
|
|
||||||
version?: string
|
|
||||||
lastUsed?: number
|
|
||||||
label?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolvedBinary {
|
|
||||||
path: string
|
|
||||||
label: string
|
|
||||||
version?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function prettyLabel(p: string): string {
|
|
||||||
const parts = p.split(/[\\/]/)
|
|
||||||
const last = parts[parts.length - 1] || p
|
|
||||||
return last || p
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
|
|
||||||
const ui = settings.getOwner("state", "ui")
|
|
||||||
const list = (ui as any)?.opencodeBinaries
|
|
||||||
if (!Array.isArray(list)) return []
|
|
||||||
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
|
|
||||||
const server = settings.getOwner("config", "server")
|
|
||||||
const value = (server as any)?.opencodeBinary
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BinaryResolver {
|
|
||||||
constructor(private readonly settings: SettingsService) {}
|
|
||||||
|
|
||||||
list(): OpenCodeBinaryEntry[] {
|
|
||||||
return readUiBinaries(this.settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveDefault(): ResolvedBinary {
|
|
||||||
const binaries = this.list()
|
|
||||||
const configuredDefault = readDefaultBinaryPath(this.settings)
|
|
||||||
const fallback = binaries[0]?.path
|
|
||||||
const path = configuredDefault ?? fallback ?? "opencode"
|
|
||||||
|
|
||||||
const entry = binaries.find((b) => b.path === path)
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
label: entry?.label ?? prettyLabel(path),
|
|
||||||
version: entry?.version,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
type PlainObject = Record<string, unknown>
|
|
||||||
|
|
||||||
export function isPlainObject(value: unknown): value is PlainObject {
|
|
||||||
if (!value || typeof value !== "object") return false
|
|
||||||
if (Array.isArray(value)) return false
|
|
||||||
const proto = Object.getPrototypeOf(value)
|
|
||||||
return proto === Object.prototype || proto === null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RFC 7396-ish merge patch with explicit null deletes.
|
|
||||||
* - Objects merge recursively
|
|
||||||
* - Arrays/scalars replace
|
|
||||||
* - null deletes keys
|
|
||||||
*/
|
|
||||||
export function applyMergePatch(current: unknown, patch: unknown): unknown {
|
|
||||||
if (!isPlainObject(patch)) {
|
|
||||||
return patch
|
|
||||||
}
|
|
||||||
|
|
||||||
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(patch)) {
|
|
||||||
if (value === null) {
|
|
||||||
delete base[key]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = base[key]
|
|
||||||
if (isPlainObject(value) && isPlainObject(existing)) {
|
|
||||||
base[key] = applyMergePatch(existing, value)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
base[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
|
||||||
import type { Logger } from "../logger"
|
|
||||||
import type { ConfigLocation } from "../config/location"
|
|
||||||
import { isPlainObject } from "./merge-patch"
|
|
||||||
|
|
||||||
type Doc = Record<string, unknown>
|
|
||||||
|
|
||||||
function ensureTrailingNewline(content: string): string {
|
|
||||||
if (!content) return "\n"
|
|
||||||
return content.endsWith("\n") ? content : `${content}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeReadYaml(filePath: string, logger: Logger): unknown {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8")
|
|
||||||
return parseYaml(content)
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeReadJson(filePath: string, logger: Logger): unknown {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8")
|
|
||||||
return JSON.parse(content)
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
||||||
const yaml = stringifyYaml(doc as any)
|
|
||||||
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickBackupPath(filePath: string): string {
|
|
||||||
const preferred = `${filePath}.bak`
|
|
||||||
if (!fs.existsSync(preferred)) {
|
|
||||||
return preferred
|
|
||||||
}
|
|
||||||
return `${filePath}.bak.${Date.now()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDoc(value: unknown): Doc {
|
|
||||||
return isPlainObject(value) ? (value as Doc) : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeNewOwnerDoc(value: unknown): boolean {
|
|
||||||
const doc = normalizeDoc(value)
|
|
||||||
// Heuristic: owner-bucket docs have at least one of these roots.
|
|
||||||
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeLegacyConfig(value: unknown): boolean {
|
|
||||||
const doc = normalizeDoc(value)
|
|
||||||
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeLegacyState(value: unknown): boolean {
|
|
||||||
const doc = normalizeDoc(value)
|
|
||||||
return Boolean(doc.recentFolders)
|
|
||||||
}
|
|
||||||
|
|
||||||
function omitKeys(source: Doc, keys: Set<string>): Doc {
|
|
||||||
const out: Doc = {}
|
|
||||||
for (const [k, v] of Object.entries(source)) {
|
|
||||||
if (keys.has(k)) continue
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
|
|
||||||
const cfg = normalizeDoc(legacyConfig)
|
|
||||||
const st = normalizeDoc(legacyState)
|
|
||||||
|
|
||||||
const outConfig: Doc = {}
|
|
||||||
const outState: Doc = {}
|
|
||||||
|
|
||||||
const uiConfig: Doc = {}
|
|
||||||
const uiSettings: Doc = {}
|
|
||||||
const serverConfig: Doc = {}
|
|
||||||
const uiState: Doc = {}
|
|
||||||
|
|
||||||
// theme -> config.ui.theme
|
|
||||||
if (typeof cfg.theme === "string") {
|
|
||||||
uiConfig.theme = cfg.theme
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferences = normalizeDoc(cfg.preferences)
|
|
||||||
if (Object.keys(preferences).length > 0) {
|
|
||||||
// Server-owned stable keys
|
|
||||||
const envVars = preferences.environmentVariables
|
|
||||||
if (isPlainObject(envVars)) {
|
|
||||||
serverConfig.environmentVariables = envVars
|
|
||||||
}
|
|
||||||
const listeningMode = preferences.listeningMode
|
|
||||||
if (typeof listeningMode === "string") {
|
|
||||||
serverConfig.listeningMode = listeningMode
|
|
||||||
}
|
|
||||||
const lastUsedBinary = preferences.lastUsedBinary
|
|
||||||
if (typeof lastUsedBinary === "string") {
|
|
||||||
serverConfig.opencodeBinary = lastUsedBinary
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI-owned state keys (drop preferences)
|
|
||||||
const modelRecents = preferences.modelRecents
|
|
||||||
const modelFavorites = preferences.modelFavorites
|
|
||||||
const modelThinkingSelections = preferences.modelThinkingSelections
|
|
||||||
|
|
||||||
const models: Doc = {}
|
|
||||||
if (Array.isArray(modelRecents)) {
|
|
||||||
models.recents = modelRecents
|
|
||||||
}
|
|
||||||
if (Array.isArray(modelFavorites)) {
|
|
||||||
models.favorites = modelFavorites
|
|
||||||
}
|
|
||||||
if (isPlainObject(modelThinkingSelections)) {
|
|
||||||
models.thinkingSelections = modelThinkingSelections
|
|
||||||
}
|
|
||||||
if (Object.keys(models).length > 0) {
|
|
||||||
uiState.models = models
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remaining preferences are treated as stable UI settings.
|
|
||||||
const moved = new Set([
|
|
||||||
"environmentVariables",
|
|
||||||
"listeningMode",
|
|
||||||
"lastUsedBinary",
|
|
||||||
"modelRecents",
|
|
||||||
"modelFavorites",
|
|
||||||
"modelThinkingSelections",
|
|
||||||
])
|
|
||||||
Object.assign(uiSettings, omitKeys(preferences, moved))
|
|
||||||
}
|
|
||||||
|
|
||||||
// recentFolders lives in legacy state (yaml) or legacy config.json
|
|
||||||
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
|
|
||||||
if (Array.isArray(recentFolders)) {
|
|
||||||
uiState.recentFolders = recentFolders
|
|
||||||
}
|
|
||||||
|
|
||||||
// opencodeBinaries -> state.ui.opencodeBinaries
|
|
||||||
if (Array.isArray(cfg.opencodeBinaries)) {
|
|
||||||
uiState.opencodeBinaries = cfg.opencodeBinaries
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(uiSettings).length > 0) {
|
|
||||||
uiConfig.settings = uiSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(uiConfig).length > 0) {
|
|
||||||
outConfig.ui = uiConfig
|
|
||||||
}
|
|
||||||
if (Object.keys(serverConfig).length > 0) {
|
|
||||||
outConfig.server = serverConfig
|
|
||||||
}
|
|
||||||
if (Object.keys(uiState).length > 0) {
|
|
||||||
outState.ui = uiState
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown top-level keys -> legacy.unknown
|
|
||||||
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
|
|
||||||
const unknownConfig = omitKeys(cfg, knownConfigKeys)
|
|
||||||
if (Object.keys(unknownConfig).length > 0) {
|
|
||||||
outConfig.legacy = { unknown: unknownConfig }
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownStateKeys = new Set(["recentFolders"])
|
|
||||||
const unknownState = omitKeys(st, knownStateKeys)
|
|
||||||
if (Object.keys(unknownState).length > 0) {
|
|
||||||
outState.legacy = { unknown: unknownState }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { config: outConfig, state: outState }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate older config/state layouts into owner-bucket YAML docs.
|
|
||||||
*
|
|
||||||
* Legacy inputs supported:
|
|
||||||
* - config.yaml with { preferences, opencodeBinaries, theme }
|
|
||||||
* - state.yaml with { recentFolders }
|
|
||||||
* - legacy config.json with full ConfigFile schema
|
|
||||||
*/
|
|
||||||
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
|
|
||||||
const configYamlPath = location.configYamlPath
|
|
||||||
const stateYamlPath = location.stateYamlPath
|
|
||||||
const legacyJsonPath = location.legacyJsonPath
|
|
||||||
|
|
||||||
const configExists = fs.existsSync(configYamlPath)
|
|
||||||
const stateExists = fs.existsSync(stateYamlPath)
|
|
||||||
|
|
||||||
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
|
|
||||||
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
|
|
||||||
|
|
||||||
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
|
|
||||||
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
|
|
||||||
|
|
||||||
if (configIsNew && stateIsNew) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyJsonExists = fs.existsSync(legacyJsonPath)
|
|
||||||
|
|
||||||
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
|
|
||||||
const shouldMigrateFromJson = !configExists && legacyJsonExists
|
|
||||||
|
|
||||||
if (!hasLegacyYaml && !shouldMigrateFromJson) {
|
|
||||||
// Either fresh install or partially written docs; let stores create on first write.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
|
|
||||||
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
|
|
||||||
|
|
||||||
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(location.baseDir, { recursive: true })
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backup legacy files before rewriting.
|
|
||||||
if (configExists) {
|
|
||||||
try {
|
|
||||||
const bak = pickBackupPath(configYamlPath)
|
|
||||||
fs.renameSync(configYamlPath, bak)
|
|
||||||
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateExists) {
|
|
||||||
try {
|
|
||||||
const bak = pickBackupPath(stateYamlPath)
|
|
||||||
fs.renameSync(stateYamlPath, bak)
|
|
||||||
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldMigrateFromJson) {
|
|
||||||
try {
|
|
||||||
const bak = pickBackupPath(legacyJsonPath)
|
|
||||||
fs.renameSync(legacyJsonPath, bak)
|
|
||||||
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeYaml(configYamlPath, config, logger)
|
|
||||||
writeYaml(stateYamlPath, state, logger)
|
|
||||||
|
|
||||||
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { SettingsDoc } from "./yaml-doc-store"
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
|
|
||||||
const next: SettingsDoc = { ...value }
|
|
||||||
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
|
|
||||||
|
|
||||||
if (!speech) {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
|
|
||||||
if (rawApiKey) {
|
|
||||||
delete speech.apiKey
|
|
||||||
speech.hasApiKey = true
|
|
||||||
} else if (!("hasApiKey" in speech)) {
|
|
||||||
speech.hasApiKey = false
|
|
||||||
}
|
|
||||||
|
|
||||||
next.speech = speech
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
|
|
||||||
if (owner !== "server") {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return sanitizeServerOwner(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
|
|
||||||
const next: SettingsDoc = { ...value }
|
|
||||||
if (isPlainObject(next.server)) {
|
|
||||||
next.server = sanitizeServerOwner(next.server)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { Logger } from "../logger"
|
|
||||||
import type { EventBus } from "../events/bus"
|
|
||||||
import type { ConfigLocation } from "../config/location"
|
|
||||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
|
||||||
import { migrateSettingsLayout } from "./migrate"
|
|
||||||
import type { WorkspaceEventPayload } from "../api-types"
|
|
||||||
import { sanitizeConfigOwner } from "./public-config"
|
|
||||||
|
|
||||||
export type DocKind = "config" | "state"
|
|
||||||
|
|
||||||
export class SettingsService {
|
|
||||||
private readonly configStore: YamlDocStore
|
|
||||||
private readonly stateStore: YamlDocStore
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly location: ConfigLocation,
|
|
||||||
private readonly eventBus: EventBus | undefined,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {
|
|
||||||
migrateSettingsLayout(location, logger)
|
|
||||||
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
|
||||||
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
getDoc(kind: DocKind): SettingsDoc {
|
|
||||||
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
|
||||||
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
|
||||||
this.publish(kind, "*")
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
|
||||||
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
|
||||||
}
|
|
||||||
|
|
||||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
|
||||||
const updated =
|
|
||||||
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
|
||||||
this.publish(kind, owner, updated)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
|
||||||
if (!this.eventBus) return
|
|
||||||
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
|
||||||
const nextValue = value ?? this.getOwner(kind, owner)
|
|
||||||
const payload: WorkspaceEventPayload = {
|
|
||||||
type,
|
|
||||||
owner,
|
|
||||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
|
||||||
} as any
|
|
||||||
this.eventBus.publish(payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
|
||||||
import type { Logger } from "../logger"
|
|
||||||
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
|
||||||
|
|
||||||
export type SettingsDoc = Record<string, unknown>
|
|
||||||
|
|
||||||
function ensureTrailingNewline(content: string): string {
|
|
||||||
if (!content) return "\n"
|
|
||||||
return content.endsWith("\n") ? content : `${content}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDoc(input: unknown): SettingsDoc {
|
|
||||||
if (!isPlainObject(input)) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
export class YamlDocStore {
|
|
||||||
private cache: SettingsDoc = {}
|
|
||||||
private loaded = false
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly filePath: string,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
load(): SettingsDoc {
|
|
||||||
if (this.loaded) {
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(this.filePath)) {
|
|
||||||
this.cache = {}
|
|
||||||
this.loaded = true
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = fs.readFileSync(this.filePath, "utf-8")
|
|
||||||
const parsed = parseYaml(content)
|
|
||||||
this.cache = normalizeDoc(parsed)
|
|
||||||
this.loaded = true
|
|
||||||
return this.cache
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
|
|
||||||
this.cache = {}
|
|
||||||
this.loaded = true
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): SettingsDoc {
|
|
||||||
return this.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
replace(next: unknown): SettingsDoc {
|
|
||||||
const normalized = normalizeDoc(next)
|
|
||||||
this.cache = normalized
|
|
||||||
this.loaded = true
|
|
||||||
this.persist()
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
mergePatch(patch: unknown): SettingsDoc {
|
|
||||||
if (!isPlainObject(patch)) {
|
|
||||||
throw new Error("Patch must be a JSON object")
|
|
||||||
}
|
|
||||||
const current = this.get()
|
|
||||||
const next = applyMergePatch(current, patch)
|
|
||||||
return this.replace(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
getOwner(owner: string): SettingsDoc {
|
|
||||||
const doc = this.get()
|
|
||||||
const value = (doc as any)?.[owner]
|
|
||||||
return normalizeDoc(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceOwner(owner: string, value: unknown): SettingsDoc {
|
|
||||||
const doc = this.get()
|
|
||||||
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
|
|
||||||
this.replace(nextDoc)
|
|
||||||
return nextDoc[owner] as SettingsDoc
|
|
||||||
}
|
|
||||||
|
|
||||||
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
|
|
||||||
if (!isPlainObject(patch)) {
|
|
||||||
throw new Error("Patch must be a JSON object")
|
|
||||||
}
|
|
||||||
const doc = this.get()
|
|
||||||
const currentOwner = normalizeDoc((doc as any)?.[owner])
|
|
||||||
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
|
|
||||||
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
|
|
||||||
this.replace(nextDoc)
|
|
||||||
return nextOwner
|
|
||||||
}
|
|
||||||
|
|
||||||
private persist() {
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
|
||||||
const yaml = stringifyYaml(this.cache as any)
|
|
||||||
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import { Readable } from "node:stream"
|
|
||||||
import OpenAI from "openai"
|
|
||||||
import { toFile } from "openai/uploads"
|
|
||||||
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
|
|
||||||
import type { Logger } from "../../logger"
|
|
||||||
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
|
|
||||||
|
|
||||||
interface OpenAICompatibleSpeechProviderOptions {
|
|
||||||
settings: NormalizedSpeechSettings
|
|
||||||
logger: Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OpenAICompatibleSpeechProvider {
|
|
||||||
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
|
|
||||||
|
|
||||||
getCapabilities() {
|
|
||||||
const { settings } = this.options
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
configured: Boolean(settings.apiKey),
|
|
||||||
provider: settings.provider,
|
|
||||||
supportsStt: true,
|
|
||||||
supportsTts: true,
|
|
||||||
supportsStreamingTts: true,
|
|
||||||
baseUrl: settings.baseUrl,
|
|
||||||
sttModel: settings.sttModel,
|
|
||||||
ttsModel: settings.ttsModel,
|
|
||||||
ttsVoice: settings.ttsVoice,
|
|
||||||
ttsFormats: ["mp3", "wav", "opus", "aac"],
|
|
||||||
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
|
||||||
const client = this.createClient()
|
|
||||||
const startedAt = Date.now()
|
|
||||||
const extension = extensionForMime(input.mimeType)
|
|
||||||
const buffer = Buffer.from(input.audioBase64, "base64")
|
|
||||||
const filename = input.filename?.trim() || `prompt-input.${extension}`
|
|
||||||
|
|
||||||
this.options.logger.info(
|
|
||||||
{
|
|
||||||
mimeType: input.mimeType,
|
|
||||||
bytes: buffer.byteLength,
|
|
||||||
language: input.language,
|
|
||||||
model: this.options.settings.sttModel,
|
|
||||||
},
|
|
||||||
"speech.transcribe",
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = await this.requestTranscription(client, buffer, filename, input)
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: typeof response?.text === "string" ? response.text : "",
|
|
||||||
language: typeof response?.language === "string" ? response.language : input.language,
|
|
||||||
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
|
|
||||||
segments: Array.isArray(response?.segments)
|
|
||||||
? response.segments
|
|
||||||
.filter((segment: any) => typeof segment?.text === "string")
|
|
||||||
.map((segment: any) => ({
|
|
||||||
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
|
|
||||||
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
|
|
||||||
text: String(segment.text),
|
|
||||||
}))
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestTranscription(
|
|
||||||
client: OpenAI,
|
|
||||||
buffer: Buffer,
|
|
||||||
filename: string,
|
|
||||||
input: TranscribeAudioInput,
|
|
||||||
): Promise<any> {
|
|
||||||
const baseRequest = {
|
|
||||||
model: this.options.settings.sttModel,
|
|
||||||
...(input.language ? { language: input.language } : {}),
|
|
||||||
...(input.prompt ? { prompt: input.prompt } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const file = await toFile(buffer, filename, { type: input.mimeType })
|
|
||||||
return (await client.audio.transcriptions.create({
|
|
||||||
...baseRequest,
|
|
||||||
file,
|
|
||||||
response_format: "verbose_json" as any,
|
|
||||||
} as any)) as any
|
|
||||||
} catch (error) {
|
|
||||||
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
|
|
||||||
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
|
|
||||||
return (await client.audio.transcriptions.create({
|
|
||||||
...baseRequest,
|
|
||||||
file: retryFile,
|
|
||||||
} as any)) as any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
|
||||||
const format = input.format ?? this.options.settings.ttsFormat
|
|
||||||
|
|
||||||
this.options.logger.info(
|
|
||||||
{
|
|
||||||
model: this.options.settings.ttsModel,
|
|
||||||
voice: this.options.settings.ttsVoice,
|
|
||||||
format,
|
|
||||||
},
|
|
||||||
"speech.synthesize",
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = await this.requestSpeechAudio(input.text, format)
|
|
||||||
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
|
|
||||||
|
|
||||||
const audioBuffer = Buffer.from(await response.arrayBuffer())
|
|
||||||
return {
|
|
||||||
audioBase64: audioBuffer.toString("base64"),
|
|
||||||
mimeType,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
|
||||||
const format = input.format ?? this.options.settings.ttsFormat
|
|
||||||
|
|
||||||
this.options.logger.info(
|
|
||||||
{
|
|
||||||
model: this.options.settings.ttsModel,
|
|
||||||
voice: this.options.settings.ttsVoice,
|
|
||||||
format,
|
|
||||||
},
|
|
||||||
"speech.synthesize.stream",
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = await this.requestSpeechAudio(input.text, format)
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("Speech provider did not return a stream.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
stream: Readable.fromWeb(response.body as any),
|
|
||||||
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
|
|
||||||
const { settings } = this.options
|
|
||||||
if (!settings.apiKey) {
|
|
||||||
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
|
||||||
let response: Response
|
|
||||||
try {
|
|
||||||
response = await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${settings.apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: settings.ttsModel,
|
|
||||||
voice: settings.ttsVoice,
|
|
||||||
input: text,
|
|
||||||
response_format: format,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
const detailedError = error as Error & {
|
|
||||||
cause?: unknown
|
|
||||||
code?: string
|
|
||||||
errno?: number | string
|
|
||||||
syscall?: string
|
|
||||||
address?: string
|
|
||||||
port?: number
|
|
||||||
}
|
|
||||||
this.options.logger.error(
|
|
||||||
{
|
|
||||||
err: error,
|
|
||||||
endpoint: endpoint.toString(),
|
|
||||||
baseUrl: settings.baseUrl,
|
|
||||||
model: settings.ttsModel,
|
|
||||||
voice: settings.ttsVoice,
|
|
||||||
format,
|
|
||||||
cause: detailedError.cause,
|
|
||||||
code: detailedError.code,
|
|
||||||
errno: detailedError.errno,
|
|
||||||
syscall: detailedError.syscall,
|
|
||||||
address: detailedError.address,
|
|
||||||
port: detailedError.port,
|
|
||||||
},
|
|
||||||
"speech.synthesize fetch failed",
|
|
||||||
)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = await response.text()
|
|
||||||
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
private createClient(): OpenAI {
|
|
||||||
const { settings } = this.options
|
|
||||||
if (!settings.apiKey) {
|
|
||||||
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return new OpenAI({
|
|
||||||
apiKey: settings.apiKey,
|
|
||||||
baseURL: settings.baseUrl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extensionForMime(mimeType: string): string {
|
|
||||||
const normalized = mimeType.toLowerCase()
|
|
||||||
if (normalized.includes("webm")) return "webm"
|
|
||||||
if (normalized.includes("ogg")) return "ogg"
|
|
||||||
if (normalized.includes("wav")) return "wav"
|
|
||||||
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
|
|
||||||
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
|
|
||||||
return "webm"
|
|
||||||
}
|
|
||||||
|
|
||||||
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
|
|
||||||
if (format === "wav") return "audio/wav"
|
|
||||||
if (format === "opus") return 'audio/ogg; codecs="opus"'
|
|
||||||
if (format === "aac") return "audio/aac"
|
|
||||||
return "audio/mpeg"
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureTrailingSlash(value: string): string {
|
|
||||||
return value.endsWith("/") ? value : `${value}/`
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { z } from "zod"
|
|
||||||
import type { Readable } from "node:stream"
|
|
||||||
import type { Logger } from "../logger"
|
|
||||||
import type { SettingsService } from "../settings/service"
|
|
||||||
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
|
|
||||||
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
|
|
||||||
|
|
||||||
const ServerSpeechSettingsSchema = z.object({
|
|
||||||
speech: z
|
|
||||||
.object({
|
|
||||||
provider: z.string().optional(),
|
|
||||||
apiKey: z.string().optional(),
|
|
||||||
baseUrl: z.string().optional(),
|
|
||||||
sttModel: z.string().optional(),
|
|
||||||
ttsModel: z.string().optional(),
|
|
||||||
ttsVoice: z.string().optional(),
|
|
||||||
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface TranscribeAudioInput {
|
|
||||||
audioBase64: string
|
|
||||||
mimeType: string
|
|
||||||
filename?: string
|
|
||||||
language?: string
|
|
||||||
prompt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SynthesizeSpeechInput {
|
|
||||||
text: string
|
|
||||||
format?: "mp3" | "wav" | "opus" | "aac"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpeechSynthesisStreamResponse {
|
|
||||||
stream: Readable
|
|
||||||
mimeType: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpeechProvider {
|
|
||||||
getCapabilities(): SpeechCapabilitiesResponse
|
|
||||||
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
|
||||||
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
|
||||||
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedSpeechSettings {
|
|
||||||
provider: string
|
|
||||||
apiKey?: string
|
|
||||||
baseUrl?: string
|
|
||||||
sttModel: string
|
|
||||||
ttsModel: string
|
|
||||||
ttsVoice: string
|
|
||||||
ttsFormat: "mp3" | "wav" | "opus" | "aac"
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PROVIDER = "openai-compatible"
|
|
||||||
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
|
|
||||||
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
|
|
||||||
const DEFAULT_TTS_VOICE = "alloy"
|
|
||||||
const DEFAULT_TTS_FORMAT = "mp3"
|
|
||||||
export class SpeechService {
|
|
||||||
constructor(
|
|
||||||
private readonly settings: SettingsService,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
getCapabilities(): SpeechCapabilitiesResponse {
|
|
||||||
return this.createProvider().getCapabilities()
|
|
||||||
}
|
|
||||||
|
|
||||||
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
|
||||||
return this.createProvider().transcribe(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
|
||||||
return this.createProvider().synthesize(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
|
||||||
return this.createProvider().synthesizeStream(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
private createProvider(): SpeechProvider {
|
|
||||||
const settings = this.resolveSettings()
|
|
||||||
return new OpenAICompatibleSpeechProvider({
|
|
||||||
settings,
|
|
||||||
logger: this.logger.child({ provider: settings.provider }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveSettings(): NormalizedSpeechSettings {
|
|
||||||
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
|
|
||||||
const speech = parsed.speech ?? {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
|
||||||
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
|
|
||||||
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
|
|
||||||
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
|
||||||
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
|
||||||
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
|
||||||
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,8 @@ import path from "path"
|
|||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
import { connect } from "net"
|
import { connect } from "net"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import type { SettingsService } from "../settings/service"
|
import { ConfigStore } from "../config/store"
|
||||||
import type { BinaryResolver } from "../settings/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
|
|||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
settings: SettingsService
|
configStore: ConfigStore
|
||||||
binaryResolver: BinaryResolver
|
binaryRegistry: BinaryRegistry
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
getServerBaseUrl: () => string
|
getServerBaseUrl: () => string
|
||||||
@@ -86,7 +86,7 @@ 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.binaryResolver.resolveDefault()
|
const binary = this.options.binaryRegistry.resolveDefault()
|
||||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
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)
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
@@ -109,14 +109,17 @@ export class WorkspaceManager {
|
|||||||
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 serverConfig = this.options.settings.getOwner("config", "server")
|
const preferences = this.options.configStore.get().preferences ?? {}
|
||||||
const envVars = (serverConfig as any)?.environmentVariables
|
const userEnvironment = preferences.environmentVariables ?? {}
|
||||||
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
|
||||||
|
|
||||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||||
const opencodePassword = generateOpencodeServerPassword()
|
const opencodePassword = generateOpencodeServerPassword()
|
||||||
@@ -145,10 +148,7 @@ export class WorkspaceManager {
|
|||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||||
if (runtimeVersion) {
|
|
||||||
descriptor.binaryVersion = runtimeVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
descriptor.pid = pid
|
descriptor.pid = pid
|
||||||
descriptor.port = port
|
descriptor.port = port
|
||||||
@@ -277,12 +277,42 @@ export class WorkspaceManager {
|
|||||||
return candidates[0] ?? ""
|
return candidates[0] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 async waitForWorkspaceReadiness(params: {
|
private async waitForWorkspaceReadiness(params: {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
port: number
|
port: number
|
||||||
exitPromise: Promise<ProcessExitInfo>
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
getLastOutput: () => string
|
getLastOutput: () => string
|
||||||
}): Promise<string | undefined> {
|
}) {
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.waitForPortAvailability(params.port),
|
this.waitForPortAvailability(params.port),
|
||||||
@@ -296,7 +326,7 @@ export class WorkspaceManager {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const version = await this.waitForInstanceHealth(params)
|
await this.waitForInstanceHealth(params)
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||||
@@ -309,8 +339,6 @@ export class WorkspaceManager {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return version
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForInstanceHealth(params: {
|
private async waitForInstanceHealth(params: {
|
||||||
@@ -318,7 +346,7 @@ export class WorkspaceManager {
|
|||||||
port: number
|
port: number
|
||||||
exitPromise: Promise<ProcessExitInfo>
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
getLastOutput: () => string
|
getLastOutput: () => string
|
||||||
}): Promise<string | undefined> {
|
}) {
|
||||||
const probeResult = await Promise.race([
|
const probeResult = await Promise.race([
|
||||||
this.probeInstance(params.workspaceId, params.port),
|
this.probeInstance(params.workspaceId, params.port),
|
||||||
params.exitPromise.then((info) => {
|
params.exitPromise.then((info) => {
|
||||||
@@ -332,7 +360,7 @@ export class WorkspaceManager {
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (probeResult.ok) {
|
if (probeResult.ok) {
|
||||||
return probeResult.version
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestOutput = params.getLastOutput().trim()
|
const latestOutput = params.getLastOutput().trim()
|
||||||
@@ -343,11 +371,8 @@ export class WorkspaceManager {
|
|||||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async probeInstance(
|
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||||
workspaceId: string,
|
const url = `http://127.0.0.1:${port}/project/current`
|
||||||
port: number,
|
|
||||||
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
|
||||||
const url = `http://127.0.0.1:${port}/global/health`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
@@ -358,22 +383,11 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
const response = await fetch(url, { headers })
|
const response = await fetch(url, { headers })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = `/global/health returned HTTP ${response.status}`
|
const reason = `health probe returned HTTP ${response.status}`
|
||||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||||
return { ok: false, reason }
|
return { ok: false, reason }
|
||||||
}
|
}
|
||||||
|
return { ok: true }
|
||||||
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
|
|
||||||
const healthy = payload?.healthy === true
|
|
||||||
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
|
|
||||||
|
|
||||||
if (!healthy) {
|
|
||||||
const reason = "Instance reported unhealthy"
|
|
||||||
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
|
|
||||||
return { ok: false, reason }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, version: version || undefined }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : String(error)
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { Logger } from "../logger"
|
|||||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||||
|
|
||||||
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
|
||||||
|
|
||||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
return { command: binaryPath, args, options: {} as const }
|
return { command: binaryPath, args, options: {} as const }
|
||||||
@@ -42,61 +40,6 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
|||||||
return { command: binaryPath, args, options: {} as const }
|
return { command: binaryPath, args, options: {} as const }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function probeBinaryVersion(binaryPath: string): {
|
|
||||||
valid: boolean
|
|
||||||
version?: string
|
|
||||||
reported?: string
|
|
||||||
error?: string
|
|
||||||
} {
|
|
||||||
if (!binaryPath) {
|
|
||||||
return { valid: false, error: "Missing binary path" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(spec.command, spec.args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
windowsVerbatimArguments: Boolean(
|
|
||||||
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { valid: false, error: result.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const stderr = result.stderr?.trim()
|
|
||||||
const stdout = result.stdout?.trim()
|
|
||||||
const combined = stderr || stdout
|
|
||||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
|
||||||
return { valid: false, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdoutLines = String(result.stdout ?? "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
const stderrLines = String(result.stderr ?? "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
|
|
||||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
|
||||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
|
||||||
if (!reported) {
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionMatch = reported.match(VERSION_REGEX)
|
|
||||||
const version = versionMatch?.[1]
|
|
||||||
return { valid: true, version, reported }
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||||
|
|
||||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||||
|
|||||||
2480
packages/tauri-app/Cargo.lock
generated
2480
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const serverDevInstallCommand =
|
|||||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -92,15 +91,6 @@ function ensureUiBuild() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncServerUiBundle() {
|
|
||||||
console.log("[prebuild] syncing server public UI bundle...")
|
|
||||||
execSync(serverPrepareUiCommand, {
|
|
||||||
cwd: workspaceRoot,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: envWithRootBin,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureServerDevDependencies() {
|
function ensureServerDevDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
return
|
return
|
||||||
@@ -256,7 +246,6 @@ function copyUiLoadingAssets() {
|
|||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
syncServerUiBundle()
|
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.12.3"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -19,13 +19,9 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
keepawake = "0.6"
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-global-shortcut = "2"
|
|
||||||
url = "2"
|
url = "2"
|
||||||
|
tauri-plugin-keepawake = "0.1.1"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
"opener:allow-open-url",
|
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -2379,70 +2379,34 @@
|
|||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "global-shortcut:default",
|
"const": "keepawake:default",
|
||||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
"description": "Enables the start command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "global-shortcut:allow-is-registered",
|
"const": "keepawake:allow-start",
|
||||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the register command without any pre-configured scope.",
|
"description": "Enables the stop command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "global-shortcut:allow-register",
|
"const": "keepawake:allow-stop",
|
||||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the register_all command without any pre-configured scope.",
|
"description": "Denies the start command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "global-shortcut:allow-register-all",
|
"const": "keepawake:deny-start",
|
||||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the unregister command without any pre-configured scope.",
|
"description": "Denies the stop command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "global-shortcut:allow-unregister",
|
"const": "keepawake:deny-stop",
|
||||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:allow-unregister-all",
|
|
||||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-is-registered",
|
|
||||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the register command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-register",
|
|
||||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the register_all command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-register-all",
|
|
||||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the unregister command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-unregister",
|
|
||||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-unregister-all",
|
|
||||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
|
|||||||
@@ -2378,6 +2378,36 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-start",
|
||||||
|
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-stop",
|
||||||
|
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-start",
|
||||||
|
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-stop",
|
||||||
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ use std::ffi::OsStr;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -19,24 +17,10 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn configure_spawn(command: &mut Command) {
|
|
||||||
command.creation_flags(CREATE_NO_WINDOW);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn configure_spawn(_command: &mut Command) {}
|
|
||||||
|
|
||||||
fn workspace_root() -> Option<PathBuf> {
|
fn workspace_root() -> Option<PathBuf> {
|
||||||
std::env::current_dir().ok().and_then(|mut dir| {
|
std::env::current_dir().ok().and_then(|mut dir| {
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
@@ -51,49 +35,7 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
#[cfg(windows)]
|
|
||||||
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn configure_posix_process_group(command: &mut Command) {
|
|
||||||
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
|
||||||
// processes (login shell/tsx) without leaving the server orphaned.
|
|
||||||
unsafe {
|
|
||||||
command.pre_exec(|| {
|
|
||||||
if libc::setpgid(0, 0) != 0 {
|
|
||||||
return Err(std::io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
|
||||||
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
|
||||||
if force {
|
|
||||||
args.push("/F".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut command = Command::new("taskkill");
|
|
||||||
command.args(&args);
|
|
||||||
configure_spawn(&mut command);
|
|
||||||
|
|
||||||
match command.output() {
|
|
||||||
Ok(output) => {
|
|
||||||
if output.status.success() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the PID is already gone, treat it as success.
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
|
||||||
let combined = format!("{stdout}\n{stderr}");
|
|
||||||
combined.contains("not found") || combined.contains("no running instance")
|
|
||||||
}
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn navigate_main(app: &AppHandle, url: &str) {
|
fn navigate_main(app: &AppHandle, url: &str) {
|
||||||
if let Some(win) = app.webview_windows().get("main") {
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
let mut display = url.to_string();
|
let mut display = url.to_string();
|
||||||
@@ -198,16 +140,9 @@ struct PreferencesConfig {
|
|||||||
listening_mode: Option<String>,
|
listening_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ServerConfig {
|
|
||||||
#[serde(rename = "listeningMode")]
|
|
||||||
listening_mode: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
preferences: Option<PreferencesConfig>,
|
preferences: Option<PreferencesConfig>,
|
||||||
server: Option<ServerConfig>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
||||||
@@ -253,18 +188,11 @@ fn resolve_listening_mode() -> String {
|
|||||||
|
|
||||||
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||||
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||||
let mode = config
|
if let Some(mode) = config
|
||||||
.server
|
.preferences
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|srv| srv.listening_mode.as_ref())
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
.or_else(|| {
|
{
|
||||||
config
|
|
||||||
.preferences
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(mode) = mode {
|
|
||||||
if mode == "local" {
|
if mode == "local" {
|
||||||
return "local".to_string();
|
return "local".to_string();
|
||||||
}
|
}
|
||||||
@@ -278,17 +206,11 @@ fn resolve_listening_mode() -> String {
|
|||||||
// Legacy fallback.
|
// Legacy fallback.
|
||||||
if let Ok(content) = fs::read_to_string(&json_path) {
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||||
let mode = config
|
if let Some(mode) = config
|
||||||
.server
|
.preferences
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|srv| srv.listening_mode.as_ref())
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
.or_else(|| {
|
{
|
||||||
config
|
|
||||||
.preferences
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
|
||||||
});
|
|
||||||
if let Some(mode) = mode {
|
|
||||||
if mode == "local" {
|
if mode == "local" {
|
||||||
return "local".to_string();
|
return "local".to_string();
|
||||||
}
|
}
|
||||||
@@ -404,21 +326,13 @@ impl CliProcessManager {
|
|||||||
let mut child_opt = self.child.lock();
|
let mut child_opt = self.child.lock();
|
||||||
if let Some(mut child) = child_opt.take() {
|
if let Some(mut child) = child_opt.take() {
|
||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(windows)]
|
|
||||||
let mut forced_tree_shutdown = false;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||||
// Prefer signaling the process group to avoid orphaning children
|
|
||||||
// when the CLI was launched via a wrapper shell.
|
|
||||||
let group_res = libc::kill(-pid, libc::SIGTERM);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGTERM);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = kill_process_tree_windows(child.id(), false);
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -426,21 +340,6 @@ impl CliProcessManager {
|
|||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
Ok(Some(_)) => break,
|
Ok(Some(_)) => break,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
#[cfg(windows)]
|
|
||||||
if !forced_tree_shutdown
|
|
||||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
|
||||||
{
|
|
||||||
log_line(&format!(
|
|
||||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
|
||||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
|
||||||
child.id()
|
|
||||||
));
|
|
||||||
forced_tree_shutdown = true;
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||||
log_line(&format!(
|
log_line(&format!(
|
||||||
"stop timed out after {}s; sending SIGKILL pid={}",
|
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||||
@@ -449,21 +348,11 @@ impl CliProcessManager {
|
|||||||
));
|
));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGKILL);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !forced_tree_shutdown
|
let _ = child.kill();
|
||||||
&& !kill_process_tree_windows(child.id(), true)
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
} else if forced_tree_shutdown {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -541,12 +430,9 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
|
||||||
configure_posix_process_group(&mut c);
|
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
ShellCommandType::Direct(cmd) => {
|
ShellCommandType::Direct(cmd) => {
|
||||||
@@ -556,12 +442,9 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
|
||||||
configure_posix_process_group(&mut c);
|
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -634,24 +517,7 @@ impl CliProcessManager {
|
|||||||
locked.error = Some("CLI did not start in time".to_string());
|
locked.error = Some("CLI did not start in time".to_string());
|
||||||
log_line("timeout waiting for CLI readiness");
|
log_line("timeout waiting for CLI readiness");
|
||||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||||
#[cfg(unix)]
|
let _ = child.kill();
|
||||||
unsafe {
|
|
||||||
let pid = child.id() as i32;
|
|
||||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGKILL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(any(unix, windows)))]
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||||
Self::emit_status(&app_clone, &locked);
|
Self::emit_status(&app_clone, &locked);
|
||||||
@@ -942,31 +808,14 @@ impl CliEntry {
|
|||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
// Dev: plain HTTP + Vite dev server proxy.
|
// Dev: plain HTTP + Vite dev server proxy.
|
||||||
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.or_else(|| {
|
|
||||||
std::env::var("ELECTRON_RENDERER_URL")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
|
||||||
let log_level = std::env::var("CLI_LOG_LEVEL")
|
|
||||||
.ok()
|
|
||||||
.map(|value| value.trim().to_lowercase())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or_else(|| "info".to_string());
|
|
||||||
|
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
args.push("false".to_string());
|
args.push("false".to_string());
|
||||||
args.push("--http".to_string());
|
args.push("--http".to_string());
|
||||||
args.push("true".to_string());
|
args.push("true".to_string());
|
||||||
args.push("--http-port".to_string());
|
|
||||||
args.push("0".to_string());
|
|
||||||
args.push("--ui-dev-server".to_string());
|
args.push("--ui-dev-server".to_string());
|
||||||
args.push(ui_dev_server);
|
args.push("http://localhost:3000".to_string());
|
||||||
args.push("--log-level".to_string());
|
args.push("--log-level".to_string());
|
||||||
args.push(log_level);
|
args.push("debug".to_string());
|
||||||
} else {
|
} else {
|
||||||
// Prod desktop: always keep loopback HTTP enabled.
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
@@ -1031,11 +880,6 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
|
||||||
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
|
||||||
|
|
||||||
let resources = dir.join("../Resources");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
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/index.js")));
|
||||||
@@ -1131,18 +975,9 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(path: PathBuf) -> String {
|
fn normalize_path(path: PathBuf) -> String {
|
||||||
let resolved = if let Ok(clean) = path.canonicalize() {
|
if let Ok(clean) = path.canonicalize() {
|
||||||
clean
|
clean.to_string_lossy().to_string()
|
||||||
} else {
|
} else {
|
||||||
path
|
path.to_string_lossy().to_string()
|
||||||
};
|
|
||||||
|
|
||||||
let rendered = resolved.to_string_lossy().to_string();
|
|
||||||
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
|
|
||||||
format!("\\\\{}", stripped)
|
|
||||||
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
|
|
||||||
stripped.to_string()
|
|
||||||
} else {
|
|
||||||
rendered
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,52 +3,20 @@
|
|||||||
mod cli_manager;
|
mod cli_manager;
|
||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
use keepawake::KeepAwake;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||||
use tauri_plugin_global_shortcut::{
|
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
|
||||||
};
|
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::ffi::OsStr;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::iter;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
|
||||||
|
|
||||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
|
||||||
const ZOOM_STEP: f64 = 0.2;
|
|
||||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
|
||||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
|
||||||
pub zoom_level: Mutex<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
struct WakeLockConfig {
|
|
||||||
display: bool,
|
|
||||||
idle: bool,
|
|
||||||
sleep: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -67,38 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn wake_lock_start(
|
|
||||||
state: tauri::State<AppState>,
|
|
||||||
config: Option<WakeLockConfig>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let config = config.unwrap_or(WakeLockConfig {
|
|
||||||
display: true,
|
|
||||||
idle: false,
|
|
||||||
sleep: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut builder = keepawake::Builder::default();
|
|
||||||
builder
|
|
||||||
.display(config.display)
|
|
||||||
.idle(config.idle)
|
|
||||||
.sleep(config.sleep)
|
|
||||||
.reason("CodeNomad active session")
|
|
||||||
.app_name("CodeNomad")
|
|
||||||
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
|
||||||
|
|
||||||
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
|
||||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
|
||||||
*state_lock = Some(wake_lock);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
|
||||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
|
||||||
state_lock.take();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
@@ -110,10 +46,7 @@ fn should_allow_internal(url: &Url) -> bool {
|
|||||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||||
// This must be treated as an internal origin or the navigation guard will
|
// This must be treated as an internal origin or the navigation guard will
|
||||||
// redirect it to the system browser and the app will appear blank.
|
// redirect it to the system browser and the app will appear blank.
|
||||||
"http" | "https" => matches!(
|
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
||||||
url.host_str(),
|
|
||||||
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
|
||||||
),
|
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,132 +66,6 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
|
||||||
paths
|
|
||||||
.iter()
|
|
||||||
.filter_map(|path| match std::fs::metadata(path) {
|
|
||||||
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
|
||||||
let _ = window.emit(event_name, ());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_folder_drop_event(
|
|
||||||
app_handle: &AppHandle,
|
|
||||||
window_label: &str,
|
|
||||||
event_name: &str,
|
|
||||||
paths: &[std::path::PathBuf],
|
|
||||||
) {
|
|
||||||
let directories = collect_directory_paths(paths);
|
|
||||||
|
|
||||||
if directories.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
|
||||||
let _ = window.emit(event_name, json!({ "paths": directories }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clamp_zoom_level(value: f64) -> f64 {
|
|
||||||
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let normalized = clamp_zoom_level(next_zoom);
|
|
||||||
if window.set_zoom(normalized).is_ok() {
|
|
||||||
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
|
||||||
*zoom_level = normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reload_main_window(app_handle: &AppHandle) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let _ = window.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn force_reload_main_window(app_handle: &AppHandle) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
if let Ok(mut url) = window.url() {
|
|
||||||
if should_allow_internal(&url) {
|
|
||||||
let reload_token = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let existing_pairs: Vec<(String, String)> = url
|
|
||||||
.query_pairs()
|
|
||||||
.into_owned()
|
|
||||||
.filter(|(key, _)| key != "__codenomad_force_reload")
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut pairs = url.query_pairs_mut();
|
|
||||||
pairs.clear();
|
|
||||||
for (key, value) in existing_pairs {
|
|
||||||
pairs.append_pair(&key, &value);
|
|
||||||
}
|
|
||||||
pairs.append_pair("__codenomad_force_reload", &reload_token);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = window.navigate(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = window.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_fullscreen_window(app_handle: &AppHandle) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
|
|
||||||
let _ = window.set_fullscreen(next_fullscreen);
|
|
||||||
if cfg!(not(target_os = "macos")) {
|
|
||||||
if next_fullscreen {
|
|
||||||
let _ = window.hide_menu();
|
|
||||||
} else {
|
|
||||||
let _ = window.show_menu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fullscreen_shortcut() -> Option<Shortcut> {
|
|
||||||
if cfg!(target_os = "macos") {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(Shortcut::new(None, ShortcutCode::F11))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn set_windows_app_user_model_id() {
|
|
||||||
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
|
||||||
.encode_wide()
|
|
||||||
.chain(iter::once(0))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
|
||||||
if result < 0 {
|
|
||||||
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn set_windows_app_user_model_id() {}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
@@ -267,48 +74,14 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(
|
.plugin(tauri_plugin_keepawake::init())
|
||||||
tauri_plugin_global_shortcut::Builder::new()
|
|
||||||
.with_handler(|app, shortcut, event| {
|
|
||||||
if event.state() != ShortcutState::Pressed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if fullscreen_shortcut().as_ref() == Some(shortcut) {
|
|
||||||
toggle_fullscreen_window(app);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
wake_lock: Mutex::new(None),
|
|
||||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
|
||||||
build_menu(&app.handle())?;
|
build_menu(&app.handle())?;
|
||||||
if let Some(shortcut) = fullscreen_shortcut() {
|
|
||||||
let shortcut_manager = app.handle().global_shortcut();
|
|
||||||
let _ = shortcut_manager.register(shortcut.clone());
|
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let app_handle = app.handle().clone();
|
|
||||||
window.on_window_event(move |event| {
|
|
||||||
if let WindowEvent::Focused(focused) = event {
|
|
||||||
let shortcut_manager = app_handle.global_shortcut();
|
|
||||||
if *focused {
|
|
||||||
let _ = shortcut_manager.register(shortcut.clone());
|
|
||||||
} else {
|
|
||||||
let _ = shortcut_manager.unregister(shortcut.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dev_mode = is_dev_mode();
|
let dev_mode = is_dev_mode();
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
let manager = app.state::<AppState>().manager.clone();
|
let manager = app.state::<AppState>().manager.clone();
|
||||||
@@ -319,12 +92,7 @@ fn main() {
|
|||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||||
cli_get_status,
|
|
||||||
cli_restart,
|
|
||||||
wake_lock_start,
|
|
||||||
wake_lock_stop
|
|
||||||
])
|
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
// File menu
|
// File menu
|
||||||
@@ -333,42 +101,36 @@ fn main() {
|
|||||||
let _ = window.emit("menu:newInstance", ());
|
let _ = window.emit("menu:newInstance", ());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"close" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
app_handle.exit(0);
|
app_handle.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
"reload" => {
|
"reload" => {
|
||||||
reload_main_window(app_handle);
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval("window.location.reload()");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"force_reload" => {
|
"force_reload" => {
|
||||||
force_reload_main_window(app_handle);
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval("window.location.reload(true)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"toggle_devtools" => {
|
"toggle_devtools" => {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
if window.is_devtools_open() {
|
window.open_devtools();
|
||||||
window.close_devtools();
|
|
||||||
} else {
|
|
||||||
window.open_devtools();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"reset_zoom" => {
|
|
||||||
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
|
|
||||||
}
|
|
||||||
"zoom_in" => {
|
|
||||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
|
||||||
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"zoom_out" => {
|
|
||||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
|
||||||
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"toggle_fullscreen" => {
|
"toggle_fullscreen" => {
|
||||||
toggle_fullscreen_window(app_handle);
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
@@ -382,11 +144,6 @@ fn main() {
|
|||||||
let _ = window.maximize();
|
let _ = window.maximize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"close_window" => {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let _ = window.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// App menu (macOS)
|
// App menu (macOS)
|
||||||
"about" => {
|
"about" => {
|
||||||
@@ -430,27 +187,6 @@ fn main() {
|
|||||||
app.exit(0);
|
app.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tauri::RunEvent::WindowEvent {
|
|
||||||
label,
|
|
||||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
|
|
||||||
}
|
|
||||||
tauri::RunEvent::WindowEvent {
|
|
||||||
label,
|
|
||||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
|
|
||||||
}
|
|
||||||
tauri::RunEvent::WindowEvent {
|
|
||||||
label,
|
|
||||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
|
|
||||||
}
|
|
||||||
tauri::RunEvent::WindowEvent {
|
tauri::RunEvent::WindowEvent {
|
||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
@@ -474,7 +210,6 @@ fn main() {
|
|||||||
|
|
||||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||||
let is_mac = cfg!(target_os = "macos");
|
let is_mac = cfg!(target_os = "macos");
|
||||||
let is_linux = cfg!(target_os = "linux");
|
|
||||||
|
|
||||||
// Create submenus
|
// Create submenus
|
||||||
let mut submenus = Vec::new();
|
let mut submenus = Vec::new();
|
||||||
@@ -499,77 +234,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
"new_instance",
|
"new_instance",
|
||||||
"New Instance",
|
"New Instance",
|
||||||
true,
|
true,
|
||||||
Some("CmdOrCtrl+N"),
|
Some("CmdOrCtrl+N")
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let file_menu = if is_mac {
|
let file_menu = SubmenuBuilder::new(app, "File")
|
||||||
SubmenuBuilder::new(app, "File")
|
.item(&new_instance_item)
|
||||||
.item(&new_instance_item)
|
.separator()
|
||||||
.separator()
|
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
||||||
.close_window()
|
.build()?;
|
||||||
.build()?
|
|
||||||
} else {
|
|
||||||
SubmenuBuilder::new(app, "File")
|
|
||||||
.item(&new_instance_item)
|
|
||||||
.separator()
|
|
||||||
.text("quit", "Quit")
|
|
||||||
.build()?
|
|
||||||
};
|
|
||||||
submenus.push(file_menu);
|
submenus.push(file_menu);
|
||||||
|
|
||||||
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
|
|
||||||
let force_reload_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"force_reload",
|
|
||||||
"Force Reload",
|
|
||||||
true,
|
|
||||||
Some("CmdOrCtrl+Shift+R"),
|
|
||||||
)?;
|
|
||||||
let toggle_devtools_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"toggle_devtools",
|
|
||||||
"Toggle Developer Tools",
|
|
||||||
true,
|
|
||||||
Some("Alt+CmdOrCtrl+I"),
|
|
||||||
)?;
|
|
||||||
let reset_zoom_item =
|
|
||||||
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
|
|
||||||
let zoom_in_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"zoom_in",
|
|
||||||
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
|
|
||||||
true,
|
|
||||||
None::<&str>,
|
|
||||||
)?;
|
|
||||||
let zoom_out_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"zoom_out",
|
|
||||||
if is_mac {
|
|
||||||
"Zoom Out"
|
|
||||||
} else {
|
|
||||||
"Zoom Out\tCtrl+-"
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
None::<&str>,
|
|
||||||
)?;
|
|
||||||
let toggle_fullscreen_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"toggle_fullscreen",
|
|
||||||
if is_mac {
|
|
||||||
"Toggle Full Screen"
|
|
||||||
} else {
|
|
||||||
"Toggle Full Screen\tF11"
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
if is_mac {
|
|
||||||
Some("Ctrl+Cmd+F")
|
|
||||||
} else {
|
|
||||||
None::<&str>
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let close_window_item =
|
|
||||||
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
|
|
||||||
|
|
||||||
// Edit menu with predefined items for standard functionality
|
// Edit menu with predefined items for standard functionality
|
||||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||||
.undo()
|
.undo()
|
||||||
@@ -585,48 +259,27 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
let view_menu = SubmenuBuilder::new(app, "View")
|
let view_menu = SubmenuBuilder::new(app, "View")
|
||||||
.item(&reload_item)
|
.text("reload", "Reload")
|
||||||
.item(&force_reload_item)
|
.text("force_reload", "Force Reload")
|
||||||
.item(&toggle_devtools_item)
|
.text("toggle_devtools", "Toggle Developer Tools")
|
||||||
.separator()
|
.separator()
|
||||||
.item(&reset_zoom_item)
|
|
||||||
.item(&zoom_in_item)
|
|
||||||
.item(&zoom_out_item)
|
|
||||||
.separator()
|
.separator()
|
||||||
.item(&toggle_fullscreen_item)
|
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||||
.build()?;
|
.build()?;
|
||||||
submenus.push(view_menu);
|
submenus.push(view_menu);
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
let window_menu = if is_linux {
|
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||||
SubmenuBuilder::new(app, "Window")
|
.text("minimize", "Minimize")
|
||||||
.text("minimize", "Minimize")
|
.text("zoom", "Zoom")
|
||||||
.text("zoom", "Zoom")
|
.build()?;
|
||||||
.separator()
|
|
||||||
.item(&close_window_item)
|
|
||||||
.build()?
|
|
||||||
} else if is_mac {
|
|
||||||
SubmenuBuilder::new(app, "Window")
|
|
||||||
.minimize()
|
|
||||||
.maximize()
|
|
||||||
.build()?
|
|
||||||
} else {
|
|
||||||
SubmenuBuilder::new(app, "Window")
|
|
||||||
.minimize()
|
|
||||||
.maximize()
|
|
||||||
.separator()
|
|
||||||
.close_window()
|
|
||||||
.build()?
|
|
||||||
};
|
|
||||||
submenus.push(window_menu);
|
submenus.push(window_menu);
|
||||||
|
|
||||||
// Build the main menu with all submenus
|
// Build the main menu with all submenus
|
||||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||||
.iter()
|
|
||||||
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
|
||||||
.collect();
|
|
||||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||||
|
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.12.3",
|
"version": "0.1.0",
|
||||||
"identifier": "ai.neuralnomads.codenomad.client",
|
"identifier": "ai.opencode.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
"beforeBuildCommand": "npm run bundle:server",
|
"beforeBuildCommand": "npm run bundle:server",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.1",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,15 +13,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.2.6",
|
"@opencode-ai/sdk": "1.1.11",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/api": "^2.10.1",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
@@ -32,8 +30,7 @@
|
|||||||
"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",
|
||||||
"virtua": "^0.48.8",
|
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||||
"yaml": "^2.4.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } 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 useMediaQuery from "@suid/material/useMediaQuery"
|
|
||||||
import { Minimize2 } from "lucide-solid"
|
|
||||||
import AlertDialog from "./components/alert-dialog"
|
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 { 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-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
|
import { initMarkdown } from "./lib/markdown"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
|
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 { getLogger } from "./lib/logger"
|
||||||
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
|
||||||
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
|
||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
@@ -52,133 +50,44 @@ import {
|
|||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
|
||||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
import { openSettings } from "./stores/settings-screen"
|
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
|
const { isDark } = useTheme()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
serverSettings,
|
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
toggleShowPromptVoiceInput,
|
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
setToolInputsVisibility,
|
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
|
interface LaunchErrorState {
|
||||||
|
message: string
|
||||||
|
binaryPath: string
|
||||||
|
missingBinary: boolean
|
||||||
|
}
|
||||||
|
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||||
|
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||||
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
|
||||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
|
||||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
|
||||||
|
|
||||||
// In-memory only: hides chrome on phone; may also request browser fullscreen.
|
|
||||||
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
|
|
||||||
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
|
|
||||||
|
|
||||||
const fullscreenSupported = () => {
|
|
||||||
if (typeof document === "undefined") return false
|
|
||||||
const el = document.documentElement as any
|
|
||||||
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncBrowserFullscreenState = () => {
|
|
||||||
if (typeof document === "undefined") return
|
|
||||||
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
|
|
||||||
}
|
|
||||||
|
|
||||||
const enterMobileFullscreen = async () => {
|
|
||||||
if (!isPhoneLayout()) return
|
|
||||||
setMobileFullscreenMode(true)
|
|
||||||
if (!fullscreenSupported()) return
|
|
||||||
try {
|
|
||||||
await document.documentElement.requestFullscreen()
|
|
||||||
} catch {
|
|
||||||
// Ignore: immersive mode still works without browser fullscreen.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitMobileFullscreen = async () => {
|
|
||||||
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
|
|
||||||
try {
|
|
||||||
await document.exitFullscreen()
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMobileFullscreenMode(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (typeof document === "undefined") return
|
|
||||||
const shouldShow =
|
|
||||||
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
|
||||||
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateInstanceTabBarHeight = () => {
|
const updateInstanceTabBarHeight = () => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (typeof document === "undefined") return
|
|
||||||
syncBrowserFullscreenState()
|
|
||||||
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
|
|
||||||
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
const vv = window.visualViewport
|
|
||||||
if (!vv) return
|
|
||||||
|
|
||||||
const updateKeyboardOffset = () => {
|
|
||||||
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
|
|
||||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
|
|
||||||
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
|
|
||||||
schedule()
|
|
||||||
vv.addEventListener("resize", schedule)
|
|
||||||
vv.addEventListener("scroll", schedule)
|
|
||||||
window.addEventListener("orientationchange", schedule)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
vv.removeEventListener("resize", schedule)
|
|
||||||
vv.removeEventListener("scroll", schedule)
|
|
||||||
window.removeEventListener("orientationchange", schedule)
|
|
||||||
document.documentElement.style.removeProperty("--keyboard-offset")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// If the user exits browser fullscreen via browser UI, restore chrome.
|
|
||||||
let lastBrowserFullscreen = false
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const active = browserFullscreenActive()
|
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||||
const mode = mobileFullscreenMode()
|
|
||||||
if (mode && lastBrowserFullscreen && !active) {
|
|
||||||
setMobileFullscreenMode(false)
|
|
||||||
}
|
|
||||||
lastBrowserFullscreen = active
|
|
||||||
})
|
|
||||||
|
|
||||||
// If we leave phone layout (rotation / resize), restore chrome.
|
|
||||||
createEffect(() => {
|
|
||||||
if (!isPhoneLayout() && mobileFullscreenMode()) {
|
|
||||||
void exitMobileFullscreen()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -234,26 +143,60 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||||
|
|
||||||
|
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||||
|
if (!error) {
|
||||||
|
return t("app.launchError.fallbackMessage")
|
||||||
|
}
|
||||||
|
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed.error === "string") {
|
||||||
|
return parsed.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMissingBinaryMessage = (message: string): boolean => {
|
||||||
|
const normalized = message.toLowerCase()
|
||||||
|
return (
|
||||||
|
normalized.includes("opencode binary not found") ||
|
||||||
|
normalized.includes("binary not found") ||
|
||||||
|
normalized.includes("no such file or directory") ||
|
||||||
|
normalized.includes("binary is not executable") ||
|
||||||
|
normalized.includes("enoent")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearLaunchError = () => setLaunchError(null)
|
||||||
|
|
||||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSelectingFolder(true)
|
setIsSelectingFolder(true)
|
||||||
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||||
try {
|
try {
|
||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
|
setIsAdvancedSettingsOpen(false)
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
instanceId,
|
instanceId,
|
||||||
port: instances().get(instanceId)?.port,
|
port: instances().get(instanceId)?.port,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
const message = formatLaunchErrorMessage(error)
|
||||||
const missingBinary = isMissingBinaryMessage(message)
|
const missingBinary = isMissingBinaryMessage(message)
|
||||||
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
setLaunchError({
|
||||||
|
message,
|
||||||
|
binaryPath: selectedBinary,
|
||||||
|
missingBinary,
|
||||||
|
})
|
||||||
log.error("Failed to create instance", error)
|
log.error("Failed to create instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSelectingFolder(false)
|
setIsSelectingFolder(false)
|
||||||
@@ -266,7 +209,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
function handleLaunchErrorAdvanced() {
|
function handleLaunchErrorAdvanced() {
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
openSettings("opencode")
|
setIsAdvancedSettingsOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
@@ -350,16 +293,13 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
toggleShowPromptVoiceInput,
|
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
setToolInputsVisibility,
|
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
@@ -455,61 +395,37 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
|
<div class="h-screen w-screen flex flex-col">
|
||||||
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
|
|
||||||
<div class="mobile-fullscreen-exit-wrapper">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="message-scroll-button mobile-fullscreen-exit-button"
|
|
||||||
onClick={() => void exitMobileFullscreen()}
|
|
||||||
aria-label={t("instanceShell.fullscreen.exit")}
|
|
||||||
title={t("instanceShell.fullscreen.exit")}
|
|
||||||
>
|
|
||||||
<Minimize2 class="h-5 w-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show
|
<Show
|
||||||
when={!hasInstances()}
|
when={!hasInstances()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
<InstanceTabs
|
||||||
<InstanceTabs
|
instances={instances()}
|
||||||
instances={instances()}
|
activeInstanceId={activeInstanceId()}
|
||||||
activeInstanceId={activeInstanceId()}
|
onSelect={setActiveInstanceId}
|
||||||
onSelect={setActiveInstanceId}
|
onClose={handleCloseInstance}
|
||||||
onClose={handleCloseInstance}
|
onNew={handleNewInstanceRequest}
|
||||||
onNew={handleNewInstanceRequest}
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={Array.from(instances().values())}>
|
<For each={Array.from(instances().values())}>
|
||||||
{(instance) => {
|
{(instance) => {
|
||||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||||
return (
|
return (
|
||||||
<div
|
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
||||||
class="flex-1 min-h-0 overflow-hidden"
|
<InstanceMetadataProvider instance={instance}>
|
||||||
style={{ display: isVisible() ? "flex" : "none" }}
|
<InstanceShell
|
||||||
data-instance-id={instance.id}
|
instance={instance}
|
||||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
escapeInDebounce={escapeInDebounce()}
|
||||||
data-instance-visible={isVisible() ? "true" : "false"}
|
paletteCommands={paletteCommands}
|
||||||
>
|
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||||
<InstanceMetadataProvider instance={instance}>
|
onNewSession={() => handleNewSession(instance.id)}
|
||||||
<InstanceShell
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||||
instance={instance}
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||||
isActiveInstance={isActiveInstance()}
|
onExecuteCommand={executeCommand}
|
||||||
escapeInDebounce={escapeInDebounce()}
|
tabBarOffset={instanceTabBarHeight()}
|
||||||
paletteCommands={paletteCommands}
|
|
||||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
|
||||||
onNewSession={() => handleNewSession(instance.id)}
|
|
||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
|
||||||
onExecuteCommand={executeCommand}
|
|
||||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
|
||||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
|
||||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
|
||||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
|
||||||
/>
|
/>
|
||||||
</InstanceMetadataProvider>
|
</InstanceMetadataProvider>
|
||||||
|
|
||||||
@@ -525,25 +441,41 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||||
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||||
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||||
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={showFolderSelection()}>
|
<Show when={showFolderSelection()}>
|
||||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowFolderSelection(false)
|
||||||
|
setIsAdvancedSettingsOpen(false)
|
||||||
|
clearLaunchError()
|
||||||
|
}}
|
||||||
|
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title={t("app.launchError.closeTitle")}
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
onClose={() => {
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||||
setShowFolderSelection(false)
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||||
clearLaunchError()
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<SettingsScreen />
|
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
const availableAgents = createMemo(() => {
|
const availableAgents = createMemo(() => {
|
||||||
const allAgents = instanceAgents()
|
const allAgents = instanceAgents()
|
||||||
if (isChildSession()) {
|
if (isChildSession()) {
|
||||||
return allAgents.filter((agent) => !agent.hidden)
|
return allAgents
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent")
|
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
|
||||||
|
|
||||||
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
|
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
|
||||||
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
|
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
|
||||||
@@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
>
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Select.Value<Agent>>
|
<Select.Value<Agent>>
|
||||||
{() => (
|
{(state) => (
|
||||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
{t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
|
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -115,28 +115,28 @@ const AlertDialog: Component = () => {
|
|||||||
>
|
>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<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}>
|
<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 items-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||||
style={{
|
style={{
|
||||||
"background-color": accent.badgeBg,
|
"background-color": accent.badgeBg,
|
||||||
"border-color": accent.badgeBorder,
|
"border-color": accent.badgeBorder,
|
||||||
color: accent.badgeText,
|
color: accent.badgeText,
|
||||||
}}
|
}}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{accent.symbol}
|
{accent.symbol}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
<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">
|
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||||
{payload.message}
|
{payload.message}
|
||||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isPrompt}>
|
<Show when={isPrompt}>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@@ -185,14 +185,14 @@ const AlertDialog: Component = () => {
|
|||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AlertDialog
|
export default AlertDialog
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||||
import type { Highlighter } from "shiki/bundle/full"
|
import type { Highlighter } from "shiki/bundle/full"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter } from "../lib/markdown"
|
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||||
import { escapeHtml } from "../lib/text-render-utils"
|
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -112,10 +112,6 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const groupedCommandList = () => processedCommands().groups
|
const groupedCommandList = () => processedCommands().groups
|
||||||
const orderedCommands = () => processedCommands().ordered
|
const orderedCommands = () => processedCommands().ordered
|
||||||
|
|
||||||
const isCommandDisabled = (command: Command) => {
|
|
||||||
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
|
|
||||||
}
|
|
||||||
const selectedIndex = createMemo(() => {
|
const selectedIndex = createMemo(() => {
|
||||||
const ordered = orderedCommands()
|
const ordered = orderedCommands()
|
||||||
if (ordered.length === 0) return -1
|
if (ordered.length === 0) return -1
|
||||||
@@ -142,11 +138,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentId = selectedCommandId()
|
const currentId = selectedCommandId()
|
||||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||||
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
|
setSelectedCommandId(ordered[0].id)
|
||||||
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -200,14 +195,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
if (index < 0 || index >= ordered.length) return
|
if (index < 0 || index >= ordered.length) return
|
||||||
const command = ordered[index]
|
const command = ordered[index]
|
||||||
if (!command) return
|
if (!command) return
|
||||||
if (isCommandDisabled(command)) return
|
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommandClick(command: Command) {
|
function handleCommandClick(command: Command) {
|
||||||
if (isCommandDisabled(command)) return
|
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
@@ -272,13 +265,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
<For each={group.commands}>
|
<For each={group.commands}>
|
||||||
{(command, localIndex) => {
|
{(command, localIndex) => {
|
||||||
const commandIndex = group.startIndex + localIndex()
|
const commandIndex = group.startIndex + localIndex()
|
||||||
const disabled = isCommandDisabled(command)
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-command-index={commandIndex}
|
data-command-index={commandIndex}
|
||||||
onClick={() => handleCommandClick(command)}
|
onClick={() => handleCommandClick(command)}
|
||||||
disabled={disabled}
|
|
||||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
if (event.movementX === 0 && event.movementY === 0) return
|
if (event.movementX === 0 && event.movementY === 0) return
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import type { Component } from "solid-js"
|
|
||||||
|
|
||||||
interface ContextMeterProps {
|
|
||||||
usedTokens: number
|
|
||||||
availableTokens: number | null
|
|
||||||
formatTokens: (value: number) => string
|
|
||||||
usedLabel: string
|
|
||||||
availableLabel: string
|
|
||||||
class?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
return Math.min(Math.max(value, min), max)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFillColor(percent: number): string {
|
|
||||||
if (percent >= 0.8) return "var(--status-error)"
|
|
||||||
if (percent >= 0.6) return "var(--status-warning)"
|
|
||||||
return "var(--status-success)"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContextMeter: Component<ContextMeterProps> = (props) => {
|
|
||||||
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
|
|
||||||
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
|
|
||||||
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
|
|
||||||
|
|
||||||
const percent = () => {
|
|
||||||
const usedValue = used()
|
|
||||||
const availableValue = available()
|
|
||||||
if (availableValue === null || availableValue <= 0) return null
|
|
||||||
|
|
||||||
// Heuristic: if available >= used, treat it like a capacity/limit.
|
|
||||||
// Otherwise treat it like remaining tokens.
|
|
||||||
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
|
|
||||||
return clamp(ratio, 0, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fillColor = () => {
|
|
||||||
const value = percent()
|
|
||||||
if (value === null) return "var(--border-base)"
|
|
||||||
return resolveFillColor(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const percentLabel = () => {
|
|
||||||
const value = percent()
|
|
||||||
if (value === null) return "--"
|
|
||||||
return `${Math.round(value * 100)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerClass =
|
|
||||||
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
|
|
||||||
|
|
||||||
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
|
|
||||||
const rad = (angleDeg * Math.PI) / 180
|
|
||||||
return {
|
|
||||||
x: cx + r * Math.cos(rad),
|
|
||||||
y: cy + r * Math.sin(rad),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
|
|
||||||
const start = polarToCartesian(cx, cy, r, startAngle)
|
|
||||||
const end = polarToCartesian(cx, cy, r, endAngle)
|
|
||||||
const delta = ((endAngle - startAngle) % 360 + 360) % 360
|
|
||||||
const largeArc = delta > 180 ? 1 : 0
|
|
||||||
|
|
||||||
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
|
||||||
}
|
|
||||||
|
|
||||||
const circle = () => {
|
|
||||||
const value = percent()
|
|
||||||
const size = 22
|
|
||||||
const r = 9
|
|
||||||
const cx = 11
|
|
||||||
const cy = 11
|
|
||||||
const progress = value === null ? 0 : value
|
|
||||||
const startAngle = -90
|
|
||||||
const endAngle = startAngle + progress * 360
|
|
||||||
const isFull = progress >= 0.999
|
|
||||||
const hasFill = progress > 0.001
|
|
||||||
|
|
||||||
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 22 22"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ flex: "0 0 auto" }}
|
|
||||||
>
|
|
||||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
|
|
||||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
|
|
||||||
{isFull ? (
|
|
||||||
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
|
|
||||||
) : sectorPath ? (
|
|
||||||
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
|
|
||||||
) : null}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tooltipText = () => `Context Used: ${percentLabel()}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="inline-flex items-center gap-2" title={tooltipText()}>
|
|
||||||
{circle()}
|
|
||||||
<div class={containerClass}>
|
|
||||||
<span class={LABEL_CLASS}>{props.usedLabel}</span>
|
|
||||||
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
|
|
||||||
<span class="text-muted">/</span>
|
|
||||||
<span class={LABEL_CLASS}>{props.availableLabel}</span>
|
|
||||||
<span class="font-semibold text-primary tabular-nums">
|
|
||||||
{available() !== null ? props.formatTokens(available() as number) : "--"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContextMeter
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } 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 "@git-diff-view/solid/styles/diff-view-pure.css"
|
|
||||||
import { disableCache } from "@git-diff-view/core"
|
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 { ErrorBoundary } from "solid-js"
|
||||||
import { getLanguageFromPath } from "../lib/text-render-utils"
|
import { getLanguageFromPath } from "../lib/markdown"
|
||||||
import { normalizeDiffText } from "../lib/diff-utils"
|
import { normalizeDiffText } from "../lib/diff-utils"
|
||||||
import { setCacheEntry } from "../lib/global-cache"
|
import { setCacheEntry } from "../lib/global-cache"
|
||||||
import type { CacheEntryParams } from "../lib/global-cache"
|
import type { CacheEntryParams } from "../lib/global-cache"
|
||||||
@@ -135,4 +134,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
|
|||||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
serverSettings,
|
preferences,
|
||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateEnvironmentVariables,
|
updateEnvironmentVariables,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
|
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
||||||
const [newKey, setNewKey] = createSignal("")
|
const [newKey, setNewKey] = createSignal("")
|
||||||
const [newValue, setNewValue] = createSignal("")
|
const [newValue, setNewValue] = createSignal("")
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface MonacoDiffViewerProps {
|
|||||||
after: string
|
after: string
|
||||||
viewMode?: "split" | "unified"
|
viewMode?: "split" | "unified"
|
||||||
contextMode?: "expanded" | "collapsed"
|
contextMode?: "expanded" | "collapsed"
|
||||||
wordWrap?: "on" | "off"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
@@ -55,17 +54,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
renderWhitespace: "selection",
|
renderWhitespace: "selection",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
wordWrap: "off",
|
||||||
glyphMargin: false,
|
glyphMargin: false,
|
||||||
folding: false,
|
folding: false,
|
||||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||||
lineNumbersMinChars: 4,
|
lineNumbersMinChars: 4,
|
||||||
lineDecorationsWidth: 12,
|
lineDecorationsWidth: 12,
|
||||||
// Use legacy diff algorithm for better performance with large files
|
|
||||||
// See: https://github.com/microsoft/vscode/issues/184037
|
|
||||||
diffAlgorithm: "legacy",
|
|
||||||
// Limit computation time to avoid freezing on large files
|
|
||||||
maxComputationTime: 10000,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setReady(true)
|
setReady(true)
|
||||||
@@ -87,7 +81,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
if (!ready() || !monaco || !diffEditor) return
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
|
||||||
|
|
||||||
diffEditor.updateOptions({
|
diffEditor.updateOptions({
|
||||||
renderSideBySide: viewMode === "split",
|
renderSideBySide: viewMode === "split",
|
||||||
@@ -96,20 +89,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
contextMode === "collapsed"
|
contextMode === "collapsed"
|
||||||
? { enabled: true }
|
? { enabled: true }
|
||||||
: { enabled: false },
|
: { enabled: false },
|
||||||
wordWrap,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
|
||||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-footer keyboard-hints">
|
<div class="panel-footer">
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,37 +1,36 @@
|
|||||||
import { Select } from "@kobalte/core/select"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
|
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 { ThemeModeToggle } from "./theme-mode-toggle"
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
|
||||||
import VersionPill from "./version-pill"
|
import VersionPill from "./version-pill"
|
||||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
import { githubStars } from "../stores/github-stars"
|
import { githubStars } from "../stores/github-stars"
|
||||||
import { formatCompactCount } from "../lib/formatters"
|
import { formatCompactCount } from "../lib/formatters"
|
||||||
import { useI18n, type Locale } from "../lib/i18n"
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
|
||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
|
||||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
onClose?: () => void
|
advancedSettingsOpen?: boolean
|
||||||
|
onAdvancedSettingsOpen?: () => void
|
||||||
|
onAdvancedSettingsClose?: () => void
|
||||||
|
onOpenRemoteAccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
@@ -45,7 +44,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
{ value: "ru", label: "Русский" },
|
{ value: "ru", label: "Русский" },
|
||||||
{ value: "ja", label: "日本語" },
|
{ value: "ja", label: "日本語" },
|
||||||
{ value: "zh-Hans", label: "简体中文" },
|
{ value: "zh-Hans", label: "简体中文" },
|
||||||
{ value: "he", label: "עברית" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||||
@@ -55,7 +53,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = serverSettings().opencodeBinary
|
const lastUsed = preferences().lastUsedBinary
|
||||||
if (!lastUsed) return
|
if (!lastUsed) return
|
||||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||||
})
|
})
|
||||||
@@ -194,31 +192,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function dropTargetBlocked() {
|
|
||||||
return isLoading() || isFolderBrowserOpen() || settingsOpen()
|
|
||||||
}
|
|
||||||
|
|
||||||
function showInvalidFolderDropAlert() {
|
|
||||||
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
|
|
||||||
title: t("folderSelection.drop.invalidTitle"),
|
|
||||||
variant: "warning",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const folderDrop = useFolderDrop({
|
|
||||||
enabled: () => !dropTargetBlocked(),
|
|
||||||
onInvalidDrop: showInvalidFolderDropAlert,
|
|
||||||
onDrop: async (paths) => {
|
|
||||||
const firstPath = paths[0]
|
|
||||||
if (!firstPath) {
|
|
||||||
showInvalidFolderDropAlert()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleFolderSelect(firstPath)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -236,6 +209,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openExternalLink = (url: string) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -258,6 +236,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
handleFolderSelect(path)
|
handleFolderSelect(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBinaryChange(binary: string) {
|
||||||
|
|
||||||
|
setSelectedBinary(binary)
|
||||||
|
}
|
||||||
|
|
||||||
function handleRemove(path: string, e?: Event) {
|
function handleRemove(path: string, e?: Event) {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -333,16 +316,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||||
style="background-color: var(--surface-secondary)"
|
style="background-color: var(--surface-secondary)"
|
||||||
onDragEnter={folderDrop.bind.onDragEnter}
|
|
||||||
onDragOver={folderDrop.bind.onDragOver}
|
|
||||||
onDragLeave={folderDrop.bind.onDragLeave}
|
|
||||||
onDrop={folderDrop.bind.onDrop}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||||
aria-busy={isLoading() ? "true" : "false"}
|
aria-busy={isLoading() ? "true" : "false"}
|
||||||
>
|
>
|
||||||
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
|
<div class="absolute top-4 left-6">
|
||||||
<Select<LanguageOption>
|
<Select<LanguageOption>
|
||||||
value={selectedLanguageOption()}
|
value={selectedLanguageOption()}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -386,34 +365,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
|
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||||
<button
|
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||||
type="button"
|
<Show when={props.onOpenRemoteAccess}>
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
|
||||||
onClick={() => openSettings("appearance")}
|
|
||||||
aria-label={t("settings.open.title")}
|
|
||||||
title={t("settings.open.title")}
|
|
||||||
>
|
|
||||||
<Settings class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
|
||||||
onClick={() => openSettings("remote")}
|
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
|
||||||
title={t("instanceTabs.remote.title")}
|
|
||||||
>
|
|
||||||
<MonitorUp class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<Show when={props.onClose}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
onClick={() => props.onClose?.()}
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
aria-label={t("app.launchError.close")}
|
|
||||||
title={t("app.launchError.closeTitle")}
|
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<MonitorUp class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,7 +384,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<div class="mt-3 flex justify-center gap-2">
|
<div class="mt-3 flex justify-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
@@ -432,13 +392,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.github")}
|
title={t("folderSelection.links.github")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GitHubMarkIcon class="w-4 h-4" />
|
<GitHubMarkIcon class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||||
@@ -446,7 +406,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.githubStars")}
|
title={t("folderSelection.links.githubStars")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Star class="w-4 h-4" />
|
<Star class="w-4 h-4" />
|
||||||
@@ -455,7 +415,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={DISCORD_URL}
|
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
@@ -463,7 +423,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.discord")}
|
title={t("folderSelection.links.discord")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
openExternalLink(
|
||||||
|
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DiscordSymbolIcon class="w-4 h-4" />
|
<DiscordSymbolIcon class="w-4 h-4" />
|
||||||
@@ -586,16 +548,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
: t("folderSelection.browse.button")}
|
: t("folderSelection.browse.button")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* Advanced settings section */}
|
||||||
<div class="panel-section w-full">
|
<div class="panel-section w-full">
|
||||||
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
|
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Settings class="w-4 h-4 icon-muted" />
|
<Settings class="w-4 h-4 icon-muted" />
|
||||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
|
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||||
</button>
|
</button>
|
||||||
@@ -611,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
|
<div class="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">
|
||||||
@@ -629,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
<Kbd shortcut="cmd+n" />
|
||||||
<span>{t("folderSelection.hints.browse")}</span>
|
<span>{t("folderSelection.hints.browse")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -645,17 +607,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
|
|
||||||
<div class="folder-drop-overlay" aria-hidden="true">
|
|
||||||
<div class="folder-drop-card">
|
|
||||||
<FolderPlus class="w-8 h-8 icon-muted" />
|
|
||||||
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
|
|
||||||
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AdvancedSettingsModal
|
||||||
|
open={Boolean(props.advancedSettingsOpen)}
|
||||||
|
onClose={() => props.onAdvancedSettingsClose?.()}
|
||||||
|
selectedBinary={selectedBinary()}
|
||||||
|
onBinaryChange={handleBinaryChange}
|
||||||
|
isLoading={props.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
<DirectoryBrowserDialog
|
<DirectoryBrowserDialog
|
||||||
open={isFolderBrowserOpen()}
|
open={isFolderBrowserOpen()}
|
||||||
title={t("folderSelection.dialog.title")}
|
title={t("folderSelection.dialog.title")}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface HintRowProps {
|
|||||||
|
|
||||||
const HintRow: Component<HintRowProps> = (props) => {
|
const HintRow: Component<HintRowProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
|
<span aria-hidden={props.ariaHidden} class={`text-xs text-muted ${props.class || ""}`}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||||
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import InstanceInfo from "./instance-info"
|
import InstanceInfo from "./instance-info"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="log-container">
|
<div class="log-container">
|
||||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||||
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
|
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
||||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
|
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||||
import InstanceServiceStatus from "./instance-service-status"
|
import InstanceServiceStatus from "./instance-service-status"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { showConfirmDialog } from "../stores/alerts"
|
|
||||||
import { disposeInstance } from "../stores/instances"
|
|
||||||
import { showToastNotification } from "../lib/notifications"
|
|
||||||
import { getLogger } from "../lib/logger"
|
|
||||||
|
|
||||||
interface InstanceInfoProps {
|
interface InstanceInfoProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
showDisposeButton?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = getLogger("actions")
|
|
||||||
|
|
||||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const metadataContext = useOptionalInstanceMetadataContext()
|
const metadataContext = useOptionalInstanceMetadataContext()
|
||||||
@@ -23,8 +16,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||||
|
|
||||||
const [isDisposing, setIsDisposing] = createSignal(false)
|
|
||||||
|
|
||||||
const currentInstance = () => instanceAccessor()
|
const currentInstance = () => instanceAccessor()
|
||||||
const metadata = () => metadataAccessor()
|
const metadata = () => metadataAccessor()
|
||||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||||
@@ -34,46 +25,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
return env ? Object.entries(env) : []
|
return env ? Object.entries(env) : []
|
||||||
})
|
})
|
||||||
|
|
||||||
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
|
|
||||||
|
|
||||||
const handleDisposeInstance = async () => {
|
|
||||||
if (!disposeEnabled()) return
|
|
||||||
|
|
||||||
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
|
|
||||||
title: t("infoView.dispose.confirm.title"),
|
|
||||||
variant: "warning",
|
|
||||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
|
||||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
setIsDisposing(true)
|
|
||||||
try {
|
|
||||||
const ok = await disposeInstance(currentInstance().id)
|
|
||||||
if (ok) {
|
|
||||||
showToastNotification({
|
|
||||||
message: t("infoView.dispose.toast.success"),
|
|
||||||
variant: "success",
|
|
||||||
duration: 8000,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
showToastNotification({
|
|
||||||
message: t("infoView.dispose.toast.error"),
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to dispose instance", error)
|
|
||||||
showToastNotification({
|
|
||||||
message: t("infoView.dispose.toast.error"),
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsDisposing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -82,7 +33,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<div class="panel-body space-y-3">
|
<div class="panel-body space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
||||||
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
{currentInstance().folder}
|
{currentInstance().folder}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +45,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<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">
|
||||||
{t("instanceInfo.labels.project")}
|
{t("instanceInfo.labels.project")}
|
||||||
</div>
|
</div>
|
||||||
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||||
{project().id}
|
{project().id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +88,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<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">
|
||||||
{t("instanceInfo.labels.binaryPath")}
|
{t("instanceInfo.labels.binaryPath")}
|
||||||
</div>
|
</div>
|
||||||
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
{currentInstance().binaryPath}
|
{currentInstance().binaryPath}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +102,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<For each={environmentEntries()}>
|
<For each={environmentEntries()}>
|
||||||
{([key, value]) => (
|
{([key, value]) => (
|
||||||
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||||
{key}
|
{key}
|
||||||
</span>
|
</span>
|
||||||
@@ -205,19 +156,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.showDisposeButton}>
|
|
||||||
<div class="pt-3 border-t border-base">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button-danger button-small w-full"
|
|
||||||
onClick={handleDisposeInstance}
|
|
||||||
disabled={!disposeEnabled()}
|
|
||||||
>
|
|
||||||
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Component, For, Show, createMemo } from "solid-js"
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
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, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||||
|
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { openSettings } from "../stores/settings-screen"
|
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -16,11 +17,13 @@ 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) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||||
|
|
||||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||||
@@ -30,10 +33,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const notificationTitle = createMemo(() => {
|
const notificationTitle = createMemo(() => {
|
||||||
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
|
if (!notificationsSupported()) return "Notifications unsupported"
|
||||||
return notificationsEnabled()
|
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||||
? t("settings.notifications.status.enabled")
|
|
||||||
: t("settings.notifications.status.disabled")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,35 +72,32 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<ThemeModeToggle class="new-tab-button" />
|
||||||
class="new-tab-button"
|
|
||||||
onClick={() => openSettings("appearance")}
|
|
||||||
title={t("settings.open.title")}
|
|
||||||
aria-label={t("settings.open.ariaLabel")}
|
|
||||||
>
|
|
||||||
<Settings class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||||
onClick={() => openSettings("notifications")}
|
onClick={() => setNotificationsOpen(true)}
|
||||||
title={notificationTitle()}
|
title={notificationTitle()}
|
||||||
aria-label={notificationTitle()}
|
aria-label={notificationTitle()}
|
||||||
>
|
>
|
||||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||||
class="new-tab-button tab-remote-button"
|
<button
|
||||||
onClick={() => openSettings("remote")}
|
class="new-tab-button tab-remote-button"
|
||||||
title={t("instanceTabs.remote.title")}
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
title={t("instanceTabs.remote.title")}
|
||||||
>
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
<MonitorUp class="w-4 h-4" />
|
>
|
||||||
</button>
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -404,7 +404,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||||
dir="auto"
|
|
||||||
classList={{
|
classList={{
|
||||||
"text-accent": isFocused(),
|
"text-accent": isFocused(),
|
||||||
}}
|
}}
|
||||||
@@ -503,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="panel-footer hidden sm:block keyboard-hints">
|
<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">
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import PermissionNotificationBanner from "../permission-notification-banner"
|
|||||||
import PermissionApprovalModal from "../permission-approval-modal"
|
import PermissionApprovalModal from "../permission-approval-modal"
|
||||||
import SessionView from "../session/session-view"
|
import SessionView from "../session/session-view"
|
||||||
import { formatTokenTotal } from "../../lib/formatters"
|
import { formatTokenTotal } from "../../lib/formatters"
|
||||||
import ContextMeter from "../context-meter"
|
|
||||||
import { sseManager } from "../../lib/sse-manager"
|
import { sseManager } from "../../lib/sse-manager"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
@@ -42,7 +41,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
|||||||
import RightPanel from "./shell/right-panel/RightPanel"
|
import RightPanel from "./shell/right-panel/RightPanel"
|
||||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||||
import { getSessionStatus } from "../../stores/session-status"
|
import { getSessionStatus } from "../../stores/session-status"
|
||||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
import { ShieldAlert } from "lucide-solid"
|
||||||
|
|
||||||
import type { LayoutMode } from "./shell/types"
|
import type { LayoutMode } from "./shell/types"
|
||||||
import {
|
import {
|
||||||
@@ -62,9 +61,6 @@ const log = getLogger("session")
|
|||||||
|
|
||||||
interface InstanceShellProps {
|
interface InstanceShellProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
// Provided by App-level instance tabs; lets us pause heavy rendering
|
|
||||||
// work for inactive instances while keeping them mounted for fast switching.
|
|
||||||
isActiveInstance?: boolean
|
|
||||||
escapeInDebounce: boolean
|
escapeInDebounce: boolean
|
||||||
paletteCommands: Accessor<Command[]>
|
paletteCommands: Accessor<Command[]>
|
||||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||||
@@ -73,16 +69,10 @@ interface InstanceShellProps {
|
|||||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||||
onExecuteCommand: (command: Command) => void
|
onExecuteCommand: (command: Command) => void
|
||||||
tabBarOffset: number
|
tabBarOffset: number
|
||||||
|
|
||||||
// In-memory only: mobile immersive/fullscreen mode.
|
|
||||||
mobileFullscreenMode: boolean
|
|
||||||
onEnterMobileFullscreen: () => void
|
|
||||||
onExitMobileFullscreen: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||||
const { t, locale } = useI18n()
|
const { t } = useI18n()
|
||||||
const isRTL = () => locale() === "he"
|
|
||||||
|
|
||||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
||||||
@@ -119,7 +109,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||||
|
|
||||||
const tabletQuery = useMediaQuery("(min-width: 768px)")
|
const tabletQuery = useMediaQuery("(min-width: 768px)")
|
||||||
const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
|
|
||||||
|
|
||||||
const layoutMode = createMemo<LayoutMode>(() => {
|
const layoutMode = createMemo<LayoutMode>(() => {
|
||||||
if (desktopQuery()) return "desktop"
|
if (desktopQuery()) return "desktop"
|
||||||
@@ -128,9 +117,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
||||||
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
|
|
||||||
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
|
|
||||||
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
|
|
||||||
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||||
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||||
|
|
||||||
@@ -363,6 +349,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
measureDrawerHost,
|
measureDrawerHost,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
|
||||||
|
|
||||||
|
|
||||||
|
const formattedAvailableTokens = () => {
|
||||||
|
const avail = tokenStats().avail
|
||||||
|
if (typeof avail === "number") {
|
||||||
|
return formatTokenTotal(avail)
|
||||||
|
}
|
||||||
|
return "--"
|
||||||
|
}
|
||||||
|
|
||||||
const renderLeftPanel = () => {
|
const renderLeftPanel = () => {
|
||||||
if (leftPinned()) {
|
if (leftPinned()) {
|
||||||
@@ -372,7 +368,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: `${sessionSidebarWidth()}px`,
|
width: `${sessionSidebarWidth()}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderInlineEnd: "1px solid var(--border-base)",
|
borderRight: "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -414,7 +410,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const modalProps = container ? { container: container as Element } : undefined
|
const modalProps = container ? { container: container as Element } : undefined
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor={isRTL() ? "right" : "left"}
|
anchor="left"
|
||||||
variant="temporary"
|
variant="temporary"
|
||||||
open={leftOpen()}
|
open={leftOpen()}
|
||||||
onClose={closeLeftDrawer}
|
onClose={closeLeftDrawer}
|
||||||
@@ -423,7 +419,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
backgroundImage: "none",
|
backgroundImage: "none",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
@@ -481,7 +477,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: `${rightDrawerWidth()}px`,
|
width: `${rightDrawerWidth()}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderInlineStart: "1px solid var(--border-base)",
|
borderLeft: "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -524,7 +520,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const modalProps = container ? { container: container as Element } : undefined
|
const modalProps = container ? { container: container as Element } : undefined
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor={isRTL() ? "left" : "right"}
|
anchor="right"
|
||||||
variant="temporary"
|
variant="temporary"
|
||||||
open={rightOpen()}
|
open={rightOpen()}
|
||||||
onClose={closeRightDrawer}
|
onClose={closeRightDrawer}
|
||||||
@@ -533,7 +529,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
backgroundImage: "none",
|
backgroundImage: "none",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
@@ -598,14 +594,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
{renderLeftPanel()}
|
{renderLeftPanel()}
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
||||||
<Show when={!mobileFullscreen()}>
|
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
<Show
|
||||||
<Show
|
when={!isPhoneLayout()}
|
||||||
when={!compactHeaderLayout()}
|
fallback={
|
||||||
fallback={
|
<div class="flex flex-col w-full gap-1.5">
|
||||||
<div class="flex flex-col w-full gap-1.5">
|
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
|
||||||
<Show when={leftDrawerState() === "floating-closed"}>
|
<Show when={leftDrawerState() === "floating-closed"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setLeftToggleButtonEl}
|
ref={setLeftToggleButtonEl}
|
||||||
@@ -631,17 +626,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button command-palette-button"
|
class="connection-status-button px-2 py-0.5 text-xs"
|
||||||
onClick={handleCommandPaletteClick}
|
onClick={handleCommandPaletteClick}
|
||||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
style={{ flex: "0 0 auto", width: "auto" }}
|
||||||
>
|
>
|
||||||
{t("instanceShell.commandPalette.button")}
|
{t("instanceShell.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint kbd-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||||
<span
|
<span
|
||||||
@@ -652,18 +647,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
onClick={props.onEnterMobileFullscreen}
|
|
||||||
aria-label={t("instanceShell.fullscreen.enter")}
|
|
||||||
title={t("instanceShell.fullscreen.enter")}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<Maximize2 class="w-5 h-5" aria-hidden="true" />
|
|
||||||
</IconButton>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={rightDrawerState() === "floating-closed"}>
|
<Show when={rightDrawerState() === "floating-closed"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setRightToggleButtonEl}
|
ref={setRightToggleButtonEl}
|
||||||
@@ -676,19 +659,22 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
{rightAppBarButtonIcon()}
|
{rightAppBarButtonIcon()}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||||
<Show when={!showingInfoView()}>
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
<ContextMeter
|
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||||
usedTokens={tokenStats().used}
|
{t("instanceShell.metrics.usedLabel")}
|
||||||
availableTokens={tokenStats().avail}
|
</span>
|
||||||
formatTokens={formatTokenTotal}
|
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
|
||||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||||
|
{t("instanceShell.metrics.availableLabel")}
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -707,13 +693,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!showingInfoView()}>
|
<Show when={!showingInfoView()}>
|
||||||
<ContextMeter
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
usedTokens={tokenStats().used}
|
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||||
availableTokens={tokenStats().avail}
|
{t("instanceShell.metrics.usedLabel")}
|
||||||
formatTokens={formatTokenTotal}
|
</span>
|
||||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
</div>
|
||||||
/>
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-muted">
|
||||||
|
{t("instanceShell.metrics.availableLabel")}
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center session-header-hints">
|
<div class="ml-auto flex items-center session-header-hints">
|
||||||
@@ -729,7 +720,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
|
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button command-palette-button"
|
class="connection-status-button px-2 py-0.5 text-xs"
|
||||||
onClick={handleCommandPaletteClick}
|
onClick={handleCommandPaletteClick}
|
||||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
style={{ flex: "0 0 auto", width: "auto" }}
|
||||||
@@ -739,11 +730,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
||||||
<span class="connection-status-shortcut-hint kbd-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="ms-auto flex items-center gap-3">
|
<div class="ml-auto flex items-center gap-3">
|
||||||
<div class="connection-status-meta flex items-center gap-3">
|
<div class="connection-status-meta flex items-center gap-3">
|
||||||
<Show when={connectionStatus() === "connected"}>
|
<Show when={connectionStatus() === "connected"}>
|
||||||
<span class="status-indicator connected">
|
<span class="status-indicator connected">
|
||||||
@@ -778,10 +769,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
@@ -804,14 +794,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<For each={cachedSessionIds()}>
|
<For each={cachedSessionIds()}>
|
||||||
{(sessionId) => {
|
{(sessionId) => {
|
||||||
const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
|
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||||
style={{ display: isActive() ? "flex" : "none" }}
|
style={{ display: isActive() ? "flex" : "none" }}
|
||||||
data-session-id={sessionId}
|
data-session-id={sessionId}
|
||||||
data-instance-id={props.instance.id}
|
|
||||||
data-session-active={isActive() ? "true" : "false"}
|
|
||||||
aria-hidden={!isActive()}
|
aria-hidden={!isActive()}
|
||||||
>
|
>
|
||||||
<SessionView
|
<SessionView
|
||||||
@@ -820,8 +808,6 @@ const InstanceShell2: 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}
|
||||||
isPhoneLayout={isPhoneLayout()}
|
|
||||||
compactPromptLayout={compactPromptLayout()}
|
|
||||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||||
onSidebarToggle={() => setLeftOpen(true)}
|
onSidebarToggle={() => setLeftOpen(true)}
|
||||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||||
@@ -847,10 +833,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div class="instance-shell2 flex flex-col flex-1 min-h-0">
|
||||||
class="instance-shell2 flex flex-col flex-1 min-h-0"
|
|
||||||
data-instance-id={props.instance.id}
|
|
||||||
>
|
|
||||||
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||||
{sessionLayout}
|
{sessionLayout}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Show,
|
Show,
|
||||||
Suspense,
|
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
lazy,
|
|
||||||
onCleanup,
|
onCleanup,
|
||||||
type Accessor,
|
type Accessor,
|
||||||
type Component,
|
type Component,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import IconButton from "@suid/material/IconButton"
|
import IconButton from "@suid/material/IconButton"
|
||||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
@@ -20,7 +18,12 @@ import type { Instance } from "../../../../types/instance"
|
|||||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||||
import type { Session } from "../../../../types/session"
|
import type { Session } from "../../../../types/session"
|
||||||
import type { DrawerViewState } from "../types"
|
import type { DrawerViewState } from "../types"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
|
||||||
|
|
||||||
|
import ChangesTab from "./tabs/ChangesTab"
|
||||||
|
import FilesTab from "./tabs/FilesTab"
|
||||||
|
import GitChangesTab from "./tabs/GitChangesTab"
|
||||||
|
import StatusTab from "./tabs/StatusTab"
|
||||||
|
|
||||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||||
import { requestData } from "../../../../lib/opencode-api"
|
import { requestData } from "../../../../lib/opencode-api"
|
||||||
@@ -29,7 +32,6 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
|||||||
import {
|
import {
|
||||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
|
|
||||||
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||||
@@ -46,15 +48,6 @@ import {
|
|||||||
readStoredRightPanelTab,
|
readStoredRightPanelTab,
|
||||||
} from "../storage"
|
} from "../storage"
|
||||||
|
|
||||||
const LazyChangesTab = lazy(() => import("./tabs/ChangesTab"))
|
|
||||||
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
|
|
||||||
const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
|
|
||||||
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
|
|
||||||
|
|
||||||
function RightPanelTabFallback() {
|
|
||||||
return <div class="flex-1 min-h-0" />
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RightPanelProps {
|
interface RightPanelProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -109,9 +102,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
||||||
)
|
)
|
||||||
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
|
|
||||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
|
|
||||||
)
|
|
||||||
|
|
||||||
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
||||||
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
||||||
@@ -205,11 +195,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
|
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
|
|
||||||
})
|
|
||||||
|
|
||||||
const clampSplitWidth = (value: number) => {
|
const clampSplitWidth = (value: number) => {
|
||||||
const min = 200
|
const min = 200
|
||||||
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||||
@@ -249,8 +234,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const mode = activeSplitResize()
|
const mode = activeSplitResize()
|
||||||
if (!mode) return
|
if (!mode) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
const delta = event.clientX - splitResizeStartX()
|
||||||
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
|
||||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
if (mode === "changes") setChangesSplitWidth(next)
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
@@ -273,8 +257,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const touch = event.touches[0]
|
const touch = event.touches[0]
|
||||||
if (!touch) return
|
if (!touch) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
const delta = touch.clientX - splitResizeStartX()
|
||||||
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
|
||||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
if (mode === "changes") setChangesSplitWidth(next)
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
@@ -573,13 +556,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
void loadBrowserEntries(browserPath())
|
void loadBrowserEntries(browserPath())
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (rightPanelTab() === "files") return
|
|
||||||
setBrowserSelectedContent(null)
|
|
||||||
setBrowserSelectedLoading(false)
|
|
||||||
setBrowserSelectedError(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (rightPanelTab() !== "git-changes") return
|
if (rightPanelTab() !== "git-changes") return
|
||||||
if (gitStatusLoading()) return
|
if (gitStatusLoading()) return
|
||||||
@@ -587,14 +563,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
void loadGitStatus()
|
void loadGitStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (rightPanelTab() === "git-changes") return
|
|
||||||
setGitSelectedBefore(null)
|
|
||||||
setGitSelectedAfter(null)
|
|
||||||
setGitSelectedLoading(false)
|
|
||||||
setGitSelectedError(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||||
setSelectedFile(file)
|
setSelectedFile(file)
|
||||||
if (closeList) {
|
if (closeList) {
|
||||||
@@ -761,109 +729,97 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<Show when={rightPanelTab() === "changes"}>
|
<Show when={rightPanelTab() === "changes"}>
|
||||||
<Suspense fallback={<RightPanelTabFallback />}>
|
<ChangesTab
|
||||||
<LazyChangesTab
|
t={props.t}
|
||||||
t={props.t}
|
instanceId={props.instanceId}
|
||||||
instanceId={props.instanceId}
|
activeSessionId={props.activeSessionId}
|
||||||
activeSessionId={props.activeSessionId}
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
activeSessionDiffs={props.activeSessionDiffs}
|
selectedFile={selectedFile}
|
||||||
selectedFile={selectedFile}
|
onSelectFile={handleSelectChangesFile}
|
||||||
onSelectFile={handleSelectChangesFile}
|
diffViewMode={diffViewMode}
|
||||||
diffViewMode={diffViewMode}
|
diffContextMode={diffContextMode}
|
||||||
diffContextMode={diffContextMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
diffWordWrapMode={diffWordWrapMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
listOpen={changesListOpen}
|
||||||
onContextModeChange={setDiffContextMode}
|
onToggleList={toggleChangesList}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
splitWidth={changesSplitWidth}
|
||||||
listOpen={changesListOpen}
|
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||||
onToggleList={toggleChangesList}
|
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||||
splitWidth={changesSplitWidth}
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
/>
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "git-changes"}>
|
<Show when={rightPanelTab() === "git-changes"}>
|
||||||
<Suspense fallback={<RightPanelTabFallback />}>
|
<GitChangesTab
|
||||||
<LazyGitChangesTab
|
t={props.t}
|
||||||
t={props.t}
|
activeSessionId={props.activeSessionId}
|
||||||
activeSessionId={props.activeSessionId}
|
entries={gitStatusEntries}
|
||||||
entries={gitStatusEntries}
|
statusLoading={gitStatusLoading}
|
||||||
statusLoading={gitStatusLoading}
|
statusError={gitStatusError}
|
||||||
statusError={gitStatusError}
|
selectedPath={gitSelectedPath}
|
||||||
selectedPath={gitSelectedPath}
|
selectedLoading={gitSelectedLoading}
|
||||||
selectedLoading={gitSelectedLoading}
|
selectedError={gitSelectedError}
|
||||||
selectedError={gitSelectedError}
|
selectedBefore={gitSelectedBefore}
|
||||||
selectedBefore={gitSelectedBefore}
|
selectedAfter={gitSelectedAfter}
|
||||||
selectedAfter={gitSelectedAfter}
|
mostChangedPath={gitMostChangedPath}
|
||||||
mostChangedPath={gitMostChangedPath}
|
scopeKey={gitScopeKey}
|
||||||
scopeKey={gitScopeKey}
|
diffViewMode={diffViewMode}
|
||||||
diffViewMode={diffViewMode}
|
diffContextMode={diffContextMode}
|
||||||
diffContextMode={diffContextMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
diffWordWrapMode={diffWordWrapMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
onOpenFile={(path) => void openGitFile(path)}
|
||||||
onContextModeChange={setDiffContextMode}
|
onRefresh={() => void refreshGitStatus()}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
listOpen={gitChangesListOpen}
|
||||||
onOpenFile={(path: string) => void openGitFile(path)}
|
onToggleList={toggleGitList}
|
||||||
onRefresh={() => void refreshGitStatus()}
|
splitWidth={gitChangesSplitWidth}
|
||||||
listOpen={gitChangesListOpen}
|
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||||
onToggleList={toggleGitList}
|
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||||
splitWidth={gitChangesSplitWidth}
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
/>
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "files"}>
|
<Show when={rightPanelTab() === "files"}>
|
||||||
<Suspense fallback={<RightPanelTabFallback />}>
|
<FilesTab
|
||||||
<LazyFilesTab
|
t={props.t}
|
||||||
t={props.t}
|
browserPath={browserPath}
|
||||||
browserPath={browserPath}
|
browserEntries={browserEntries}
|
||||||
browserEntries={browserEntries}
|
browserLoading={browserLoading}
|
||||||
browserLoading={browserLoading}
|
browserError={browserError}
|
||||||
browserError={browserError}
|
browserSelectedPath={browserSelectedPath}
|
||||||
browserSelectedPath={browserSelectedPath}
|
browserSelectedContent={browserSelectedContent}
|
||||||
browserSelectedContent={browserSelectedContent}
|
browserSelectedLoading={browserSelectedLoading}
|
||||||
browserSelectedLoading={browserSelectedLoading}
|
browserSelectedError={browserSelectedError}
|
||||||
browserSelectedError={browserSelectedError}
|
parentPath={browserParentPath}
|
||||||
parentPath={browserParentPath}
|
scopeKey={browserScopeKey}
|
||||||
scopeKey={browserScopeKey}
|
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
||||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
onOpenFile={(path) => void openBrowserFile(path)}
|
||||||
onOpenFile={(path: string) => void openBrowserFile(path)}
|
onRefresh={() => void refreshFilesTab()}
|
||||||
onRefresh={() => void refreshFilesTab()}
|
listOpen={filesListOpen}
|
||||||
listOpen={filesListOpen}
|
onToggleList={toggleFilesList}
|
||||||
onToggleList={toggleFilesList}
|
splitWidth={filesSplitWidth}
|
||||||
splitWidth={filesSplitWidth}
|
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "status"}>
|
<Show when={rightPanelTab() === "status"}>
|
||||||
<Suspense fallback={<RightPanelTabFallback />}>
|
<StatusTab
|
||||||
<LazyStatusTab
|
t={props.t}
|
||||||
t={props.t}
|
instanceId={props.instanceId}
|
||||||
instanceId={props.instanceId}
|
instance={props.instance}
|
||||||
instance={props.instance}
|
activeSessionId={props.activeSessionId}
|
||||||
activeSessionId={props.activeSessionId}
|
activeSession={props.activeSession}
|
||||||
activeSession={props.activeSession}
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
activeSessionDiffs={props.activeSessionDiffs}
|
latestTodoState={props.latestTodoState}
|
||||||
latestTodoState={props.latestTodoState}
|
backgroundProcessList={props.backgroundProcessList}
|
||||||
backgroundProcessList={props.backgroundProcessList}
|
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
expandedItems={rightPanelExpandedItems}
|
||||||
expandedItems={rightPanelExpandedItems}
|
onExpandedItemsChange={handleAccordionChange}
|
||||||
onExpandedItemsChange={handleAccordionChange}
|
onOpenChangesTab={openChangesTabFromStatus}
|
||||||
onOpenChangesTab={openChangesTabFromStatus}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,50 @@
|
|||||||
import type { Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
|
|
||||||
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||||
|
|
||||||
import { useI18n } from "../../../../../lib/i18n"
|
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
|
||||||
|
|
||||||
interface DiffToolbarProps {
|
interface DiffToolbarProps {
|
||||||
viewMode: DiffViewMode
|
viewMode: DiffViewMode
|
||||||
contextMode: DiffContextMode
|
contextMode: DiffContextMode
|
||||||
wordWrapMode: DiffWordWrapMode
|
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||||
const { t } = useI18n()
|
|
||||||
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
|
|
||||||
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
|
|
||||||
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
|
|
||||||
|
|
||||||
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified"))
|
|
||||||
const contextModeTitle = () =>
|
|
||||||
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull")
|
|
||||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap"))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="file-viewer-toolbar">
|
<div class="file-viewer-toolbar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="file-viewer-toolbar-icon-button"
|
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
|
||||||
onClick={() => props.onViewModeChange(nextViewMode())}
|
aria-pressed={props.viewMode === "split"}
|
||||||
aria-label={viewModeTitle()}
|
onClick={() => props.onViewModeChange("split")}
|
||||||
title={viewModeTitle()}
|
|
||||||
>
|
>
|
||||||
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
Split
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="file-viewer-toolbar-icon-button"
|
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
|
||||||
onClick={() => props.onContextModeChange(nextContextMode())}
|
aria-pressed={props.viewMode === "unified"}
|
||||||
aria-label={contextModeTitle()}
|
onClick={() => props.onViewModeChange("unified")}
|
||||||
title={contextModeTitle()}
|
|
||||||
>
|
>
|
||||||
{nextContextMode() === "collapsed" ? (
|
Unified
|
||||||
<FoldVertical class="h-4 w-4" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
|
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
|
||||||
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
|
aria-pressed={props.contextMode === "collapsed"}
|
||||||
aria-label={wordWrapTitle()}
|
onClick={() => props.onContextModeChange("collapsed")}
|
||||||
title={wordWrapTitle()}
|
title="Hide unchanged regions"
|
||||||
>
|
>
|
||||||
<WrapText class="h-4 w-4" aria-hidden="true" />
|
Collapsed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
|
||||||
|
aria-pressed={props.contextMode === "expanded"}
|
||||||
|
onClick={() => props.onContextModeChange("expanded")}
|
||||||
|
title="Show full file"
|
||||||
|
>
|
||||||
|
Expanded
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Show, type Component, type JSX } from "solid-js"
|
import { Show, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
import { useI18n } from "../../../../../lib/i18n"
|
|
||||||
import OverlayList from "./OverlayList"
|
import OverlayList from "./OverlayList"
|
||||||
|
|
||||||
type SplitFilePanelList = {
|
type SplitFilePanelList = {
|
||||||
@@ -25,13 +24,12 @@ interface SplitFilePanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||||
const { t } = useI18n()
|
|
||||||
return (
|
return (
|
||||||
<div class="files-tab-container">
|
<div class="files-tab-container">
|
||||||
<div class="files-tab-header">
|
<div class="files-tab-header">
|
||||||
<div class="files-tab-header-row">
|
<div class="files-tab-header-row">
|
||||||
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||||
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
|
{props.listOpen ? "Hide files" : "Show files"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{props.header}
|
{props.header}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
|
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||||
|
|
||||||
const LazyMonacoDiffViewer = lazy(() =>
|
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ChangesTabProps {
|
interface ChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -20,10 +18,8 @@ interface ChangesTabProps {
|
|||||||
|
|
||||||
diffViewMode: Accessor<DiffViewMode>
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
diffContextMode: Accessor<DiffContextMode>
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
|
||||||
|
|
||||||
listOpen: Accessor<boolean>
|
listOpen: Accessor<boolean>
|
||||||
onToggleList: () => void
|
onToggleList: () => void
|
||||||
@@ -34,18 +30,14 @@ interface ChangesTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||||
const sessionId = createMemo(() => props.activeSessionId())
|
const renderContent = (): JSX.Element => {
|
||||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
const sessionId = props.activeSessionId()
|
||||||
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
|
|
||||||
|
|
||||||
const sorted = createMemo<any[]>(() => {
|
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||||
const list = diffs()
|
const diffs = hasSession ? props.activeSessionDiffs() : null
|
||||||
if (!Array.isArray(list)) return []
|
|
||||||
return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
|
||||||
})
|
|
||||||
|
|
||||||
const totals = createMemo(() => {
|
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
|
||||||
return sorted().reduce(
|
const totals = sorted.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
@@ -53,61 +45,49 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{ additions: 0, deletions: 0 },
|
{ additions: 0, deletions: 0 },
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const mostChanged = createMemo<any | null>(() => {
|
const mostChanged = sorted.length
|
||||||
const items = sorted()
|
? sorted.reduce((best, item) => {
|
||||||
if (items.length === 0) return null
|
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
||||||
return items.reduce((best, item) => {
|
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
|
||||||
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
const bestScore = bestAdd + bestDel
|
||||||
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
|
|
||||||
const bestScore = bestAdd + bestDel
|
|
||||||
|
|
||||||
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
||||||
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||||
const score = add + del
|
const score = add + del
|
||||||
|
|
||||||
if (score > bestScore) return item
|
if (score > bestScore) return item
|
||||||
if (score < bestScore) return best
|
if (score < bestScore) return best
|
||||||
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||||
}, items[0])
|
}, sorted[0])
|
||||||
})
|
: null
|
||||||
|
|
||||||
const selectedFileData = createMemo<any | null>(() => {
|
// Auto-select the most-changed file if none selected.
|
||||||
const currentSelected = props.selectedFile()
|
const currentSelected = props.selectedFile()
|
||||||
const items = sorted()
|
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
|
||||||
if (currentSelected) {
|
|
||||||
const match = items.find((f) => f.file === currentSelected)
|
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
|
||||||
if (match) return match
|
|
||||||
|
const emptyViewerMessage = () => {
|
||||||
|
if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
||||||
|
if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
|
||||||
|
if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
|
||||||
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
}
|
}
|
||||||
return mostChanged()
|
|
||||||
})
|
|
||||||
|
|
||||||
const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`)
|
|
||||||
|
|
||||||
const emptyViewerMessage = createMemo(() => {
|
|
||||||
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
|
||||||
const currentDiffs = diffs()
|
|
||||||
if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading")
|
|
||||||
if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
|
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
|
||||||
})
|
|
||||||
|
|
||||||
const headerPath = createMemo(() => {
|
|
||||||
const file = selectedFileData()
|
|
||||||
return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes")
|
|
||||||
})
|
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
|
||||||
const sortedList = sorted()
|
|
||||||
const totalsValue = totals()
|
|
||||||
const selected = selectedFileData()
|
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-header">
|
||||||
|
<DiffToolbar
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
onViewModeChange={props.onViewModeChange}
|
||||||
|
onContextModeChange={props.onContextModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="file-viewer-content file-viewer-content--monaco">
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
<Show
|
<Show
|
||||||
when={selected && hasSession() && sortedList.length > 0 ? selected : null}
|
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||||
@@ -115,23 +95,14 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Suspense
|
<MonacoDiffViewer
|
||||||
fallback={
|
scopeKey={scopeKey}
|
||||||
<div class="file-viewer-empty">
|
path={String(file().file || "")}
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
before={String((file() as any).before || "")}
|
||||||
</div>
|
after={String((file() as any).after || "")}
|
||||||
}
|
viewMode={props.diffViewMode()}
|
||||||
>
|
contextMode={props.diffContextMode()}
|
||||||
<LazyMonacoDiffViewer
|
/>
|
||||||
scopeKey={scopeKey()}
|
|
||||||
path={String(file().file || "")}
|
|
||||||
before={String((file() as any).before || "")}
|
|
||||||
after={String((file() as any).after || "")}
|
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
wordWrap={props.diffWordWrapMode()}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,11 +114,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderListPanel = () => (
|
const renderListPanel = () => (
|
||||||
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sortedList}>
|
<For each={sorted}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
|
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onSelectFile(item.file, props.isPhoneLayout())
|
props.onSelectFile(item.file, props.isPhoneLayout())
|
||||||
}}
|
}}
|
||||||
@@ -168,11 +139,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderListOverlay = () => (
|
const renderListOverlay = () => (
|
||||||
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sortedList}>
|
<For each={sorted}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
|
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onSelectFile(item.file, true)
|
props.onSelectFile(item.file, true)
|
||||||
}}
|
}}
|
||||||
@@ -193,6 +164,8 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitFilePanel
|
<SplitFilePanel
|
||||||
header={
|
header={
|
||||||
@@ -203,23 +176,12 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
|
|
||||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
<span class="files-tab-stat files-tab-stat-additions">
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
<span class="files-tab-stat-value">+{totalsValue.additions}</span>
|
<span class="files-tab-stat-value">+{totals.additions}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="files-tab-stat files-tab-stat-deletions">
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
<span class="files-tab-stat-value">-{totalsValue.deletions}</span>
|
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ "margin-left": "auto" }}>
|
|
||||||
<DiffToolbar
|
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
wordWrapMode={props.diffWordWrapMode()}
|
|
||||||
onViewModeChange={props.onViewModeChange}
|
|
||||||
onContextModeChange={props.onContextModeChange}
|
|
||||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
@@ -230,7 +192,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
|
overlayAriaLabel="Changes"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||||
|
|
||||||
const LazyMonacoFileViewer = lazy(() =>
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -53,8 +51,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||||
|
|
||||||
const emptyViewerMessage = () => {
|
const emptyViewerMessage = () => {
|
||||||
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
return "Select a file to preview"
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
@@ -79,15 +77,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(payload) => (
|
{(payload) => (
|
||||||
<Suspense
|
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||||
fallback={
|
|
||||||
<div class="file-viewer-empty">
|
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -101,7 +91,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +113,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.browserLoading() && entriesValue === null}>
|
<Show when={props.browserLoading() && entriesValue === null}>
|
||||||
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={sorted}>
|
<For each={sorted}>
|
||||||
@@ -164,7 +154,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.browserLoading()}>
|
<Show when={props.browserLoading()}>
|
||||||
<span>{props.t("instanceInfo.loading")}</span>
|
<span>Loading…</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +165,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
disabled={props.browserLoading()}
|
disabled={props.browserLoading()}
|
||||||
style={{ "margin-inline-start": "auto" }}
|
style={{ "margin-left": "auto" }}
|
||||||
onClick={() => props.onRefresh()}
|
onClick={() => props.onRefresh()}
|
||||||
>
|
>
|
||||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||||
@@ -190,7 +180,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
overlayAriaLabel="Files"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
|
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||||
|
|
||||||
const LazyMonacoDiffViewer = lazy(() =>
|
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface GitChangesTabProps {
|
interface GitChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -31,10 +29,8 @@ interface GitChangesTabProps {
|
|||||||
|
|
||||||
diffViewMode: Accessor<DiffViewMode>
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
diffContextMode: Accessor<DiffContextMode>
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
|
||||||
|
|
||||||
onOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
@@ -48,18 +44,17 @@ interface GitChangesTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||||
const sessionId = createMemo(() => props.activeSessionId())
|
const renderContent = (): JSX.Element => {
|
||||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
const sessionId = props.activeSessionId()
|
||||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
|
||||||
|
|
||||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||||
const list = entries()
|
const entries = hasSession ? props.entries() : null
|
||||||
if (!Array.isArray(list)) return []
|
|
||||||
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
|
||||||
})
|
|
||||||
|
|
||||||
const totals = createMemo(() => {
|
const sorted = Array.isArray(entries)
|
||||||
return sorted().reduce(
|
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const totals = sorted.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||||
@@ -67,36 +62,32 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{ additions: 0, deletions: 0 },
|
{ additions: 0, deletions: 0 },
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
|
const nonDeleted = sorted.filter((item) => item && item.status !== "deleted")
|
||||||
|
|
||||||
|
const emptyViewerMessage = () => {
|
||||||
|
if (!hasSession) return "Select a session to view changes."
|
||||||
|
if (entries === null) return "Loading git changes…"
|
||||||
|
if (nonDeleted.length === 0) return "No git changes yet."
|
||||||
|
return "No file selected."
|
||||||
|
}
|
||||||
|
|
||||||
const selectedEntry = createMemo<GitFileStatus | null>(() => {
|
|
||||||
const list = sorted()
|
|
||||||
const selectedPath = props.selectedPath()
|
const selectedPath = props.selectedPath()
|
||||||
const fallbackPath = props.mostChangedPath()
|
const fallbackPath = props.mostChangedPath()
|
||||||
const found =
|
const selectedEntry =
|
||||||
list.find((item) => item.path === selectedPath) ||
|
sorted.find((item) => item.path === selectedPath) ||
|
||||||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
|
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
|
||||||
return found ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const emptyViewerMessage = createMemo(() => {
|
|
||||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
|
||||||
const currentEntries = entries()
|
|
||||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
|
||||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
|
||||||
})
|
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
|
||||||
const totalsValue = totals()
|
|
||||||
const selected = selectedEntry()
|
|
||||||
const sortedList = sorted()
|
|
||||||
const nonDeletedList = nonDeleted()
|
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-header">
|
||||||
|
<DiffToolbar
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
onViewModeChange={props.onViewModeChange}
|
||||||
|
onContextModeChange={props.onContextModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="file-viewer-content file-viewer-content--monaco">
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
<Show
|
<Show
|
||||||
when={props.selectedLoading()}
|
when={props.selectedLoading()}
|
||||||
@@ -106,12 +97,12 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
fallback={
|
fallback={
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
selected &&
|
selectedEntry &&
|
||||||
props.selectedBefore() !== null &&
|
props.selectedBefore() !== null &&
|
||||||
props.selectedAfter() !== null &&
|
props.selectedAfter() !== null &&
|
||||||
selected.status !== "deleted"
|
selectedEntry.status !== "deleted"
|
||||||
? {
|
? {
|
||||||
path: selected.path,
|
path: selectedEntry.path,
|
||||||
before: props.selectedBefore() as string,
|
before: props.selectedBefore() as string,
|
||||||
after: props.selectedAfter() as string,
|
after: props.selectedAfter() as string,
|
||||||
}
|
}
|
||||||
@@ -124,23 +115,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Suspense
|
<MonacoDiffViewer
|
||||||
fallback={
|
scopeKey={props.scopeKey()}
|
||||||
<div class="file-viewer-empty">
|
path={String(file().path || "")}
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
before={String((file() as any).before || "")}
|
||||||
</div>
|
after={String((file() as any).after || "")}
|
||||||
}
|
viewMode={props.diffViewMode()}
|
||||||
>
|
contextMode={props.diffContextMode()}
|
||||||
<LazyMonacoDiffViewer
|
/>
|
||||||
scopeKey={props.scopeKey()}
|
|
||||||
path={String(file().path || "")}
|
|
||||||
before={String((file() as any).before || "")}
|
|
||||||
after={String((file() as any).after || "")}
|
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
wordWrap={props.diffWordWrapMode()}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -154,7 +136,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,8 +146,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
|
||||||
const renderListPanel = () => (
|
const renderListPanel = () => (
|
||||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sortedList}>
|
<For each={sorted}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
@@ -179,7 +161,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -196,8 +178,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderListOverlay = () => (
|
const renderListOverlay = () => (
|
||||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sortedList}>
|
<For each={sorted}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
@@ -210,7 +192,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -227,19 +209,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitFilePanel
|
<SplitFilePanel
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
|
||||||
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
<span class="files-tab-stat files-tab-stat-additions">
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
<span class="files-tab-stat-value">+{totalsValue.additions}</span>
|
<span class="files-tab-stat-value">+{totals.additions}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="files-tab-stat files-tab-stat-deletions">
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
<span class="files-tab-stat-value">-{totalsValue.deletions}</span>
|
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,23 +231,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
class="files-header-icon-button"
|
class="files-header-icon-button"
|
||||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
disabled={!hasSession() || props.statusLoading() || entries() === null}
|
disabled={!hasSession || props.statusLoading() || entries === null}
|
||||||
style={{ "margin-left": "auto" }}
|
style={{ "margin-left": "auto" }}
|
||||||
onClick={() => props.onRefresh()}
|
onClick={() => props.onRefresh()}
|
||||||
>
|
>
|
||||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
<DiffToolbar
|
}
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
wordWrapMode={props.diffWordWrapMode()}
|
|
||||||
onViewModeChange={props.onViewModeChange}
|
|
||||||
onContextModeChange={props.onContextModeChange}
|
|
||||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
viewer={renderViewer()}
|
viewer={renderViewer()}
|
||||||
listOpen={props.listOpen()}
|
listOpen={props.listOpen()}
|
||||||
@@ -274,7 +247,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
overlayAriaLabel="Git Changes"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { For, Show, type Accessor, type Component } from "solid-js"
|
import { For, Show, type Accessor, type Component } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import { Accordion } from "@kobalte/core"
|
import { Accordion } from "@kobalte/core"
|
||||||
import { Tooltip } from "@kobalte/core/tooltip"
|
|
||||||
|
|
||||||
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||||
|
|
||||||
import type { Instance } from "../../../../../types/instance"
|
import type { Instance } from "../../../../../types/instance"
|
||||||
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||||
@@ -207,25 +206,21 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
{
|
{
|
||||||
id: "session-changes",
|
id: "session-changes",
|
||||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||||
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
|
|
||||||
render: renderStatusSessionChanges,
|
render: renderStatusSessionChanges,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "plan",
|
id: "plan",
|
||||||
labelKey: "instanceShell.rightPanel.sections.plan",
|
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||||
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
|
|
||||||
render: renderPlanSectionContent,
|
render: renderPlanSectionContent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "background-processes",
|
id: "background-processes",
|
||||||
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||||
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
|
|
||||||
render: renderBackgroundProcesses,
|
render: renderBackgroundProcesses,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mcp",
|
id: "mcp",
|
||||||
labelKey: "instanceShell.rightPanel.sections.mcp",
|
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||||
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
|
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -238,7 +233,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
{
|
{
|
||||||
id: "lsp",
|
id: "lsp",
|
||||||
labelKey: "instanceShell.rightPanel.sections.lsp",
|
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||||
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
|
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -251,7 +245,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
{
|
{
|
||||||
id: "plugins",
|
id: "plugins",
|
||||||
labelKey: "instanceShell.rightPanel.sections.plugins",
|
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||||
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
|
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -283,23 +276,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||||
<Accordion.Header>
|
<Accordion.Header>
|
||||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||||
<span class="section-left">
|
<span>{props.t(section.labelKey)}</span>
|
||||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
|
||||||
<Tooltip.Trigger
|
|
||||||
class="section-info-trigger"
|
|
||||||
aria-label={props.t(section.tooltipKey)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Info class="section-info-icon" />
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content class="section-info-tooltip">
|
|
||||||
{props.t(section.tooltipKey)}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
<span class="section-label">{props.t(section.labelKey)}</span>
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,5 +3,3 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
|
|||||||
export type DiffViewMode = "split" | "unified"
|
export type DiffViewMode = "split" | "unified"
|
||||||
|
|
||||||
export type DiffContextMode = "expanded" | "collapsed"
|
export type DiffContextMode = "expanded" | "collapsed"
|
||||||
|
|
||||||
export type DiffWordWrapMode = "on" | "off"
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-
|
|||||||
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
||||||
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
|
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
|
||||||
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
||||||
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
|
|
||||||
|
|
||||||
export const clampWidth = (value: number) =>
|
export const clampWidth = (value: number) =>
|
||||||
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
|
|||||||
if (!side) return
|
if (!side) return
|
||||||
const startWidth = resizeStartWidth()
|
const startWidth = resizeStartWidth()
|
||||||
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
||||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||||
const rawDelta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
|
||||||
const delta = isRtl ? -rawDelta : rawDelta
|
|
||||||
const nextWidth = clamp(startWidth + delta)
|
const nextWidth = clamp(startWidth + delta)
|
||||||
applyDrawerWidth(side, nextWidth)
|
applyDrawerWidth(side, nextWidth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { batch, createMemo, type Accessor } from "solid-js"
|
import { batch, createMemo, type Accessor } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { Session } from "../../../types/session"
|
import type { Session } from "../../../types/session"
|
||||||
import {
|
import {
|
||||||
activeParentSessionId,
|
activeParentSessionId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
@@ -7,20 +8,6 @@ import { useI18n } from "../lib/i18n"
|
|||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
type MarkdownModule = typeof import("../lib/markdown")
|
|
||||||
|
|
||||||
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
|
||||||
|
|
||||||
function loadMarkdownModule(): Promise<MarkdownModule> {
|
|
||||||
if (!markdownModulePromise) {
|
|
||||||
markdownModulePromise = import("../lib/markdown").catch((error) => {
|
|
||||||
markdownModulePromise = null
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return markdownModulePromise
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashText(value: string): string {
|
function hashText(value: string): string {
|
||||||
let hash = 2166136261
|
let hash = 2166136261
|
||||||
for (let index = 0; index < value.length; index++) {
|
for (let index = 0; index < value.length; index++) {
|
||||||
@@ -37,45 +24,6 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
|||||||
return `text-${hashText(text)}`
|
return `text-${hashText(text)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePartCacheId(part: TextPart, text: string): string {
|
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
|
||||||
if (partId) {
|
|
||||||
return partId
|
|
||||||
}
|
|
||||||
|
|
||||||
return `anonymous:${hashText(text)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeHtmlEntitiesLocally(content: string): string {
|
|
||||||
if (!content.includes("&") || typeof document === "undefined") {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement("textarea")
|
|
||||||
textarea.innerHTML = content
|
|
||||||
return textarea.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(content: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
}
|
|
||||||
|
|
||||||
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFallbackHtml(content: string): string {
|
|
||||||
if (!content) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return escapeHtml(content).replace(/\n/g, "<br />")
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
part: TextPart
|
part: TextPart
|
||||||
instanceId?: string
|
instanceId?: string
|
||||||
@@ -90,8 +38,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestKey = ""
|
let latestRequestedText = ""
|
||||||
let cleanupLanguageListener: (() => void) | undefined
|
|
||||||
|
|
||||||
const notifyRendered = () => {
|
const notifyRendered = () => {
|
||||||
Promise.resolve().then(() => props.onRendered?.())
|
Promise.resolve().then(() => props.onRendered?.())
|
||||||
@@ -100,14 +47,15 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const resolved = createMemo(() => {
|
const resolved = createMemo(() => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
const rawText = typeof part.text === "string" ? part.text : ""
|
const rawText = typeof part.text === "string" ? part.text : ""
|
||||||
const text = decodeHtmlEntitiesLocally(rawText)
|
const text = decodeHtmlEntities(rawText)
|
||||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||||
const highlightEnabled = !props.disableHighlight
|
const highlightEnabled = !props.disableHighlight
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||||
const cacheId = resolvePartCacheId(part, text)
|
if (!partId) {
|
||||||
|
throw new Error("Markdown rendering requires a part id")
|
||||||
|
}
|
||||||
const version = resolvePartVersion(part, text)
|
const version = resolvePartVersion(part, text)
|
||||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheHandle = useGlobalCache({
|
const cacheHandle = useGlobalCache({
|
||||||
@@ -115,46 +63,26 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
sessionId: () => props.sessionId,
|
sessionId: () => props.sessionId,
|
||||||
scope: "markdown",
|
scope: "markdown",
|
||||||
cacheId: () => {
|
cacheId: () => {
|
||||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
const { partId, themeKey, highlightEnabled } = resolved()
|
||||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||||
},
|
},
|
||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
|
|
||||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
createEffect(async () => {
|
||||||
const cacheEntry: RenderCache = {
|
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||||
text: snapshot.text,
|
|
||||||
html: renderedHtml,
|
|
||||||
theme: snapshot.themeKey,
|
|
||||||
mode: snapshot.version,
|
|
||||||
}
|
|
||||||
setHtml(renderedHtml)
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||||
const markdown = await loadMarkdownModule()
|
setMarkdownTheme(themeKey === "dark")
|
||||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
|
||||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
|
||||||
suppressHighlight: !snapshot.highlightEnabled,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
latestRequestedText = text
|
||||||
commitCacheEntry(snapshot, rendered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const snapshot = resolved()
|
|
||||||
latestRequestKey = snapshot.requestKey
|
|
||||||
|
|
||||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||||
if (!cache) return false
|
if (!cache) return false
|
||||||
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
return cache.theme === themeKey && cache.mode === version
|
||||||
}
|
}
|
||||||
|
|
||||||
const localCache = snapshot.part.renderCache
|
const localCache = part.renderCache
|
||||||
if (localCache && cacheMatches(localCache)) {
|
if (localCache && cacheMatches(localCache)) {
|
||||||
setHtml(localCache.html)
|
setHtml(localCache.html)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
@@ -164,92 +92,115 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const globalCache = cacheHandle.get<RenderCache>()
|
const globalCache = cacheHandle.get<RenderCache>()
|
||||||
if (globalCache && cacheMatches(globalCache)) {
|
if (globalCache && cacheMatches(globalCache)) {
|
||||||
setHtml(globalCache.html)
|
setHtml(globalCache.html)
|
||||||
|
part.renderCache = globalCache
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setHtml(renderFallbackHtml(snapshot.text))
|
const commitCacheEntry = (renderedHtml: string) => {
|
||||||
notifyRendered()
|
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||||
|
setHtml(renderedHtml)
|
||||||
|
part.renderCache = cacheEntry
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
|
}
|
||||||
|
|
||||||
void renderSnapshot(snapshot).catch((error) => {
|
if (!highlightEnabled) {
|
||||||
log.error("Failed to render markdown:", error)
|
part.renderCache = undefined
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
|
||||||
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
try {
|
||||||
|
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||||
|
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(rendered)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to render markdown:", error)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rendered = await renderMarkdown(text)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(rendered)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to render markdown:", error)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handleClick = async (event: Event) => {
|
const handleClick = async (e: Event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||||
|
|
||||||
if (!copyButton) {
|
if (copyButton) {
|
||||||
return
|
e.preventDefault()
|
||||||
|
const code = copyButton.getAttribute("data-code")
|
||||||
|
if (code) {
|
||||||
|
const decodedCode = decodeURIComponent(code)
|
||||||
|
const success = await copyToClipboard(decodedCode)
|
||||||
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
|
if (copyText) {
|
||||||
|
if (success) {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const code = copyButton.getAttribute("data-code")
|
|
||||||
if (!code) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedCode = decodeURIComponent(code)
|
|
||||||
const success = await copyToClipboard(decodedCode)
|
|
||||||
const copyText = copyButton.querySelector(".copy-text")
|
|
||||||
if (!copyText) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
containerRef?.addEventListener("click", handleClick)
|
containerRef?.addEventListener("click", handleClick)
|
||||||
|
|
||||||
let disposed = false
|
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||||
void loadMarkdownModule()
|
if (props.disableHighlight) {
|
||||||
.then((markdown) => {
|
return
|
||||||
if (disposed) {
|
}
|
||||||
return
|
|
||||||
|
const { part, text, themeKey, version } = resolved()
|
||||||
|
|
||||||
|
setMarkdownTheme(themeKey === "dark")
|
||||||
|
|
||||||
|
if (latestRequestedText !== text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rendered = await renderMarkdown(text)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||||
|
setHtml(rendered)
|
||||||
|
part.renderCache = cacheEntry
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
log.error("Failed to re-render markdown after language load:", error)
|
||||||
const snapshot = resolved()
|
}
|
||||||
if (!snapshot.highlightEnabled) {
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
latestRequestKey = snapshot.requestKey
|
|
||||||
void renderSnapshot(snapshot).catch((error) => {
|
|
||||||
log.error("Failed to re-render markdown after language load:", error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
log.error("Failed to load markdown module:", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
disposed = true
|
|
||||||
containerRef?.removeEventListener("click", handleClick)
|
containerRef?.removeEventListener("click", handleClick)
|
||||||
cleanupLanguageListener?.()
|
cleanupLanguageListener()
|
||||||
cleanupLanguageListener = undefined
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const proseClass = () => "markdown-body"
|
||||||
<div
|
|
||||||
ref={containerRef}
|
return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
|
||||||
class="markdown-body"
|
|
||||||
dir="auto"
|
|
||||||
data-view="markdown"
|
|
||||||
data-part-id={resolved().partId}
|
|
||||||
data-markdown-theme={resolved().themeKey}
|
|
||||||
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
|
|
||||||
innerHTML={html()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export const MESSAGE_ANCHOR_PREFIX = "message-anchor-"
|
|
||||||
|
|
||||||
export function getMessageAnchorId(messageId: string) {
|
|
||||||
return `${MESSAGE_ANCHOR_PREFIX}${messageId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMessageIdFromAnchorId(anchorId: string) {
|
|
||||||
return anchorId.startsWith(MESSAGE_ANCHOR_PREFIX) ? anchorId.slice(MESSAGE_ANCHOR_PREFIX.length) : anchorId
|
|
||||||
}
|
|
||||||
64
packages/ui/src/components/message-block-list.tsx
Normal file
64
packages/ui/src/components/message-block-list.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Index, type Accessor } from "solid-js"
|
||||||
|
import VirtualItem from "./virtual-item"
|
||||||
|
import MessageBlock from "./message-block"
|
||||||
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
|
export function getMessageAnchorId(messageId: string) {
|
||||||
|
return `message-anchor-${messageId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||||
|
|
||||||
|
interface MessageBlockListProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
store: () => InstanceMessageStore
|
||||||
|
messageIds: () => string[]
|
||||||
|
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
|
||||||
|
suspendMeasurements?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Index each={props.messageIds()}>
|
||||||
|
{(messageId, index) => (
|
||||||
|
<VirtualItem
|
||||||
|
id={getMessageAnchorId(messageId())}
|
||||||
|
cacheKey={messageId()}
|
||||||
|
scrollContainer={props.scrollContainer}
|
||||||
|
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||||
|
placeholderClass="message-stream-placeholder"
|
||||||
|
virtualizationEnabled={() => !props.loading}
|
||||||
|
suspendMeasurements={props.suspendMeasurements}
|
||||||
|
>
|
||||||
|
<MessageBlock
|
||||||
|
messageId={messageId()}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={props.store}
|
||||||
|
messageIndex={index}
|
||||||
|
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" }} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,14 @@
|
|||||||
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
import { For, Show, createSignal } from "solid-js"
|
||||||
import { Portal } from "solid-js/web"
|
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
|
||||||
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
import type { MessageInfo, ClientPart } from "../types/message"
|
||||||
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessagePart } from "../stores/session-actions"
|
||||||
import { isTauriHost } from "../lib/runtime-env"
|
import { isTauriHost } from "../lib/runtime-env"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
|
||||||
import SpeechActionButton from "./speech-action-button"
|
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
|
||||||
return (
|
|
||||||
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
|
|
||||||
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
record: MessageRecord
|
record: MessageRecord
|
||||||
@@ -30,112 +18,15 @@ interface MessageItemProps {
|
|||||||
isQueued?: boolean
|
isQueued?: boolean
|
||||||
parts: ClientPart[]
|
parts: ClientPart[]
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
selectedMessageIds?: () => Set<string>
|
|
||||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
|
||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
|
||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
showAgentMeta?: boolean
|
showAgentMeta?: boolean
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
showDeleteMessage?: boolean
|
|
||||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
|
||||||
|
|
||||||
type ImagePreviewState = {
|
|
||||||
url: string
|
|
||||||
name: string
|
|
||||||
anchor: HTMLElement
|
|
||||||
}
|
|
||||||
|
|
||||||
const [imagePreview, setImagePreview] = createSignal<ImagePreviewState | null>(null)
|
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
|
||||||
|
|
||||||
const getImagePreviewPosition = () => {
|
|
||||||
const state = imagePreview()
|
|
||||||
if (!state) return null
|
|
||||||
|
|
||||||
const rect = state.anchor.getBoundingClientRect()
|
|
||||||
|
|
||||||
// Outer box: 320px image + 8px padding on each side.
|
|
||||||
const padding = 8
|
|
||||||
const maxImage = 320
|
|
||||||
const gap = 8
|
|
||||||
const chrome = padding * 2
|
|
||||||
const outerWidth = maxImage + chrome
|
|
||||||
const outerHeight = maxImage + chrome
|
|
||||||
|
|
||||||
const viewportW = window.innerWidth
|
|
||||||
const viewportH = window.innerHeight
|
|
||||||
|
|
||||||
const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8))
|
|
||||||
|
|
||||||
const fitsAbove = rect.top >= outerHeight + gap + 8
|
|
||||||
const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap
|
|
||||||
const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8))
|
|
||||||
|
|
||||||
return { left, top }
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const active = imagePreview()
|
|
||||||
if (!active) return
|
|
||||||
|
|
||||||
// If the user scrolls (message stream scroll container) or resizes, the anchor moves.
|
|
||||||
// Hide the popover to avoid showing it in the wrong place.
|
|
||||||
const hide = () => setImagePreview(null)
|
|
||||||
window.addEventListener("scroll", hide, true)
|
|
||||||
window.addEventListener("resize", hide)
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("scroll", hide, true)
|
|
||||||
window.removeEventListener("resize", hide)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
|
|
||||||
|
|
||||||
let topRowEl: HTMLDivElement | undefined
|
|
||||||
let actionsEl: HTMLDivElement | undefined
|
|
||||||
let speakerPrimaryEl: HTMLDivElement | undefined
|
|
||||||
let metaMeasureEl: HTMLSpanElement | undefined
|
|
||||||
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
|
||||||
|
|
||||||
const metaText = () => agentMeta()
|
|
||||||
|
|
||||||
const updateMetaLayout = () => {
|
|
||||||
const text = metaText()
|
|
||||||
if (!text) return
|
|
||||||
if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return
|
|
||||||
|
|
||||||
const rowWidth = topRowEl.getBoundingClientRect().width
|
|
||||||
const actionsWidth = actionsEl.getBoundingClientRect().width
|
|
||||||
const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width
|
|
||||||
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
|
||||||
|
|
||||||
// Allow for the flex gap between left and actions.
|
|
||||||
const availableLeft = Math.max(0, rowWidth - actionsWidth - 12)
|
|
||||||
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const text = metaText()
|
|
||||||
if (!text || typeof ResizeObserver === "undefined") {
|
|
||||||
setShowMetaInline(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMetaLayout()
|
|
||||||
const observer = new ResizeObserver(() => updateMetaLayout())
|
|
||||||
if (topRowEl) observer.observe(topRowEl)
|
|
||||||
if (actionsEl) observer.observe(actionsEl)
|
|
||||||
if (speakerPrimaryEl) observer.observe(speakerPrimaryEl)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
const isUser = () => props.record.role === "user"
|
const isUser = () => props.record.role === "user"
|
||||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||||
@@ -232,11 +123,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showImagePreview = (anchor: HTMLElement, url: string, name: string) => {
|
|
||||||
if (!url) return
|
|
||||||
setImagePreview({ anchor, url, name })
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = () => {
|
const errorMessage = () => {
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
if (!info || info.role !== "assistant" || !info.error) return null
|
if (!info || info.role !== "assistant" || !info.error) return null
|
||||||
@@ -276,8 +162,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
const timeInfo = info?.time as { created: number; end?: number } | undefined
|
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
|
||||||
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRevert = () => {
|
const handleRevert = () => {
|
||||||
@@ -296,13 +181,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
.join("\n\n")
|
.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
const speech = useSpeech({
|
|
||||||
id: () => `${props.instanceId}:${props.sessionId}:${props.record.id}`,
|
|
||||||
text: getRawContent,
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSpeakMessage = () => getRawContent().trim().length > 0 && speech.canUseSpeech()
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
const content = getRawContent()
|
const content = getRawContent()
|
||||||
if (!content) return
|
if (!content) return
|
||||||
@@ -311,30 +189,47 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteMessage = async () => {
|
const deletableTextPartId = () => {
|
||||||
if (deletingMessage()) return
|
const part = props.parts.find((candidate) => {
|
||||||
setDeletingMessage(true)
|
if (!candidate || candidate.type !== "text") return false
|
||||||
|
const id = (candidate as any).id
|
||||||
|
if (typeof id !== "string" || id.length === 0) return false
|
||||||
|
return !Boolean((candidate as any).synthetic)
|
||||||
|
})
|
||||||
|
return (part as any)?.id as string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDeletingPart = (partId?: string) => {
|
||||||
|
if (!partId) return false
|
||||||
|
return deletingParts().has(partId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPartDeleting = (partId: string, value: boolean) => {
|
||||||
|
setDeletingParts((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (value) {
|
||||||
|
next.add(partId)
|
||||||
|
} else {
|
||||||
|
next.delete(partId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeletePart = async (partId?: string) => {
|
||||||
|
if (!partId) return
|
||||||
|
if (isDeletingPart(partId)) return
|
||||||
|
setPartDeleting(partId, true)
|
||||||
try {
|
try {
|
||||||
await deleteMessage(props.instanceId, props.sessionId, props.record.id)
|
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
title: t("messagePart.actions.deleteFailedTitle"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingMessage(false)
|
setPartDeleting(partId, false)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteUpTo = async () => {
|
|
||||||
if (!props.onDeleteMessagesUpTo) return
|
|
||||||
if (deletingUpTo()) return
|
|
||||||
setDeletingUpTo(true)
|
|
||||||
try {
|
|
||||||
await props.onDeleteMessagesUpTo(props.record.id)
|
|
||||||
} finally {
|
|
||||||
setDeletingUpTo(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,16 +257,8 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
if (!info || info.role !== "assistant") return ""
|
if (!info || info.role !== "assistant") return ""
|
||||||
const modelID = info.modelID || ""
|
const modelID = info.modelID || ""
|
||||||
const providerID = info.providerID || ""
|
const providerID = info.providerID || ""
|
||||||
|
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||||
const base = modelID && providerID ? `${providerID}/${modelID}` : modelID
|
return modelID
|
||||||
if (!base) return ""
|
|
||||||
|
|
||||||
const variant = (info as SDKAssistantMessageV2).variant
|
|
||||||
if (typeof variant === "string" && variant.trim().length > 0) {
|
|
||||||
return `${base} (${variant.trim()})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return base
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentMeta = () => {
|
const agentMeta = () => {
|
||||||
@@ -390,78 +277,28 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class={containerClass()}>
|
||||||
class={containerClass()}
|
|
||||||
data-view="message-item"
|
|
||||||
data-instance-id={props.instanceId}
|
|
||||||
data-session-id={props.sessionId}
|
|
||||||
data-message-id={props.record.id}
|
|
||||||
data-message-role={isUser() ? "user" : "assistant"}
|
|
||||||
data-message-status={props.record.status}
|
|
||||||
>
|
|
||||||
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||||
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
|
<div class="message-item-header-row message-item-header-row--top">
|
||||||
<div class="message-header-left">
|
<div class="message-speaker">
|
||||||
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
|
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||||
<Show when={props.showDeleteMessage}>
|
{speakerLabel()}
|
||||||
<input
|
</span>
|
||||||
class="message-select-checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelectedForDeletion()}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onChange={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
|
||||||
props.onToggleSelectedMessage?.(props.record.id, next)
|
|
||||||
}}
|
|
||||||
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
|
||||||
title={t("messageItem.selection.checkboxAriaLabel")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
|
||||||
{speakerLabel()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={metaText() && showMetaInline()}>
|
|
||||||
<span class="message-agent-meta-inline">{metaText()}</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={metaText()}>
|
|
||||||
<span
|
|
||||||
ref={(el) => (metaMeasureEl = el)}
|
|
||||||
class="message-agent-meta-inline message-agent-meta-inline--measure"
|
|
||||||
>
|
|
||||||
{metaText()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message-item-actions" ref={(el) => (actionsEl = el)}>
|
<div class="message-item-actions">
|
||||||
<Show when={isUser()}>
|
<Show when={isUser()}>
|
||||||
<div class="message-action-group">
|
<div class="message-action-group">
|
||||||
<button
|
<Show when={props.onRevert}>
|
||||||
class="message-action-button"
|
<button
|
||||||
onClick={handleCopy}
|
|
||||||
title={copyLabel()}
|
|
||||||
aria-label={copyLabel()}
|
|
||||||
>
|
|
||||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Show when={canSpeakMessage()}>
|
|
||||||
<SpeechActionButton
|
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={() => void speech.toggle()}
|
onClick={handleRevert}
|
||||||
title={speech.buttonTitle()}
|
title={t("messageItem.actions.revert")}
|
||||||
isLoading={speech.isLoading()}
|
aria-label={t("messageItem.actions.revert")}
|
||||||
isPlaying={speech.isPlaying()}
|
>
|
||||||
/>
|
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.onFork}>
|
<Show when={props.onFork}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -472,43 +309,14 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
<button
|
||||||
<Show when={props.onRevert}>
|
class="message-action-button"
|
||||||
<button
|
onClick={handleCopy}
|
||||||
class="message-action-button"
|
title={copyLabel()}
|
||||||
onClick={handleRevert}
|
aria-label={copyLabel()}
|
||||||
title={t("messageItem.actions.revertTitle")}
|
>
|
||||||
aria-label={t("messageItem.actions.revertTitle")}
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
>
|
</button>
|
||||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.showDeleteMessage}>
|
|
||||||
<button
|
|
||||||
class="message-action-button"
|
|
||||||
onClick={() => void handleDeleteUpTo()}
|
|
||||||
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
|
||||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
|
|
||||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
|
||||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
|
||||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
|
||||||
>
|
|
||||||
<DeleteUpToIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="message-action-button"
|
|
||||||
onClick={handleDeleteMessage}
|
|
||||||
disabled={deletingMessage()}
|
|
||||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
|
|
||||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
|
||||||
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
|
||||||
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
|
||||||
>
|
|
||||||
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isUser()}>
|
<Show when={!isUser()}>
|
||||||
@@ -522,40 +330,18 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={canSpeakMessage()}>
|
<Show when={deletableTextPartId()}>
|
||||||
<SpeechActionButton
|
{(partId) => (
|
||||||
class="message-action-button"
|
<button
|
||||||
onClick={() => void speech.toggle()}
|
class="message-action-button"
|
||||||
title={speech.buttonTitle()}
|
onClick={() => void handleDeletePart(partId())}
|
||||||
isLoading={speech.isLoading()}
|
disabled={isDeletingPart(partId())}
|
||||||
isPlaying={speech.isPlaying()}
|
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||||
/>
|
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||||
</Show>
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
<Show when={props.showDeleteMessage}>
|
</button>
|
||||||
<button
|
)}
|
||||||
class="message-action-button"
|
|
||||||
onClick={() => void handleDeleteUpTo()}
|
|
||||||
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
|
||||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
|
|
||||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
|
||||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
|
||||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
|
||||||
>
|
|
||||||
<DeleteUpToIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="message-action-button"
|
|
||||||
onClick={handleDeleteMessage}
|
|
||||||
disabled={deletingMessage()}
|
|
||||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
|
|
||||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
|
||||||
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
|
||||||
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
|
||||||
>
|
|
||||||
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -563,15 +349,17 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={metaText() && !showMetaInline()}>
|
<Show when={agentMeta()}>
|
||||||
<div class="message-item-header-row message-item-header-row--meta">
|
{(meta) => (
|
||||||
<span class="message-agent-meta-block">{metaText()}</span>
|
<div class="message-item-header-row message-item-header-row--bottom">
|
||||||
</div>
|
<span class="message-agent-meta">{meta()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
|
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||||
|
|
||||||
|
|
||||||
<Show when={props.isQueued && isUser()}>
|
<Show when={props.isQueued && isUser()}>
|
||||||
@@ -579,7 +367,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={errorMessage()}>
|
<Show when={errorMessage()}>
|
||||||
<div class="message-error-block" dir="auto">⚠️ {errorMessage()}</div>
|
<div class="message-error-block">⚠️ {errorMessage()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={isGenerating()}>
|
<Show when={isGenerating()}>
|
||||||
@@ -589,20 +377,16 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={messageParts()}>
|
<For each={messageParts()}>
|
||||||
{(part) => {
|
{(part) => (
|
||||||
return (
|
<MessagePart
|
||||||
<div class="message-part-shell">
|
part={part}
|
||||||
<MessagePart
|
messageType={props.record.role}
|
||||||
part={part}
|
instanceId={props.instanceId}
|
||||||
messageType={props.record.role}
|
sessionId={props.sessionId}
|
||||||
instanceId={props.instanceId}
|
primaryUserTextPartId={primaryUserTextPartId()}
|
||||||
sessionId={props.sessionId}
|
onRendered={props.onContentRendered}
|
||||||
primaryUserTextPartId={primaryUserTextPartId()}
|
/>
|
||||||
onRendered={props.onContentRendered}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={fileAttachments().length > 0}>
|
<Show when={fileAttachments().length > 0}>
|
||||||
@@ -612,16 +396,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
const name = getAttachmentName(attachment)
|
const name = getAttachmentName(attachment)
|
||||||
const isImage = isImageAttachment(attachment)
|
const isImage = isImageAttachment(attachment)
|
||||||
return (
|
return (
|
||||||
<div
|
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||||
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
|
||||||
title={name}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isImage) return
|
|
||||||
const el = e.currentTarget as HTMLElement
|
|
||||||
showImagePreview(el, attachment.url || "", name)
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setImagePreview(null)}
|
|
||||||
>
|
|
||||||
<Show when={isImage} fallback={
|
<Show when={isImage} fallback={
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
@@ -649,6 +424,24 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeletePart(attachment.id)}
|
||||||
|
class="attachment-remove"
|
||||||
|
disabled={isDeletingPart(attachment.id)}
|
||||||
|
aria-label={t("messagePart.actions.deleteTitle")}
|
||||||
|
title={t("messagePart.actions.deleteTitle")}
|
||||||
|
>
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Show when={isImage}>
|
||||||
|
<div class="attachment-chip-preview">
|
||||||
|
<img src={attachment.url} alt={name} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -656,31 +449,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={imagePreview()}>
|
|
||||||
{(stateAccessor) => {
|
|
||||||
const state = stateAccessor()
|
|
||||||
const pos = () => getImagePreviewPosition()
|
|
||||||
return (
|
|
||||||
<Portal>
|
|
||||||
<Show when={pos()}>
|
|
||||||
{(posAccessor) => {
|
|
||||||
const coords = posAccessor()
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="attachment-image-popover"
|
|
||||||
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<img src={state.url} alt={state.name} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
</Portal>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.record.status === "sending"}>
|
<Show when={props.record.status === "sending"}>
|
||||||
<div class="message-sending">
|
<div class="message-sending">
|
||||||
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import ContextMeter from "./context-meter"
|
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
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-muted"
|
||||||
|
|
||||||
interface MessageListHeaderProps {
|
interface MessageListHeaderProps {
|
||||||
usedTokens: number
|
usedTokens: number
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||||
|
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
||||||
@@ -37,13 +40,14 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
|
|
||||||
<div class="connection-status-text connection-status-info">
|
<div class="connection-status-text connection-status-info">
|
||||||
<div class="connection-status-usage">
|
<div class="connection-status-usage">
|
||||||
<ContextMeter
|
<div class={METRIC_CHIP_CLASS}>
|
||||||
usedTokens={props.usedTokens}
|
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
|
||||||
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
|
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||||
formatTokens={props.formatTokens}
|
</div>
|
||||||
usedLabel={t("messageListHeader.metrics.usedLabel")}
|
<div class={METRIC_CHIP_CLASS}>
|
||||||
availableLabel={t("messageListHeader.metrics.availableLabel")}
|
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
|
||||||
/>
|
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,14 +55,14 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
<div class="connection-status-shortcut-action">
|
<div class="connection-status-shortcut-action">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button command-palette-button"
|
class="connection-status-button"
|
||||||
onClick={props.onCommandPalette}
|
onClick={props.onCommandPalette}
|
||||||
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
||||||
>
|
>
|
||||||
{t("messageListHeader.commandPalette.button")}
|
{t("messageListHeader.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Match, Show, Suspense, Switch, lazy } from "solid-js"
|
import { Show, Match, Switch } from "solid-js"
|
||||||
|
import ToolCall from "./tool-call"
|
||||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
|
import { useConfig } from "../stores/preferences"
|
||||||
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
|
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
|
||||||
|
|
||||||
interface MessagePartProps {
|
interface MessagePartProps {
|
||||||
part: ClientPart
|
part: ClientPart
|
||||||
messageType?: "user" | "assistant"
|
messageType?: "user" | "assistant"
|
||||||
@@ -17,18 +17,16 @@ interface MessagePartProps {
|
|||||||
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
|
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
|
||||||
primaryUserTextPartId?: string | null
|
primaryUserTextPartId?: string | null
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
}
|
}
|
||||||
|
export default function MessagePart(props: MessagePartProps) {
|
||||||
export default function MessagePart(props: MessagePartProps) {
|
|
||||||
|
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { preferences } = useConfig()
|
||||||
const partType = () => props.part?.type || ""
|
const partType = () => props.part?.type || ""
|
||||||
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
||||||
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
||||||
const isAssistantMessage = () => props.messageType === "assistant"
|
const isAssistantMessage = () => props.messageType === "assistant"
|
||||||
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
|
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
|
||||||
const markdownContainerClass = () => "message-text message-text-assistant"
|
|
||||||
const textContainerRole = () => props.messageType || "assistant"
|
|
||||||
|
|
||||||
const shouldHideTextPart = () => {
|
const shouldHideTextPart = () => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
@@ -59,11 +57,6 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const canRenderMarkdown = () => {
|
|
||||||
const id = (props.part as unknown as { id?: unknown })?.id
|
|
||||||
return typeof id === "string" && id.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasoningSegmentHasText(segment: unknown): boolean {
|
function reasoningSegmentHasText(segment: unknown): boolean {
|
||||||
if (typeof segment === "string") {
|
if (typeof segment === "string") {
|
||||||
return segment.trim().length > 0
|
return segment.trim().length > 0
|
||||||
@@ -98,28 +91,20 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
|
|
||||||
const createTextPartForMarkdown = (): TextPart => {
|
const createTextPartForMarkdown = (): TextPart => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
if (part.type === "text" && typeof part.text === "string") {
|
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
||||||
// Pass through the original part so `renderCache` updates persist.
|
|
||||||
return part as unknown as TextPart
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.type === "reasoning" && typeof (part as any).text === "string") {
|
|
||||||
// Reasoning parts render as markdown in some views; normalize to TextPart.
|
|
||||||
return {
|
return {
|
||||||
id: part.id,
|
id: part.id,
|
||||||
type: "text",
|
type: "text",
|
||||||
text: (part as any).text,
|
text: part.text,
|
||||||
synthetic: false,
|
synthetic: part.type === "text" ? part.synthetic : false,
|
||||||
version: (part as { version?: number }).version,
|
version: (part as { version?: number }).version
|
||||||
renderCache: (part as any).renderCache,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: part.id,
|
id: part.id,
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "",
|
text: "",
|
||||||
synthetic: false,
|
synthetic: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,36 +117,32 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={partType() === "text"}>
|
<Match when={partType() === "text"}>
|
||||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||||
<div
|
<div class={textContainerClass()}>
|
||||||
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
<Show
|
||||||
dir="auto"
|
when={isAssistantMessage()}
|
||||||
data-role={textContainerRole()}
|
fallback={<span class="text-primary">{plainTextContent()}</span>}
|
||||||
data-part-type="text"
|
>
|
||||||
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
<Markdown
|
||||||
>
|
part={createTextPartForMarkdown()}
|
||||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
|
instanceId={props.instanceId}
|
||||||
<Markdown
|
sessionId={props.sessionId}
|
||||||
part={createTextPartForMarkdown()}
|
isDark={isDark()}
|
||||||
instanceId={props.instanceId}
|
size={isAssistantMessage() ? "tight" : "base"}
|
||||||
sessionId={props.sessionId}
|
onRendered={props.onRendered}
|
||||||
isDark={isDark()}
|
/>
|
||||||
size={isAssistantMessage() ? "tight" : "base"}
|
</Show>
|
||||||
onRendered={props.onRendered}
|
|
||||||
/>
|
</div>
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
<Match when={partType() === "tool"}>
|
<Match when={partType() === "tool"}>
|
||||||
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
<ToolCall
|
||||||
<LazyToolCall
|
toolCall={props.part as ToolCallPart}
|
||||||
toolCall={props.part as ToolCallPart}
|
toolCallId={props.part?.id}
|
||||||
toolCallId={props.part?.id}
|
instanceId={props.instanceId}
|
||||||
instanceId={props.instanceId}
|
sessionId={props.sessionId}
|
||||||
sessionId={props.sessionId}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import type { Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
|
||||||
|
|
||||||
interface MessagePreviewProps {
|
interface MessagePreviewProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
messageId: string
|
messageId: string
|
||||||
store: () => InstanceMessageStore
|
store: () => InstanceMessageStore
|
||||||
deleteHover?: () => DeleteHoverState
|
|
||||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
|
||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
|
||||||
selectedMessageIds?: () => Set<string>
|
|
||||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||||
@@ -30,11 +24,6 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
|||||||
showThinking={() => false}
|
showThinking={() => false}
|
||||||
thinkingDefaultExpanded={() => false}
|
thinkingDefaultExpanded={() => false}
|
||||||
showUsageMetrics={() => false}
|
showUsageMetrics={() => false}
|
||||||
deleteHover={props.deleteHover}
|
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user