mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
367 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b696a681cb | |||
| 1b096b4148 | |||
| be227503af | |||
| 4ddfa7c6fa | |||
| 2415434ad9 | |||
| bf4c647653 | |||
| f35bb5184c | |||
| 2c5335d84c | |||
| 7793db3ac8 | |||
| 5f77482a29 | |||
| 30aec297d8 | |||
| a9c810cbbc | |||
| fe2e4e21d0 | |||
| 03a2504d3d | |||
| bace18ccd3 | |||
| cd2e6f06ee | |||
| 7e7ad37736 | |||
| ca8db315a9 | |||
| 9eb07ab55a | |||
| e07e420637 | |||
| 84f94e6143 | |||
| a150424c16 | |||
| 2e71292f2f | |||
| e2527db3c7 | |||
| a1dee8b273 | |||
| 04e5ca9d1b | |||
| 6dd4d1473c | |||
| 31c5454ee6 | |||
| 31b233e9db | |||
| 567d6ed1a3 | |||
| 2bde20c2ba | |||
| 92c70c9c3d | |||
| 318dbe93ba | |||
| 8bd249d8cf | |||
| 710e408709 | |||
| dac0dd5309 | |||
| bf05e8a122 | |||
| 51b10b128e | |||
| 47a45601fd | |||
| 69623c2b79 | |||
| 38536cf733 | |||
| 8bf0675997 | |||
| ff967e582c | |||
| 20bf18ffb4 | |||
| 51891d56e7 | |||
| cc22646467 | |||
| eb70b6137b | |||
| 6e2bcafd34 | |||
| 07b983e82f | |||
| c51a1588c7 | |||
| f43b0d3afd | |||
| 722f4dd416 | |||
| bb82aab5c8 | |||
| 936363e7a8 | |||
| 8688ed7e3e | |||
| 5b54203168 | |||
| bed780fac9 | |||
| 14ec7ed5b3 | |||
| 2c65273204 | |||
| 2e0f88d0e3 | |||
| e1073e5d18 | |||
| 649618c50a | |||
| 02608a4e97 | |||
| 3ad6923c61 | |||
| e4300e9b74 | |||
| 538cfaff0d | |||
| 1dad38d1b5 | |||
| c9e2a38bf4 | |||
| 5863e1254c | |||
| 4c9abff445 | |||
| 2cf68f32b5 | |||
| 90fb32be30 | |||
| 97e713e8aa | |||
| 826419127a | |||
| e0449c0b96 | |||
| 5e342f7114 | |||
| 174ab58343 | |||
| 954d618790 | |||
| 91073360c6 | |||
| 8a2cfc00c9 | |||
| 0fc33e2a06 | |||
| 6c6ed68b5a | |||
| e9106efbdc | |||
| e9e2612706 | |||
| 4ae468ff06 | |||
| 785918262a | |||
| be8fee5c2b | |||
| 4597c687fa | |||
| c4aa049042 | |||
| 1d46b5c33c | |||
| 6ae6f0fe8f | |||
| a0409e64d8 | |||
| ce4e658e3f | |||
| 215fb39fc5 | |||
| 1cc94bb23e | |||
| bc42187c58 | |||
| 80c0b06980 | |||
| 381eabb970 | |||
| c939aa04fe | |||
| 9b8e356fca | |||
| 07e5ea9367 | |||
| 7a54a2c49c | |||
| 600e405ba0 | |||
| 1daf535e6b | |||
| cc52dc396c | |||
| 132ef57272 | |||
| 0777cf1ccf | |||
| c4bc902958 | |||
| d68397b42f | |||
| 531eebb88e | |||
| db9391e8a6 | |||
| f1c31f4eba | |||
| ada5b31bb0 | |||
| ffcb45d7c9 | |||
| 6566ede935 | |||
| 0bb677cef9 | |||
| 8415ff50cb | |||
| 37522185d3 | |||
| 960eacebcf | |||
| b4a6419215 | |||
| 7c1e61af1e | |||
| 671d193786 | |||
| 2ababc87da | |||
| 5372c63c7c | |||
| ab701d20eb | |||
| 384a8f58c6 | |||
| 50c9d5221f | |||
| 1a08ee77c7 | |||
| 79cff288a6 | |||
| 0efc334ff9 | |||
| 277ecc505c | |||
| f565ff3c09 | |||
| 161247c70d | |||
| a86ecf3bba | |||
| 6e84142b59 | |||
| fc52c5ac87 | |||
| 537666149b | |||
| c06ad7c881 | |||
| 0a7cb20e66 | |||
| f43209bb8c | |||
| b34d9242d1 | |||
| 9654412021 | |||
| 31d2fecf63 | |||
| f3f59db3ae | |||
| 89e2a23b5b | |||
| ea5f97609a | |||
| 4119051077 | |||
| f116a55e4a | |||
| 89e371c94b | |||
| b1a6c40ad0 | |||
| 1772e8ee6e | |||
| 0050134d9e | |||
| d46af9cf1e | |||
| bea56feb63 | |||
| 685a894a6f | |||
| 79ea379df2 | |||
| 4863aedf3d | |||
| b5cb9aae7f | |||
| 4d09a71ef4 | |||
| 65a3f7f749 | |||
| b1d14acc35 | |||
| 3867fa2bad | |||
| 07808bea12 | |||
| 914a643ab2 | |||
| c495635f05 | |||
| 8ff40133b4 | |||
| aacdb34e3f | |||
| 233427f08e | |||
| e82542b802 | |||
| 2006259a02 | |||
| 472b33113e | |||
| 218147271c | |||
| a29deb1ee9 | |||
| f24049736e | |||
| 21a644fef5 | |||
| 106f8e94d6 | |||
| 7a2c49e762 | |||
| 9b4d5b0395 | |||
| f20655bef4 | |||
| 31c099be43 | |||
| 155e1f20d6 | |||
| fe0c4f8c74 | |||
| 53ff1b57c9 | |||
| 1025540fcc | |||
| eb9a683b40 | |||
| 48c26fa039 | |||
| 10d1e04e9b | |||
| 12acb9a59a | |||
| 807c804c24 | |||
| 660a00d317 | |||
| effd27b239 | |||
| 06d7840d1d | |||
| 0875203a6c | |||
| b9131aa69c | |||
| 4519a1da32 | |||
| 77963d884a | |||
| 7d3d80f840 | |||
| 4814ab3a3d | |||
| 747b8daafc | |||
| 1399323b78 | |||
| bd7eb0603f | |||
| 09d9cf01f9 | |||
| 4ac4df448a | |||
| 147169e9b7 | |||
| ceccde7e84 | |||
| 54f4974546 | |||
| ba57718b05 | |||
| 3f0ef9b71c | |||
| fa2b63f850 | |||
| 83dca45dd5 | |||
| f750deaa3e | |||
| b36b85936d | |||
| 7c6adcf60f | |||
| 3e704d096f | |||
| 1fd9c77744 | |||
| 9ed17da55a | |||
| 24347f336c | |||
| 93a58f55ca | |||
| e3a55db5b5 | |||
| 015e79fa59 | |||
| d5b205657f | |||
| a645615a49 | |||
| 499a8a4b0c | |||
| 969bb90f69 | |||
| f591bf5f93 | |||
| 025e1ac69f | |||
| 820c984d47 | |||
| c814f84c87 | |||
| a57fb32d95 | |||
| 05d1104ecd | |||
| 7ebc7ffb87 | |||
| a9094fd059 | |||
| 760d523847 | |||
| 0bdd9aa494 | |||
| ca9bf7abf9 | |||
| 3bbf8c8989 | |||
| beae7290f3 | |||
| a7bd1cd0d0 | |||
| ecdfcd91ca | |||
| 3151e2246a | |||
| d2204e0ff5 | |||
| f26a9e8856 | |||
| 12e38866ed | |||
| 41bd9124f4 | |||
| 8ad44cdd22 | |||
| 9b09075bb7 | |||
| 76a81ac7c4 | |||
| 3f64b5e621 | |||
| cc487dd032 | |||
| f8cf8fa983 | |||
| 5d7157fe51 | |||
| e9aa33d4bd | |||
| 02a5ae6585 | |||
| fff36b70bc | |||
| 236cfcbbc3 | |||
| a8adfb6305 | |||
| 5c8eb0ab2e | |||
| 1e216e12dc | |||
| 48106b7e78 | |||
| 0ee7cfa1fe | |||
| a261b55e43 | |||
| a136caa1b3 | |||
| 7a4d18390a | |||
| edbe2280a5 | |||
| d721fc0a49 | |||
| dc985ff881 | |||
| a468680965 | |||
| 0c0d193474 | |||
| b278e49e96 | |||
| c613c33787 | |||
| 9431356715 | |||
| f6197cefe1 | |||
| 7f33576f46 | |||
| 7ae856a9e9 | |||
| b01eb22b2c | |||
| 3cf1cef7fe | |||
| b375890745 | |||
| 0a36433062 | |||
| 5426478e46 | |||
| 3003867c25 | |||
| 8c0edca175 | |||
| 1af8dafd3e | |||
| c47cb28781 | |||
| 2859ce6e73 | |||
| 39babcbb53 | |||
| 64dc6d39ab | |||
| 17ba5539e7 | |||
| 9211ef7e95 | |||
| 605ae48c6f | |||
| 75557000de | |||
| 773d33e282 | |||
| 46e9863589 | |||
| 47f3d8b709 | |||
| 134f4136da | |||
| f011d77128 | |||
| ab5a12d916 | |||
| 76ecf2e58c | |||
| 057958c933 | |||
| b1a7ee5695 | |||
| 107180701f | |||
| e464999eb9 | |||
| cc9b73b0bd | |||
| fe5ac304b5 | |||
| ba1b660d57 | |||
| e45e0e1125 | |||
| 5b14fb4a1b | |||
| 730ea6d2e3 | |||
| 21a0fdd4c0 | |||
| cb65926c82 | |||
| 94c49b20ba | |||
| 9f3a0fe1d0 | |||
| 7f54b1bfb8 | |||
| 789e4d57b9 | |||
| f5d1ae6a9e | |||
| 7e09660c3b | |||
| 6d4f3b4ab2 | |||
| caea930074 | |||
| 69cfc44dba | |||
| 30ec231aaf | |||
| 1ff19103a2 | |||
| 70bb710715 | |||
| 51fd7c0a3a | |||
| 74a27dbf98 | |||
| 9f42bd4a85 | |||
| 2a33addd29 | |||
| 9251e5d8c4 | |||
| b6305cb4cb | |||
| b0a929440b | |||
| 76ee87ead8 | |||
| c35267776a | |||
| 55bafa29d4 | |||
| f62ba5eb86 | |||
| 04b38ce830 | |||
| 0b796c5f3d | |||
| fa6ea8bd25 | |||
| ee74dd83f5 | |||
| d3d4335509 | |||
| 2c32f7e520 | |||
| 36234c462c | |||
| a41f774cad | |||
| 889e0f9545 | |||
| 9991a33e3f | |||
| 11dbd15812 | |||
| af6383485b | |||
| 5900345f8c | |||
| a0e4db3714 | |||
| 1520b0de20 | |||
| 7f8412ec3e | |||
| 400f0fb100 | |||
| d08fedaccc | |||
| f4851e3bd9 | |||
| 8851e4de2b | |||
| e707e416ed | |||
| fc62b3dc48 | |||
| 89b26e8658 | |||
| 5970f7820e | |||
| 0c31beb637 | |||
| 7af6eafb41 | |||
| 56ec4b6912 | |||
| 06eecc5a6d | |||
| b6f76e29c5 | |||
| 932fb6c9ec | |||
| c6f684366a | |||
| c36a433a1f | |||
| 707166ae4a | |||
| 01cc475923 | |||
| a444410cd4 |
+2
-4
@@ -1,5 +1,3 @@
|
|||||||
# web + desktop packages
|
# web + desktop packages
|
||||||
packages/app/ @adamdotdevin
|
packages/app/ @Hona @Brendonovich
|
||||||
packages/tauri/ @adamdotdevin
|
packages/desktop/ @Hona @Brendonovich
|
||||||
packages/desktop/src-tauri/ @brendonovich
|
|
||||||
packages/desktop/ @adamdotdevin
|
|
||||||
|
|||||||
@@ -56,14 +56,24 @@ jobs:
|
|||||||
BUILD_LOG=$(mktemp)
|
BUILD_LOG=$(mktemp)
|
||||||
trap 'rm -f "$BUILD_LOG"' EXIT
|
trap 'rm -f "$BUILD_LOG"' EXIT
|
||||||
|
|
||||||
|
HASH=""
|
||||||
|
MAX_ATTEMPTS=3
|
||||||
|
for ((ATTEMPT = 1; ATTEMPT <= MAX_ATTEMPTS; ATTEMPT++)); do
|
||||||
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
# Build with fakeHash to trigger hash mismatch and reveal correct hash
|
||||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||||
|
|
||||||
# Extract hash from build log with portability
|
|
||||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||||
|
|
||||||
|
[ -n "$HASH" ] && break
|
||||||
|
|
||||||
|
if [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; then
|
||||||
|
echo "::warning::Attempt ${ATTEMPT}/${MAX_ATTEMPTS} produced no hash for ${SYSTEM}; retrying in $((ATTEMPT * 10))s"
|
||||||
|
sleep $((ATTEMPT * 10))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if [ -z "$HASH" ]; then
|
if [ -z "$HASH" ]; then
|
||||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
echo "::error::Failed to compute hash for ${SYSTEM} after ${MAX_ATTEMPTS} attempts"
|
||||||
cat "$BUILD_LOG"
|
cat "$BUILD_LOG"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ jobs:
|
|||||||
id: build
|
id: build
|
||||||
run: |
|
run: |
|
||||||
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||||
|
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
@@ -107,6 +108,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: opencode-cli-windows
|
name: opencode-cli-windows
|
||||||
path: packages/opencode/dist/opencode-windows*
|
path: packages/opencode/dist/opencode-windows*
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: opencode-preview-cli
|
||||||
|
path: packages/cli/dist/cli-*
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ needs.version.outputs.version }}
|
version: ${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
@@ -327,9 +334,9 @@ jobs:
|
|||||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
- name: Package and publish
|
- name: Package
|
||||||
if: needs.version.outputs.release
|
if: needs.version.outputs.release
|
||||||
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
|
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
|
||||||
working-directory: packages/desktop
|
working-directory: packages/desktop
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -349,11 +356,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
|
||||||
- name: Create and upload macOS .app.tar.gz
|
- name: Create macOS .app.tar.gz
|
||||||
if: runner.os == 'macOS' && needs.version.outputs.release
|
if: runner.os == 'macOS' && needs.version.outputs.release
|
||||||
working-directory: packages/desktop/dist
|
working-directory: packages/desktop/dist
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
|
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
|
||||||
APP_DIR="mac"
|
APP_DIR="mac"
|
||||||
@@ -371,7 +376,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
|
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
|
||||||
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
|
|
||||||
|
|
||||||
- name: Verify signed Windows Electron artifacts
|
- name: Verify signed Windows Electron artifacts
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
@@ -446,12 +450,24 @@ jobs:
|
|||||||
name: opencode-cli-signed-windows
|
name: opencode-cli-signed-windows
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
with:
|
||||||
|
name: opencode-preview-cli
|
||||||
|
path: packages/cli/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
if: needs.version.outputs.release
|
if: needs.version.outputs.release
|
||||||
with:
|
with:
|
||||||
pattern: latest-yml-*
|
pattern: latest-yml-*
|
||||||
path: /tmp/latest-yml
|
path: /tmp/latest-yml
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
if: needs.version.outputs.release
|
||||||
|
with:
|
||||||
|
pattern: opencode-desktop-*
|
||||||
|
path: /tmp/desktop
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Setup git committer
|
- name: Setup git committer
|
||||||
id: committer
|
id: committer
|
||||||
uses: ./.github/actions/setup-git-committer
|
uses: ./.github/actions/setup-git-committer
|
||||||
@@ -478,6 +494,19 @@ jobs:
|
|||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
||||||
|
|
||||||
|
- name: Upload desktop release assets
|
||||||
|
if: needs.version.outputs.release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(/tmp/desktop/*.{exe,blockmap,dmg,zip,AppImage,deb,rpm} /tmp/desktop/*.app.tar.gz)
|
||||||
|
if (( ${#files[@]} == 0 )); then
|
||||||
|
echo "No desktop release assets found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "v${{ needs.version.outputs.version }}" "${files[@]}" --clobber --repo "${{ needs.version.outputs.repo }}"
|
||||||
|
|
||||||
- run: ./script/publish.ts
|
- run: ./script/publish.ts
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
name: "sync-zed-extension"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
zed:
|
|
||||||
name: Release Zed Extension
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
|
||||||
|
|
||||||
- name: Get version tag
|
|
||||||
id: get_tag
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" = "release" ]; then
|
|
||||||
TAG="${{ github.event.release.tag_name }}"
|
|
||||||
else
|
|
||||||
TAG=$(git tag --list 'v[0-9]*.*' --sort=-version:refname | head -n 1)
|
|
||||||
fi
|
|
||||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Using tag: ${TAG}"
|
|
||||||
|
|
||||||
- name: Sync Zed extension
|
|
||||||
run: |
|
|
||||||
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
|
|
||||||
env:
|
|
||||||
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
|
|
||||||
ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }}
|
|
||||||
@@ -64,7 +64,8 @@ jobs:
|
|||||||
turbo-${{ runner.os }}-
|
turbo-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun turbo test:ci
|
timeout-minutes: 20
|
||||||
|
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
|
||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
@@ -73,26 +74,6 @@ jobs:
|
|||||||
working-directory: packages/opencode
|
working-directory: packages/opencode
|
||||||
run: bun run test:httpapi
|
run: bun run test:httpapi
|
||||||
|
|
||||||
- name: Publish unit reports
|
|
||||||
if: always()
|
|
||||||
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
|
|
||||||
with:
|
|
||||||
report_paths: packages/*/.artifacts/unit/junit.xml
|
|
||||||
check_name: "unit results (${{ matrix.settings.name }})"
|
|
||||||
detailed_summary: true
|
|
||||||
include_time_in_summary: true
|
|
||||||
fail_on_failure: false
|
|
||||||
|
|
||||||
- name: Upload unit artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
|
||||||
include-hidden-files: true
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
path: packages/*/.artifacts/unit/junit.xml
|
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
strategy:
|
strategy:
|
||||||
@@ -150,7 +131,6 @@ jobs:
|
|||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
@@ -161,6 +141,5 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
path: |
|
path: |
|
||||||
packages/app/e2e/junit-*.xml
|
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ ts-dist
|
|||||||
.turbo
|
.turbo
|
||||||
**/.serena
|
**/.serena
|
||||||
.serena/
|
.serena/
|
||||||
|
**/.omo
|
||||||
|
.omo/
|
||||||
/result
|
/result
|
||||||
refs
|
refs
|
||||||
Session.vim
|
Session.vim
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
mode: primary
|
mode: primary
|
||||||
hidden: true
|
hidden: true
|
||||||
model: opencode/gpt-5.4-nano
|
model: opencode/gpt-5.4-mini
|
||||||
color: "#44BA81"
|
color: "#44BA81"
|
||||||
tools:
|
tools:
|
||||||
"*": false
|
"*": false
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: translate English to other languages
|
description: translate English to other languages
|
||||||
model: opencode/claude-opus-4-7
|
model: opencode/claude-opus-4-8
|
||||||
---
|
---
|
||||||
|
|
||||||
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
||||||
|
|||||||
@@ -2,8 +2,15 @@
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"provider": {},
|
"provider": {},
|
||||||
"permission": {},
|
"permission": {},
|
||||||
"reference": {
|
"references": {
|
||||||
"effect": "github.com/Effect-TS/effect-smol",
|
"effect": {
|
||||||
|
"repository": "github.com/Effect-TS/effect-smol",
|
||||||
|
"description": "Use for Effect v4 and effect-smol implementation details",
|
||||||
|
},
|
||||||
|
"opencode-local": {
|
||||||
|
"path": "~/.local/share/opencode",
|
||||||
|
"description": "Contains opencode logs and data",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
- The default branch in this repo is `dev`.
|
- The default branch in this repo is `dev`.
|
||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||||
|
|
||||||
|
## Branch Names
|
||||||
|
|
||||||
|
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||||
|
|
||||||
|
Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`.
|
||||||
|
|
||||||
## Commits and PR Titles
|
## Commits and PR Titles
|
||||||
|
|
||||||
Use conventional commit-style messages and PR titles: `type(scope): summary`.
|
Use conventional commit-style messages and PR titles: `type(scope): summary`.
|
||||||
@@ -138,3 +144,15 @@ const table = sqliteTable("session", {
|
|||||||
## Type Checking
|
## Type Checking
|
||||||
|
|
||||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||||
|
|
||||||
|
## V2 Session Core
|
||||||
|
|
||||||
|
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
|
||||||
|
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
|
||||||
|
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
|
||||||
|
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||||
|
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||||
|
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
|
||||||
|
- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles.
|
||||||
|
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||||
|
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# OpenCode Session Runtime
|
||||||
|
|
||||||
|
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**System Context**:
|
||||||
|
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
||||||
|
_Avoid_: System prompt
|
||||||
|
|
||||||
|
**Session History**:
|
||||||
|
The projected chronological conversation selected for a provider turn after applying the active compaction and **Context Epoch** cutoffs.
|
||||||
|
_Avoid_: Session Context
|
||||||
|
|
||||||
|
**Context Source**:
|
||||||
|
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
|
||||||
|
_Avoid_: Prompt fragment
|
||||||
|
|
||||||
|
**System Context Registry**:
|
||||||
|
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
|
||||||
|
|
||||||
|
**Mid-Conversation System Message**:
|
||||||
|
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
|
||||||
|
_Avoid_: System update, system notification, raw text diff
|
||||||
|
|
||||||
|
**Context Epoch**:
|
||||||
|
The span during which one effective agent's initially rendered **System Context** remains immutable, ending at compaction or another baseline-replacing transition.
|
||||||
|
|
||||||
|
**Baseline System Context**:
|
||||||
|
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||||
|
_Avoid_: Live system prompt
|
||||||
|
|
||||||
|
**Context Snapshot**:
|
||||||
|
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
|
||||||
|
|
||||||
|
**Unavailable Context**:
|
||||||
|
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
|
||||||
|
|
||||||
|
**Safe Provider-Turn Boundary**:
|
||||||
|
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||||
|
|
||||||
|
**Model Tool Output**:
|
||||||
|
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||||
|
|
||||||
|
**Managed Tool Output File**:
|
||||||
|
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||||
|
|
||||||
|
**Model Request Options**:
|
||||||
|
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||||
|
_Avoid_: Request body, wire options
|
||||||
|
|
||||||
|
**Generation Controls**:
|
||||||
|
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||||
|
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
|
||||||
|
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Provider-Turn Boundary**.
|
||||||
|
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
||||||
|
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
|
||||||
|
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
||||||
|
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
|
||||||
|
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
||||||
|
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||||
|
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||||
|
- The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline.
|
||||||
|
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||||
|
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||||
|
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
|
||||||
|
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
|
||||||
|
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
|
||||||
|
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
|
||||||
|
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
|
||||||
|
- `SystemContext.replace(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
|
||||||
|
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
|
||||||
|
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||||
|
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||||
|
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||||
|
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
||||||
|
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
|
||||||
|
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
|
||||||
|
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
|
||||||
|
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**.
|
||||||
|
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
|
||||||
|
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||||
|
- Switching the selected agent requests **Context Epoch** replacement. A switch admitted after the current **Safe Provider-Turn Boundary** applies to the next provider turn while leaving the already-prepared baseline durable. Epoch creation is fenced against the authoritative effective agent, and retries re-observe the current agent.
|
||||||
|
- A cross-agent replacement must complete before another provider turn; unavailable admitted context blocks that replacement instead of exposing the previous agent's privileged baseline.
|
||||||
|
- Local tool authorization and pending permission requests retain the effective agent of the provider turn that issued the call; a later agent switch cannot change that call's policy.
|
||||||
|
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||||
|
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||||
|
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||||
|
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||||
|
- A **Context Epoch** begins with one immutable **Baseline System Context**.
|
||||||
|
- A **Context Epoch** durably records the effective agent that owns its **Baseline System Context**.
|
||||||
|
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||||
|
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||||
|
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
||||||
|
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||||
|
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||||
|
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||||
|
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||||
|
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||||
|
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||||
|
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||||
|
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||||
|
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||||
|
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
|
||||||
|
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||||
|
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
|
||||||
|
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||||
|
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||||
|
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||||
|
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
|
||||||
|
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||||
|
|
||||||
|
## Example dialogue
|
||||||
|
|
||||||
|
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
|
||||||
|
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
|
||||||
|
|
||||||
|
## Flagged ambiguities
|
||||||
|
|
||||||
|
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
exact = true
|
exact = true
|
||||||
# Only install newly resolved package versions published at least 3 days ago.
|
# Only install newly resolved package versions published at least 3 days ago.
|
||||||
minimumReleaseAge = 259200
|
minimumReleaseAge = 259200
|
||||||
minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"]
|
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
root = "./do-not-run-tests-from-root"
|
root = "./do-not-run-tests-from-root"
|
||||||
|
|||||||
+1
-1
@@ -165,7 +165,7 @@ export const app = new sst.cloudflare.x.SolidStart("Stats", {
|
|||||||
domain: `stats.${domain}`,
|
domain: `stats.${domain}`,
|
||||||
link: [database, EMAILOCTOPUS_API_KEY],
|
link: [database, EMAILOCTOPUS_API_KEY],
|
||||||
environment: {
|
environment: {
|
||||||
PUBLIC_URL: `https://stats.${domain}/stats`,
|
PUBLIC_URL: `https://${domain}/data`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-fzplLKjzYefVCRhAgfCWWIa3me2JiI2d9kDBACnz4fA=",
|
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
|
||||||
"aarch64-linux": "sha256-gccipj2CAau/lZ7KTQhdeU8n6fcXDuUt4X4XbNufCko=",
|
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
|
||||||
"aarch64-darwin": "sha256-r0QEetxKUf6HrtMOozyvb4jjEJjVtp+DE8aiKKhRrVI=",
|
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
|
||||||
"x86_64-darwin": "sha256-bU+5WudkiIflFgoqgLSf30iqeNNLuEbScI5oMFPu9eM="
|
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-6
@@ -15,7 +15,7 @@
|
|||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||||
@@ -37,10 +37,11 @@
|
|||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.3.1",
|
"@opentui/core": "0.3.4",
|
||||||
"@opentui/keymap": "0.3.1",
|
"@opentui/keymap": "0.3.4",
|
||||||
"@opentui/solid": "0.3.1",
|
"@opentui/solid": "0.3.4",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"opentui-spinner": "0.0.6",
|
"opentui-spinner": "0.0.7",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
@@ -139,7 +140,8 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
|
||||||
|
const projectID = "proj_prompt_thinking_level_regression"
|
||||||
|
const sessionID = "ses_prompt_thinking_level_regression"
|
||||||
|
|
||||||
|
test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "prompt-thinking-level-regression",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"thinking-model": {
|
||||||
|
id: "thinking-model",
|
||||||
|
name: "Thinking Model",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
variants: { high: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "thinking-model" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: "prompt-thinking-level-regression",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Prompt thinking level regression",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
const composer = page.locator('[data-component="session-composer"]')
|
||||||
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
|
const control = composer.locator('[data-component="prompt-variant-control"]')
|
||||||
|
await expectAppVisible(composer)
|
||||||
|
|
||||||
|
await idleComposer(page)
|
||||||
|
await expect(control).toBeHidden()
|
||||||
|
|
||||||
|
await composer.hover()
|
||||||
|
await expect(control).toBeVisible()
|
||||||
|
|
||||||
|
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||||
|
const high = page.getByRole("option", { name: "high" })
|
||||||
|
await expect(high).toBeVisible()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
await expect(control).toBeVisible()
|
||||||
|
await expect(high).toBeVisible()
|
||||||
|
await high.click()
|
||||||
|
|
||||||
|
await idleComposer(page)
|
||||||
|
await input.focus()
|
||||||
|
await expect(control).toBeVisible()
|
||||||
|
|
||||||
|
await idleComposer(page)
|
||||||
|
await expect(control).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function idleComposer(page: Page) {
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { test } from "@playwright/test"
|
||||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
|
test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
@@ -33,7 +34,7 @@ test("shows loaded sessions before the directory path request resolves", async (
|
|||||||
|
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
try {
|
try {
|
||||||
await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 })
|
await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first())
|
||||||
} finally {
|
} finally {
|
||||||
releasePath()
|
releasePath()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/TimelineStateRegression"
|
const directory = "C:/OpenCode/TimelineStateRegression"
|
||||||
const projectID = "proj_timeline_state_regression"
|
const projectID = "proj_timeline_state_regression"
|
||||||
@@ -106,10 +107,10 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
await expect(wrapper).toBeVisible()
|
await expectAppVisible(wrapper)
|
||||||
await expectExpanded(wrapper, true)
|
await expectExpanded(wrapper, true)
|
||||||
|
|
||||||
await wrapper.evaluate((element) => {
|
await wrapper.evaluate((element) => {
|
||||||
@@ -142,11 +143,11 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
await expect(wrapper).toBeVisible()
|
await expectAppVisible(wrapper)
|
||||||
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
|
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
|
||||||
await markDiffProbe(page)
|
await markDiffProbe(page)
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/ContextResizeRegression"
|
const directory = "C:/OpenCode/ContextResizeRegression"
|
||||||
const projectID = "proj_context_resize_regression"
|
const projectID = "proj_context_resize_regression"
|
||||||
@@ -23,9 +24,9 @@ test.describe("regression: session timeline context group resize", () => {
|
|||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expect(page.getByRole("heading", { name: title })).toBeVisible()
|
await expectSessionTitle(page, title)
|
||||||
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
|
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
|
||||||
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
|
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
|
||||||
await settle(page)
|
await settle(page)
|
||||||
|
|
||||||
const samples = await sampleExpansion(page)
|
const samples = await sampleExpansion(page)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||||||
import { fixture, pageMessages } from "./session-timeline.fixture"
|
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||||
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const forbiddenText = ["Load details", "Show earlier steps"]
|
const forbiddenText = ["Load details", "Show earlier steps"]
|
||||||
|
|
||||||
@@ -411,18 +412,21 @@ function expectCompleteScroll(
|
|||||||
|
|
||||||
async function selectHomeProject(page: Page, projectName: string) {
|
async function selectHomeProject(page: Page, projectName: string) {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
await page
|
const row = page
|
||||||
.locator('[data-component="home-project-row"]')
|
.locator('[data-component="home-project-row"]')
|
||||||
.filter({ hasText: new RegExp(projectName, "i") })
|
.filter({ hasText: new RegExp(projectName, "i") })
|
||||||
.click()
|
.first()
|
||||||
|
await expectAppVisible(row)
|
||||||
|
await row.click()
|
||||||
|
await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
|
||||||
await expect(page).toHaveURL(/\/$/)
|
await expect(page).toHaveURL(/\/$/)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
||||||
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
|
await expectSessionTitle(page, expectedTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSessionReady(page: Page) {
|
async function expectSessionReady(page: Page) {
|
||||||
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
|
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
|
|
||||||
|
export const APP_READY_TIMEOUT = 30_000
|
||||||
|
|
||||||
|
export async function expectAppVisible(locator: Locator) {
|
||||||
|
await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSessionTitle(page: Page, title: string) {
|
||||||
|
await expectAppVisible(page.getByRole("heading", { name: title }))
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.15.13",
|
"version": "1.17.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./desktop-menu": "./src/desktop-menu.ts",
|
"./desktop-menu": "./src/desktop-menu.ts",
|
||||||
|
"./updater": "./src/updater.ts",
|
||||||
|
"./wsl/types": "./src/wsl/types.ts",
|
||||||
"./vite": "./vite.js",
|
"./vite": "./vite.js",
|
||||||
"./index.css": "./src/index.css"
|
"./index.css": "./src/index.css"
|
||||||
},
|
},
|
||||||
@@ -16,8 +18,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit",
|
"test": "bun run test:unit",
|
||||||
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "playwright test",
|
"test:e2e:local": "playwright test",
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
|||||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||||
const reuse = !process.env.CI
|
const reuse = !process.env.CI
|
||||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||||
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
|
|
||||||
|
|
||||||
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
|
|
||||||
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
outputDir: "./e2e/test-results",
|
outputDir: "./e2e/test-results",
|
||||||
@@ -24,7 +18,7 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers,
|
workers,
|
||||||
reporter,
|
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||||
webServer: {
|
webServer: {
|
||||||
command,
|
command,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
|
|||||||
+111
-31
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
|
|||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
onCleanup,
|
onCleanup,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
Show,
|
Show,
|
||||||
Suspense,
|
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
@@ -33,6 +32,7 @@ import { CommentsProvider } from "@/context/comments"
|
|||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
|
import { GlobalProvider } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
@@ -43,24 +43,88 @@ import { PromptProvider } from "@/context/prompt"
|
|||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
|
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { ServersProvider } from "./context/servers"
|
|
||||||
|
|
||||||
const HomeRoute = lazy(() => import("@/pages/home"))
|
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const SessionRoute = Object.assign(
|
const SessionRoute = Object.assign(
|
||||||
() => (
|
() => {
|
||||||
|
const settings = useSettings()
|
||||||
|
const params = useParams()
|
||||||
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
|
||||||
|
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
||||||
|
// is replaced by a draft at /new-session?draftId=…
|
||||||
|
createEffect(() => {
|
||||||
|
if (!settings.general.newLayoutDesigns()) return
|
||||||
|
if (params.id || search.draftId) return
|
||||||
|
if (!tabs.ready() || !sdk.directory) return
|
||||||
|
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Session />
|
<Session />
|
||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
),
|
)
|
||||||
|
},
|
||||||
{ preload: Session.preload },
|
{ preload: Session.preload },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function DraftRoute() {
|
||||||
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const tabs = useTabs()
|
||||||
|
return (
|
||||||
|
<Show when={tabs.ready()}>
|
||||||
|
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||||
|
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResolvedDraftRoute(props: { draftID: string }) {
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const draft = createMemo(() =>
|
||||||
|
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const current = draft()
|
||||||
|
if (current && current.server !== server.key) server.setActive(current.server)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Key on the directory so retargeting the draft's project re-instantiates the
|
||||||
|
// SDK/data providers for the new directory while keeping the same draft id.
|
||||||
|
const directory = () => draft()?.directory
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={directory()} keyed>
|
||||||
|
{(dir) => (
|
||||||
|
<SDKProvider directory={dir}>
|
||||||
|
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
|
||||||
|
<DraftProviders>
|
||||||
|
<NewSession />
|
||||||
|
</DraftProviders>
|
||||||
|
</DirectoryDataProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
@@ -69,9 +133,7 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
updaterEnabled?: boolean
|
|
||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
wsl?: boolean
|
|
||||||
}
|
}
|
||||||
api?: {
|
api?: {
|
||||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||||
@@ -142,6 +204,18 @@ function SessionProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
|
function DraftProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<FileProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<CommentsProvider>{props.children}</CommentsProvider>
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<AppShellProviders>
|
<AppShellProviders>
|
||||||
@@ -171,11 +245,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
|
<WslServersProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<MarkedProvider>
|
<MarkedProvider>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
</MarkedProvider>
|
</MarkedProvider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
|
</WslServersProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</UiI18nBridge>
|
</UiI18nBridge>
|
||||||
@@ -211,26 +287,21 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
|||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const checking = createMemo(
|
||||||
|
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Show
|
||||||
|
when={!checking()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/*<Show
|
|
||||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
|
||||||
fallback={
|
|
||||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
|
||||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>*/}
|
|
||||||
{checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
|
|
||||||
<Show
|
<Show
|
||||||
when={startupHealthCheck()}
|
when={startupHealthCheck.latest}
|
||||||
fallback={
|
fallback={
|
||||||
<ConnectionError
|
<ConnectionError
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
@@ -246,8 +317,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
|||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Show>
|
</Show>
|
||||||
{/*</Show>*/}
|
</Show>
|
||||||
</Suspense>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,34 +380,44 @@ function ServerKey(props: ParentProps) {
|
|||||||
export function AppInterface(props: {
|
export function AppInterface(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
|
canonicalLocalServer?: ServerConnection.Key
|
||||||
servers?: Array<ServerConnection.Any>
|
servers?: Array<ServerConnection.Any>
|
||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider
|
||||||
<ServersProvider>
|
defaultServer={props.defaultServer}
|
||||||
|
canonicalLocalServer={props.canonicalLocalServer}
|
||||||
|
servers={props.servers}
|
||||||
|
>
|
||||||
|
<GlobalProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
|
<Dynamic
|
||||||
|
component={props.router ?? Router}
|
||||||
|
root={(routerProps) => (
|
||||||
|
<TabsProvider>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<ServerSDKProvider>
|
<ServerSDKProvider>
|
||||||
<ServerSyncProvider>
|
<ServerSyncProvider>
|
||||||
<Dynamic
|
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
|
||||||
component={props.router ?? Router}
|
</ServerSyncProvider>
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
</ServerSDKProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</ServerKey>
|
||||||
|
</TabsProvider>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Route path="/" component={HomeRoute} />
|
<Route path="/" component={HomeRoute} />
|
||||||
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
</Route>
|
</Route>
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
|
||||||
</QueryProvider>
|
|
||||||
</ServerKey>
|
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
</ServersProvider>
|
</GlobalProvider>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
<div class="text-14-regular text-text-base">{select()?.message}</div>
|
<div class="text-14-regular text-text-base">{select()?.message}</div>
|
||||||
<div>
|
<div>
|
||||||
<List
|
<List
|
||||||
|
class="px-3"
|
||||||
items={select()?.options ?? []}
|
items={select()?.options ?? []}
|
||||||
key={(x) => x.value}
|
key={(x) => x.value}
|
||||||
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||||
@@ -364,6 +365,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<List
|
<List
|
||||||
|
class="px-3"
|
||||||
ref={(ref) => {
|
ref={(ref) => {
|
||||||
listRef = ref
|
listRef = ref
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,21 +6,23 @@ import { useMutation } from "@tanstack/solid-query"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject }) {
|
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSDK = useServerSDK()
|
const global = useGlobal()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const serverCtx = createMemo(() => global.createServerCtx(props.server))
|
||||||
|
const serverSDK = () => serverCtx().sdk
|
||||||
|
const serverSync = () => serverCtx().sync
|
||||||
|
|
||||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||||
const defaultName = createMemo(() => props.project.name || folderName())
|
const defaultName = createMemo(() => props.project.name || folderName())
|
||||||
@@ -78,19 +80,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
const start = store.startup.trim()
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await serverSDK.client.project.update({
|
await serverSDK().client.project.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
directory: props.project.worktree,
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
commands: { start },
|
commands: { start },
|
||||||
})
|
})
|
||||||
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverSync.project.meta(props.project.worktree, {
|
serverSync().project.meta(props.project.worktree, {
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||||
commands: { start: start || undefined },
|
commands: { start: start || undefined },
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const DialogFork: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<Dialog title={language.t("command.session.fork")}>
|
<Dialog title={language.t("command.session.fork")}>
|
||||||
<List
|
<List
|
||||||
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
|
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
|
||||||
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
|
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
|
||||||
emptyMessage={language.t("dialog.fork.empty")}
|
emptyMessage={language.t("dialog.fork.empty")}
|
||||||
key={(x) => x.id}
|
key={(x) => x.id}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<List
|
<List
|
||||||
|
class="px-3"
|
||||||
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
|
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
|
||||||
emptyMessage={language.t("dialog.model.empty")}
|
emptyMessage={language.t("dialog.model.empty")}
|
||||||
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
key={(x) => `${x?.provider?.id}:${x?.id}`}
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ import type { ListRef } from "@opencode-ai/ui/list"
|
|||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { ServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
multiple?: boolean
|
multiple?: boolean
|
||||||
onSelect: (result: string | string[] | null) => void
|
onSelect: (result: string | string[] | null) => void
|
||||||
|
server: ServerConnection.Any
|
||||||
}
|
}
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -127,11 +128,7 @@ function uniqueRows(rows: Row[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDirectorySearch(args: {
|
function useDirectorySearch(args: { sdk: ServerSDK; start: () => string | undefined; home: () => string }) {
|
||||||
sdk: ReturnType<typeof useServerSDK>
|
|
||||||
start: () => string | undefined
|
|
||||||
home: () => string
|
|
||||||
}) {
|
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
let current = 0
|
let current = 0
|
||||||
|
|
||||||
@@ -246,9 +243,8 @@ function useDirectorySearch(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const sync = useServerSync()
|
const global = useGlobal()
|
||||||
const sdk = useServerSDK()
|
const { sync, sdk, ...serverCtx } = global.createServerCtx(props.server)
|
||||||
const layout = useLayout()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
@@ -279,7 +275,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const recentProjects = createMemo(() => {
|
const recentProjects = createMemo(() => {
|
||||||
const projects = layout.projects.list()
|
const projects = serverCtx.projects.list()
|
||||||
const byProject = new Map<string, number>()
|
const byProject = new Map<string, number>()
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
@@ -324,6 +320,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return (
|
return (
|
||||||
<Dialog title={props.title ?? language.t("command.project.open")}>
|
<Dialog title={props.title ?? language.t("command.project.open")}>
|
||||||
<List
|
<List
|
||||||
|
class="px-3"
|
||||||
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
|
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
|
||||||
emptyMessage={language.t("dialog.directory.empty")}
|
emptyMessage={language.t("dialog.directory.empty")}
|
||||||
loadingMessage={language.t("common.loading")}
|
loadingMessage={language.t("common.loading")}
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
return (
|
return (
|
||||||
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
|
||||||
<List
|
<List
|
||||||
|
class="px-3"
|
||||||
search={{
|
search={{
|
||||||
placeholder: filesOnly()
|
placeholder: filesOnly()
|
||||||
? language.t("session.header.searchFiles")
|
? language.t("session.header.searchFiles")
|
||||||
@@ -398,6 +399,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
items={items}
|
items={items}
|
||||||
key={(item) => item.id}
|
key={(item) => item.id}
|
||||||
filterKeys={["title", "description", "category"]}
|
filterKeys={["title", "description", "category"]}
|
||||||
|
skipFilter={(item) => item.type === "file"}
|
||||||
groupBy={grouped() ? (item) => item.category : () => ""}
|
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
|
||||||
import { Component, createMemo, Show } from "solid-js"
|
import { Component, createMemo, Show } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useQueryOptions } from "@/context/server-sync"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
|
|
||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
connected: "mcp.status.connected",
|
connected: "mcp.status.connected",
|
||||||
@@ -19,10 +16,7 @@ const statusLabels = {
|
|||||||
|
|
||||||
export const DialogSelectMcp: Component = () => {
|
export const DialogSelectMcp: Component = () => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const queryOptions = useQueryOptions()
|
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
@@ -30,21 +24,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = useMutation(() => ({
|
const toggle = useMcpToggle()
|
||||||
mutationFn: async (name: string) => {
|
|
||||||
const status = sync.data.mcp[name]
|
|
||||||
if (status?.status === "connected") {
|
|
||||||
await sdk.client.mcp.disconnect({ name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (status?.status === "needs_auth") {
|
|
||||||
await sdk.client.mcp.auth.authenticate({ name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sdk.client.mcp.connect({ name })
|
|
||||||
},
|
|
||||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
const totalCount = createMemo(() => items().length)
|
const totalCount = createMemo(() => items().length)
|
||||||
@@ -55,6 +35,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
|
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
|
||||||
>
|
>
|
||||||
<List
|
<List
|
||||||
|
class="px-3"
|
||||||
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
|
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
|
||||||
emptyMessage={language.t("dialog.mcp.empty")}
|
emptyMessage={language.t("dialog.mcp.empty")}
|
||||||
key={(x) => x?.name ?? ""}
|
key={(x) => x?.name ?? ""}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
|||||||
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
||||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||||
<List
|
<List
|
||||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
|
||||||
ref={(ref) => (listRef = ref)}
|
ref={(ref) => (listRef = ref)}
|
||||||
items={model.list}
|
items={model.list}
|
||||||
current={model.current()}
|
current={model.current()}
|
||||||
@@ -90,7 +90,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
|||||||
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
|
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<List
|
<List
|
||||||
class="w-full px-0"
|
class="w-full px-3"
|
||||||
key={(p) => p.id}
|
key={(p) => p.id}
|
||||||
items={providers.popular}
|
items={providers.popular}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const ModelList: Component<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
||||||
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
|
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
|
||||||
emptyMessage={language.t("dialog.model.empty")}
|
emptyMessage={language.t("dialog.model.empty")}
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<Dialog title={language.t("command.provider.connect")} transition>
|
<Dialog title={language.t("command.provider.connect")} transition>
|
||||||
<List
|
<List
|
||||||
|
class="px-3"
|
||||||
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
||||||
emptyMessage={language.t("dialog.provider.empty")}
|
emptyMessage={language.t("dialog.provider.empty")}
|
||||||
activeIcon="plus-small"
|
activeIcon="plus-small"
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "opencode"
|
const DEFAULT_USERNAME = "opencode"
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ function useDefaultServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { defaultKey, canDefault, setDefault }
|
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
||||||
}
|
}
|
||||||
|
|
||||||
function useServerPreview() {
|
function useServerPreview() {
|
||||||
@@ -121,7 +124,7 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="px-5">
|
<div>
|
||||||
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
|
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
|
||||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||||
<TextField
|
<TextField
|
||||||
@@ -172,16 +175,31 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelectServer() {
|
export function DialogSelectServer() {
|
||||||
const navigate = useNavigate()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const controller = useServerManagementController({ onSelect: dialog.close })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={controller.formTitle()}>
|
||||||
|
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||||
|
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
||||||
|
<ServerConnectionForm controller={controller} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const global = useGlobal()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||||
const { previewStatus } = useServerPreview()
|
const { previewStatus } = useServerPreview()
|
||||||
const checkServerHealth = useCheckServerHealth()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
@@ -247,6 +265,11 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
|
if (options.navigateOnAdd === false) {
|
||||||
|
server.add(conn)
|
||||||
|
options.onSelect?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
await select(conn, true)
|
await select(conn, true)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -295,12 +318,14 @@ export function DialogSelectServer() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
|
const originalKey = ServerConnection.key(original)
|
||||||
const active = server.key
|
const active = server.key
|
||||||
|
tabs.removeServer(originalKey)
|
||||||
const newConn = server.add(next)
|
const newConn = server.add(next)
|
||||||
if (!newConn) return
|
if (!newConn) return
|
||||||
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
|
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
||||||
if (nextActive) server.setActive(nextActive)
|
if (nextActive) server.setActive(nextActive)
|
||||||
server.remove(ServerConnection.key(original))
|
server.remove(originalKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
@@ -311,7 +336,12 @@ export function DialogSelectServer() {
|
|||||||
return [current, ...list.filter((x) => x !== current)]
|
return [current, ...list.filter((x) => x !== current)]
|
||||||
})
|
})
|
||||||
|
|
||||||
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
const settings = useSettings()
|
||||||
|
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||||
|
settings.general.newLayoutDesigns()
|
||||||
|
? undefined
|
||||||
|
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
||||||
|
)
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const list = items()
|
const list = items()
|
||||||
@@ -326,32 +356,16 @@ export function DialogSelectServer() {
|
|||||||
return list.slice().sort((a, b) => {
|
return list.slice().sort((a, b) => {
|
||||||
if (a === active) return -1
|
if (a === active) return -1
|
||||||
if (b === active) return 1
|
if (b === active) return 1
|
||||||
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
|
const diff =
|
||||||
|
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) return diff
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function refreshHealth() {
|
|
||||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
|
||||||
await Promise.all(
|
|
||||||
items().map(async (conn) => {
|
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setStore("status", reconcile(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
items()
|
|
||||||
void refreshHealth()
|
|
||||||
const interval = setInterval(refreshHealth, 10_000)
|
|
||||||
onCleanup(() => clearInterval(interval))
|
|
||||||
})
|
|
||||||
|
|
||||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||||
dialog.close()
|
options.onSelect?.()
|
||||||
if (persist && conn.type === "http") {
|
if (persist && conn.type === "http") {
|
||||||
server.add(conn)
|
server.add(conn)
|
||||||
navigate("/")
|
navigate("/")
|
||||||
@@ -457,7 +471,7 @@ export function DialogSelectServer() {
|
|||||||
username: conn.http.username ?? "",
|
username: conn.http.username ?? "",
|
||||||
password: conn.http.password ?? "",
|
password: conn.http.password ?? "",
|
||||||
error: "",
|
error: "",
|
||||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,66 +509,84 @@ export function DialogSelectServer() {
|
|||||||
resetEdit()
|
resetEdit()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleRemove(url: ServerConnection.Key) {
|
async function handleRemove(key: ServerConnection.Key) {
|
||||||
server.remove(url)
|
try {
|
||||||
if ((await platform.getDefaultServer?.()) === url) {
|
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||||
void platform.setDefaultServer?.(null)
|
tabs.removeServer(key)
|
||||||
|
server.remove(key)
|
||||||
|
if ((await platform.getDefaultServer?.()) === key) {
|
||||||
|
await setDefault(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<Dialog title={formTitle()}>
|
defaultKey,
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-2">
|
canDefault,
|
||||||
<Show
|
current,
|
||||||
when={!isFormMode()}
|
sortedItems,
|
||||||
fallback={
|
status: () => global.servers.health,
|
||||||
<ServerForm
|
isFormMode,
|
||||||
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
isAddMode,
|
||||||
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
formTitle,
|
||||||
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
formBusy,
|
||||||
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
||||||
busy={formBusy()}
|
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
||||||
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
||||||
status={isAddMode() ? store.addServer.status : store.editServer.status}
|
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
||||||
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
||||||
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
select,
|
||||||
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
setDefault,
|
||||||
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
startAdd,
|
||||||
onSubmit={submitForm}
|
startEdit,
|
||||||
onBack={resetForm}
|
resetForm,
|
||||||
/>
|
submitForm,
|
||||||
|
handleRemove,
|
||||||
|
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||||
|
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||||
|
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
||||||
|
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
||||||
}
|
}
|
||||||
>
|
}
|
||||||
|
|
||||||
|
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
<List
|
<List
|
||||||
|
class="flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||||
search={{
|
search={{
|
||||||
placeholder: language.t("dialog.server.search.placeholder"),
|
placeholder: language.t("dialog.server.search.placeholder"),
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
}}
|
}}
|
||||||
noInitialSelection
|
noInitialSelection
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={sortedItems}
|
items={props.controller.sortedItems}
|
||||||
key={(x) => x.http.url}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) void select(x)
|
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
||||||
}}
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
class="flex-1 min-h-0 px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const key = ServerConnection.key(i)
|
const key = ServerConnection.key(i)
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||||
<div class="flex flex-col h-full items-start w-5">
|
<div class="flex flex-col h-full items-center w-5">
|
||||||
<ServerHealthIndicator health={store.status[key]} />
|
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||||
</div>
|
</div>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
dimmed={store.status[key]?.healthy === false}
|
dimmed={props.controller.status()[key]?.healthy === false}
|
||||||
status={store.status[key]}
|
status={props.controller.status()[key]}
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultKey() === ServerConnection.key(i)}>
|
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -563,7 +595,7 @@ export function DialogSelectServer() {
|
|||||||
showCredentials
|
showCredentials
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center gap-4 pl-4">
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
<Show when={ServerConnection.key(current()) === key}>
|
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
||||||
<Icon name="check" class="h-6" />
|
<Icon name="check" class="h-6" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -582,20 +614,18 @@ export function DialogSelectServer() {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (i.type !== "http") return
|
if (i.type !== "http") return
|
||||||
startEdit(i)
|
props.controller.startEdit(i)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={canDefault() && defaultKey() !== key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canDefault() && defaultKey() === key}>
|
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
@@ -603,7 +633,7 @@ export function DialogSelectServer() {
|
|||||||
</Show>
|
</Show>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => handleRemove(ServerConnection.key(i))}
|
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
@@ -617,33 +647,58 @@ export function DialogSelectServer() {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</List>
|
</List>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="shrink-0 px-5 pb-5">
|
<div class="shrink-0 pb-5">
|
||||||
<Show
|
|
||||||
when={isFormMode()}
|
|
||||||
fallback={
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={startAdd}
|
onClick={props.controller.startAdd}
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{language.t("dialog.server.add.button")}
|
{language.t("dialog.server.add.button")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
|
<ServerForm
|
||||||
|
value={props.controller.formValue()}
|
||||||
|
name={props.controller.formName()}
|
||||||
|
username={props.controller.formUsername()}
|
||||||
|
password={props.controller.formPassword()}
|
||||||
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
|
busy={props.controller.formBusy()}
|
||||||
|
error={props.controller.formError()}
|
||||||
|
status={props.controller.formStatus()}
|
||||||
|
onChange={props.controller.handleFormChange()}
|
||||||
|
onNameChange={props.controller.handleFormNameChange()}
|
||||||
|
onUsernameChange={props.controller.handleFormUsernameChange()}
|
||||||
|
onPasswordChange={props.controller.handleFormPasswordChange()}
|
||||||
|
onSubmit={props.controller.submitForm}
|
||||||
|
onBack={props.controller.resetForm}
|
||||||
|
/>
|
||||||
|
<div class="shrink-0 pb-5">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={props.controller.submitForm}
|
||||||
|
disabled={props.controller.formBusy()}
|
||||||
|
class="px-3 py-1.5"
|
||||||
>
|
>
|
||||||
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
|
{props.controller.formBusy()
|
||||||
{formBusy()
|
|
||||||
? language.t("dialog.server.add.checking")
|
? language.t("dialog.server.add.checking")
|
||||||
: isAddMode()
|
: props.controller.isAddMode()
|
||||||
? language.t("dialog.server.add.button")
|
? language.t("dialog.server.add.button")
|
||||||
: language.t("common.save")}
|
: language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
|
|||||||
import { SettingsKeybinds } from "./settings-keybinds"
|
import { SettingsKeybinds } from "./settings-keybinds"
|
||||||
import { SettingsProviders } from "./settings-providers"
|
import { SettingsProviders } from "./settings-providers"
|
||||||
import { SettingsModels } from "./settings-models"
|
import { SettingsModels } from "./settings-models"
|
||||||
|
import { SettingsServers } from "./settings-servers"
|
||||||
|
|
||||||
export const DialogSettings: Component = () => {
|
export const DialogSettings: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -17,7 +18,7 @@ export const DialogSettings: Component = () => {
|
|||||||
<Dialog size="x-large" transition>
|
<Dialog size="x-large" transition>
|
||||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full">
|
<div class="flex flex-col justify-between h-full w-full gap-4">
|
||||||
<div class="flex flex-col gap-3 w-full pt-3">
|
<div class="flex flex-col gap-3 w-full pt-3">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
@@ -31,6 +32,10 @@ export const DialogSettings: Component = () => {
|
|||||||
<Icon name="keyboard" />
|
<Icon name="keyboard" />
|
||||||
{language.t("settings.tab.shortcuts")}
|
{language.t("settings.tab.shortcuts")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="servers">
|
||||||
|
<Icon name="server" />
|
||||||
|
{language.t("status.popover.tab.servers")}
|
||||||
|
</Tabs.Trigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,6 +66,9 @@ export const DialogSettings: Component = () => {
|
|||||||
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
||||||
<SettingsKeybinds />
|
<SettingsKeybinds />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="servers" class="no-scrollbar">
|
||||||
|
<SettingsServers />
|
||||||
|
</Tabs.Content>
|
||||||
<Tabs.Content value="providers" class="no-scrollbar">
|
<Tabs.Content value="providers" class="no-scrollbar">
|
||||||
<SettingsProviders />
|
<SettingsProviders />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import type { Platform } from "@/context/platform"
|
||||||
|
|
||||||
|
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
||||||
|
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
|
||||||
|
return "server" as const
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { directoryPickerKind } from "./directory-picker-policy"
|
||||||
|
|
||||||
|
const local = {
|
||||||
|
type: "sidecar",
|
||||||
|
variant: "base",
|
||||||
|
http: { url: "http://localhost:4096" },
|
||||||
|
} as const
|
||||||
|
const remote = {
|
||||||
|
type: "ssh",
|
||||||
|
host: "example.test",
|
||||||
|
http: { url: "http://localhost:4096" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
describe("directoryPickerKind", () => {
|
||||||
|
test("uses the native picker only for local desktop projects", () => {
|
||||||
|
expect(directoryPickerKind("desktop", local)).toBe("native")
|
||||||
|
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
||||||
|
expect(directoryPickerKind("web", local)).toBe("server")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||||
|
import { directoryPickerKind } from "./directory-picker-policy"
|
||||||
|
|
||||||
|
type DirectoryPickerInput = {
|
||||||
|
server: ServerConnection.Any
|
||||||
|
title?: string
|
||||||
|
multiple?: boolean
|
||||||
|
onSelect: (result: string | string[] | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDirectoryPicker() {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
return (input: DirectoryPickerInput) => {
|
||||||
|
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
||||||
|
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.show(
|
||||||
|
() => <DialogSelectDirectory {...input} />,
|
||||||
|
() => input.onSelect(null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ beforeAll(async () => {
|
|||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
useNavigate: () => () => undefined,
|
useNavigate: () => () => undefined,
|
||||||
useParams: () => ({}),
|
useParams: () => ({}),
|
||||||
|
useLocation: () => ({}),
|
||||||
|
useSearchParams: () => [{}, () => undefined],
|
||||||
}))
|
}))
|
||||||
mock.module("@/context/file", () => ({
|
mock.module("@/context/file", () => ({
|
||||||
useFile: () => ({
|
useFile: () => ({
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
|
import { createSignal, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
|
export function HelpButton() {
|
||||||
|
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
||||||
|
|
||||||
|
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
|
||||||
|
const [shown, setShown] = createSignal(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={!state.dismissed}>
|
||||||
|
<div class="fixed bottom-4 right-4 z-50">
|
||||||
|
<Popover
|
||||||
|
open={shown()}
|
||||||
|
onOpenChange={setShown}
|
||||||
|
triggerAs="button"
|
||||||
|
triggerProps={{
|
||||||
|
type: "button",
|
||||||
|
"aria-label": "Help",
|
||||||
|
class:
|
||||||
|
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
|
||||||
|
}}
|
||||||
|
trigger={<span aria-hidden="true">?</span>}
|
||||||
|
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||||
|
gutter={8}
|
||||||
|
placement="top-end"
|
||||||
|
>
|
||||||
|
<Show when={shown()}>
|
||||||
|
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setShown(false)
|
||||||
|
setState("dismissed", true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="xmark-small" />
|
||||||
|
</button>
|
||||||
|
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
|
||||||
|
<p class="text-12-regular text-text-weak">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,9 +31,10 @@ import {
|
|||||||
FileAttachmentPart,
|
FileAttachmentPart,
|
||||||
} from "@/context/prompt"
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -56,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
|||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
|
||||||
import {
|
import {
|
||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
@@ -72,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
|||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
|
import { useDirectoryPicker } from "./directory-picker"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import { useQueries } from "@tanstack/solid-query"
|
import { useQueries } from "@tanstack/solid-query"
|
||||||
import { useQueryOptions } from "@/context/server-sync"
|
import { useQueryOptions } from "@/context/server-sync"
|
||||||
@@ -139,7 +142,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const pickDirectory = useDirectoryPicker()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const tabsStore = useTabs()
|
||||||
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef: HTMLInputElement | undefined
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
@@ -277,6 +283,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
draggingType: "image" | "@mention" | null
|
draggingType: "image" | "@mention" | null
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
|
variantOpen: boolean
|
||||||
}>({
|
}>({
|
||||||
popover: null,
|
popover: null,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
@@ -285,6 +292,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
|
variantOpen: false,
|
||||||
})
|
})
|
||||||
const [picker, setPicker] = createStore({
|
const [picker, setPicker] = createStore({
|
||||||
projectOpen: false,
|
projectOpen: false,
|
||||||
@@ -463,7 +471,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||||
|
|
||||||
const pick = () => fileInputRef?.click()
|
const pick = () => {
|
||||||
|
pickAttachmentFiles({
|
||||||
|
picker: platform.openAttachmentPickerDialog,
|
||||||
|
directory: () => sdk.directory,
|
||||||
|
fallback: () => fileInputRef?.click(),
|
||||||
|
onFile: addAttachment,
|
||||||
|
onError: (error) =>
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const setMode = (mode: "normal" | "shell") => {
|
const setMode = (mode: "normal" | "shell") => {
|
||||||
setStore("mode", mode)
|
setStore("mode", mode)
|
||||||
@@ -623,6 +644,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
key: atKey,
|
key: atKey,
|
||||||
filterKeys: ["display"],
|
filterKeys: ["display"],
|
||||||
|
skipFilter: (item) => item.type === "file" && !item.recent,
|
||||||
groupBy: (item) => {
|
groupBy: (item) => {
|
||||||
if (item.type === "agent") return "agent"
|
if (item.type === "agent") return "agent"
|
||||||
if (item.recent) return "recent"
|
if (item.recent) return "recent"
|
||||||
@@ -1073,7 +1095,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
isDialogActive: () => !!dialog.active,
|
isDialogActive: () => !!dialog.active,
|
||||||
setDraggingType: (type) => setStore("draggingType", type),
|
setDraggingType: (type) => setStore("draggingType", type),
|
||||||
@@ -1101,6 +1123,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
|
// Check provider variants directly: `variants` also includes the UI-only default option.
|
||||||
|
const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
|
||||||
const accepting = createMemo(() => {
|
const accepting = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
@@ -1360,23 +1384,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
layout.projects.open(worktree)
|
layout.projects.open(worktree)
|
||||||
server.projects.touch(worktree)
|
server.projects.touch(worktree)
|
||||||
|
|
||||||
|
// On the draft route, retarget the existing draft in place so we keep the same
|
||||||
|
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
|
||||||
|
const draftID = search.draftId
|
||||||
|
if (draftID) {
|
||||||
|
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
|
||||||
|
restoreFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
navigate(`/${base64Encode(worktree)}/session`)
|
navigate(`/${base64Encode(worktree)}/session`)
|
||||||
}
|
}
|
||||||
const addProject = async () => {
|
const addProject = () => {
|
||||||
|
const conn = server.current
|
||||||
|
if (!conn) return
|
||||||
const select = (result: string | string[] | null) => {
|
const select = (result: string | string[] | null) => {
|
||||||
const directory = Array.isArray(result) ? result[0] : result
|
const directory = Array.isArray(result) ? result[0] : result
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
selectProject(directory)
|
selectProject(directory)
|
||||||
}
|
}
|
||||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
pickDirectory({
|
||||||
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
|
server: conn,
|
||||||
return
|
title: language.t("command.project.open"),
|
||||||
}
|
onSelect: select,
|
||||||
void import("@/components/dialog-select-directory").then((x) => {
|
|
||||||
dialog.show(
|
|
||||||
() => <x.DialogSelectDirectory onSelect={select} />,
|
|
||||||
() => select(null),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1569,6 +1600,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<ComposerPickerTrigger state={newProjectTriggerState()} />
|
<ComposerPickerTrigger state={newProjectTriggerState()} />
|
||||||
</Show>
|
</Show>
|
||||||
<ComposerModelControl state={modelControlState()} />
|
<ComposerModelControl state={modelControlState()} />
|
||||||
|
<Show when={store.mode !== "shell" && showVariantControl()}>
|
||||||
|
<div
|
||||||
|
data-component="prompt-variant-control"
|
||||||
|
classList={{
|
||||||
|
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||||
|
!local.model.variant.current() && !store.variantOpen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.variant.cycle")}
|
||||||
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
options={variants()}
|
||||||
|
current={local.model.variant.current() ?? "default"}
|
||||||
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
|
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||||
|
onSelect={(value) => {
|
||||||
|
local.model.variant.set(value === "default" ? undefined : value)
|
||||||
|
restoreFocus()
|
||||||
|
}}
|
||||||
|
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||||
|
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||||
|
triggerStyle={control()}
|
||||||
|
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -1888,7 +1952,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={variants().length > 2}>
|
<Show when={showVariantControl()}>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-variant-control"
|
data-component="prompt-variant-control"
|
||||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { attachmentMime } from "./files"
|
import { attachmentMime, pickAttachmentFiles } from "./files"
|
||||||
import { pasteMode } from "./paste"
|
import { pasteMode } from "./paste"
|
||||||
|
|
||||||
describe("attachmentMime", () => {
|
describe("attachmentMime", () => {
|
||||||
@@ -24,6 +24,70 @@ describe("attachmentMime", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("pickAttachmentFiles", () => {
|
||||||
|
test("reads the current project directory for every native picker invocation", async () => {
|
||||||
|
const paths: string[] = []
|
||||||
|
const files: File[] = []
|
||||||
|
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
|
||||||
|
let directory = "C:\\Projects\\LoremIpsum"
|
||||||
|
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
|
||||||
|
paths.push(options?.defaultPath ?? "")
|
||||||
|
await onFile?.(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pickAttachmentFiles({
|
||||||
|
picker,
|
||||||
|
directory: () => directory,
|
||||||
|
fallback: () => undefined,
|
||||||
|
onFile: async (selected) => files.push(selected),
|
||||||
|
onError: () => undefined,
|
||||||
|
})
|
||||||
|
await Promise.resolve()
|
||||||
|
directory = "C:\\Projects\\DolorSit"
|
||||||
|
pickAttachmentFiles({
|
||||||
|
picker,
|
||||||
|
directory: () => directory,
|
||||||
|
fallback: () => undefined,
|
||||||
|
onFile: async (selected) => files.push(selected),
|
||||||
|
onError: () => undefined,
|
||||||
|
})
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(files).toEqual([file, file])
|
||||||
|
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the browser file input when no native picker exists", async () => {
|
||||||
|
let fallback = 0
|
||||||
|
pickAttachmentFiles({
|
||||||
|
directory: () => "/projects/consectetur-adipiscing",
|
||||||
|
fallback: () => {
|
||||||
|
fallback += 1
|
||||||
|
},
|
||||||
|
onFile: async () => undefined,
|
||||||
|
onError: () => undefined,
|
||||||
|
})
|
||||||
|
expect(fallback).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports native picker failures without rejecting", async () => {
|
||||||
|
const error = new Error("picker unavailable")
|
||||||
|
const errors: unknown[] = []
|
||||||
|
const handled = Promise.withResolvers<void>()
|
||||||
|
pickAttachmentFiles({
|
||||||
|
picker: async () => Promise.reject(error),
|
||||||
|
directory: () => "C:\\Projects\\LoremIpsum",
|
||||||
|
fallback: () => undefined,
|
||||||
|
onFile: async () => undefined,
|
||||||
|
onError: (cause) => {
|
||||||
|
errors.push(cause)
|
||||||
|
handled.resolve()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await handled.promise
|
||||||
|
expect(errors).toEqual([error])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("pasteMode", () => {
|
describe("pasteMode", () => {
|
||||||
test("uses native paste for short single-line text", () => {
|
test("uses native paste for short single-line text", () => {
|
||||||
expect(pasteMode("hello world")).toBe("native")
|
expect(pasteMode("hello world")).toBe("native")
|
||||||
|
|||||||
@@ -2,6 +2,38 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
|
|||||||
|
|
||||||
export { ACCEPTED_FILE_TYPES }
|
export { ACCEPTED_FILE_TYPES }
|
||||||
|
|
||||||
|
type AttachmentPicker = (
|
||||||
|
options: {
|
||||||
|
defaultPath?: string
|
||||||
|
multiple?: boolean
|
||||||
|
accept?: string[]
|
||||||
|
},
|
||||||
|
onFile: (file: File) => Promise<unknown>,
|
||||||
|
) => Promise<void>
|
||||||
|
|
||||||
|
export function pickAttachmentFiles(input: {
|
||||||
|
picker?: AttachmentPicker
|
||||||
|
directory: () => string
|
||||||
|
fallback: () => void
|
||||||
|
onFile: (file: File) => Promise<unknown>
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
if (!input.picker) {
|
||||||
|
input.fallback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void input
|
||||||
|
.picker(
|
||||||
|
{
|
||||||
|
defaultPath: input.directory(),
|
||||||
|
multiple: true,
|
||||||
|
accept: ACCEPTED_FILE_TYPES,
|
||||||
|
},
|
||||||
|
input.onFile,
|
||||||
|
)
|
||||||
|
.catch(input.onError)
|
||||||
|
}
|
||||||
|
|
||||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||||
const IMAGE_EXTS = new Map([
|
const IMAGE_EXTS = new Map([
|
||||||
["gif", "image/gif"],
|
["gif", "image/gif"],
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ beforeAll(async () => {
|
|||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
useNavigate: () => () => undefined,
|
useNavigate: () => () => undefined,
|
||||||
useParams: () => params,
|
useParams: () => params,
|
||||||
|
useLocation: () => ({}),
|
||||||
|
useSearchParams: () => [{}, () => undefined],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||||
@@ -103,6 +105,16 @@ beforeAll(async () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/server", () => ({
|
||||||
|
useServer: () => ({ key: "server-key" }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/tabs", () => ({
|
||||||
|
useTabs: () => ({
|
||||||
|
promoteDraft: () => undefined,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
mock.module("@/context/prompt", () => ({
|
mock.module("@/context/prompt", () => ({
|
||||||
usePrompt: () => ({
|
usePrompt: () => ({
|
||||||
current: () => promptValue,
|
current: () => promptValue,
|
||||||
@@ -127,6 +139,7 @@ beforeAll(async () => {
|
|||||||
mock.module("@/context/sdk", () => ({
|
mock.module("@/context/sdk", () => ({
|
||||||
useSDK: () => {
|
useSDK: () => {
|
||||||
const sdk = {
|
const sdk = {
|
||||||
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
|||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, type Accessor } from "solid-js"
|
import { batch, type Accessor } from "solid-js"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
@@ -18,6 +20,7 @@ import { Worktree as WorktreeState } from "@/utils/worktree"
|
|||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -212,6 +215,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
@@ -232,11 +239,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
const queued = pending.get(sessionID)
|
const key = pendingKey(sessionID)
|
||||||
|
const queued = pending.get(key)
|
||||||
if (queued) {
|
if (queued) {
|
||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
queued.cleanup()
|
queued.cleanup()
|
||||||
pending.delete(sessionID)
|
pending.delete(key)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
return sdk.client.session
|
return sdk.client.session
|
||||||
@@ -341,7 +349,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
WorktreeState.pending(createdWorktree.directory)
|
WorktreeState.pending(sdk.scope, createdWorktree.directory)
|
||||||
sessionDirectory = createdWorktree.directory
|
sessionDirectory = createdWorktree.directory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +386,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||||
local.session.promote(sessionDirectory, session.id)
|
local.session.promote(sessionDirectory, session.id)
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
const draftID = search.draftId
|
||||||
|
if (draftID)
|
||||||
|
tabs.promoteDraft(draftID, {
|
||||||
|
server: server.key,
|
||||||
|
dirBase64: base64Encode(sessionDirectory),
|
||||||
|
sessionId: session.id,
|
||||||
|
})
|
||||||
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -500,7 +515,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
clearInput()
|
clearInput()
|
||||||
|
|
||||||
const waitForWorktree = async () => {
|
const waitForWorktree = async () => {
|
||||||
const worktree = WorktreeState.get(sessionDirectory)
|
const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
if (!worktree || worktree.status !== "pending") return true
|
||||||
|
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
@@ -517,7 +532,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
restoreInput()
|
restoreInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.set(session.id, { abort: controller, cleanup })
|
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||||
|
|
||||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
@@ -544,11 +559,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
|
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(
|
||||||
|
() => {
|
||||||
if (timer.id === undefined) return
|
if (timer.id === undefined) return
|
||||||
clearTimeout(timer.id)
|
clearTimeout(timer.id)
|
||||||
})
|
},
|
||||||
pending.delete(session.id)
|
)
|
||||||
|
pending.delete(pendingKey(session.id))
|
||||||
if (controller.signal.aborted) return false
|
if (controller.signal.aborted) return false
|
||||||
if (result.status === "failed") throw new Error(result.message)
|
if (result.status === "failed") throw new Error(result.message)
|
||||||
return true
|
return true
|
||||||
@@ -563,7 +580,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
before: waitForWorktree,
|
before: waitForWorktree,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
pending.delete(session.id)
|
pending.delete(pendingKey(session.id))
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set("session_status", session.id, { type: "idle" })
|
sync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { type Component, Show } from "solid-js"
|
||||||
|
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
|
export const ServerRowMenu: Component<{
|
||||||
|
server: ServerConnection.Any
|
||||||
|
controller: ReturnType<typeof useServerManagementController>
|
||||||
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const key = ServerConnection.key(props.server)
|
||||||
|
const builtin = ServerConnection.builtin(props.server)
|
||||||
|
const isDefault = () => props.controller.defaultKey() === key
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
|
<MenuV2.Trigger
|
||||||
|
as={IconButtonV2}
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
icon={<IconV2 name="outline-dots" />}
|
||||||
|
aria-label={language.t("common.moreOptions")}
|
||||||
|
/>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content>
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.Item
|
||||||
|
disabled={builtin || props.server.type !== "http"}
|
||||||
|
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
||||||
|
>
|
||||||
|
{language.t("dialog.server.menu.edit")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
<Show when={props.controller.canDefault() && !isDefault()}>
|
||||||
|
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||||
|
{language.t("dialog.server.menu.default")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.controller.canDefault() && isDefault()}>
|
||||||
|
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||||
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
|
<MenuV2.Separator />
|
||||||
|
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
||||||
|
{language.t("dialog.server.menu.delete")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Group>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
|||||||
|
|
||||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
||||||
return (
|
return (
|
||||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
|
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
||||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
@@ -8,13 +7,13 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useUpdaterAction } from "./updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
monoFontFamily,
|
monoFontFamily,
|
||||||
@@ -91,9 +90,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const updater = useUpdaterAction()
|
||||||
checking: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
const dir = createMemo(() => decode64(params.dir))
|
||||||
@@ -123,66 +120,14 @@ export const SettingsGeneral: Component = () => {
|
|||||||
}
|
}
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const check = () => {
|
|
||||||
if (!platform.checkUpdate) return
|
|
||||||
setStore("checking", true)
|
|
||||||
|
|
||||||
void platform
|
|
||||||
.checkUpdate()
|
|
||||||
.then((result) => {
|
|
||||||
if (!result.updateAvailable) {
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("settings.updates.toast.latest.title"),
|
|
||||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = platform.updateAndRestart
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: language.t("toast.update.action.installRestart"),
|
|
||||||
onClick: async () => {
|
|
||||||
await platform.updateAndRestart!()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: language.t("toast.update.action.notYet"),
|
|
||||||
onClick: "dismiss" as const,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: language.t("toast.update.action.notYet"),
|
|
||||||
onClick: "dismiss" as const,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
showToast({
|
|
||||||
persistent: true,
|
|
||||||
icon: "download",
|
|
||||||
title: language.t("toast.update.title"),
|
|
||||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
|
||||||
actions,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
})
|
|
||||||
.finally(() => setStore("checking", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const globalSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
globalSdk.client.pty
|
serverSdk.client.pty
|
||||||
.shells()
|
.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data ?? [])
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
@@ -723,19 +668,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||||
|
|
||||||
<SettingsList>
|
<SettingsList>
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.updates.row.startup.title")}
|
|
||||||
description={language.t("settings.updates.row.startup.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-updates-startup">
|
|
||||||
<Switch
|
|
||||||
checked={settings.updates.startup()}
|
|
||||||
disabled={!platform.checkUpdate}
|
|
||||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.releaseNotes.title")}
|
title={language.t("settings.general.row.releaseNotes.title")}
|
||||||
description={language.t("settings.general.row.releaseNotes.description")}
|
description={language.t("settings.general.row.releaseNotes.description")}
|
||||||
@@ -752,10 +684,8 @@ export const SettingsGeneral: Component = () => {
|
|||||||
title={language.t("settings.updates.row.check.title")}
|
title={language.t("settings.updates.row.check.title")}
|
||||||
description={language.t("settings.updates.row.check.description")}
|
description={language.t("settings.updates.row.check.description")}
|
||||||
>
|
>
|
||||||
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
||||||
{store.checking
|
{language.t(updater.action().label)}
|
||||||
? language.t("settings.updates.action.checking")
|
|
||||||
: language.t("settings.updates.action.checkNow")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
|
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||||
|
|
||||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||||
|
|
||||||
@@ -32,6 +33,14 @@ const ListEmptyState: Component<{ message: string; filter: string }> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsModels: Component = () => {
|
export const SettingsModels: Component = () => {
|
||||||
|
return (
|
||||||
|
<SettingsServerScope>
|
||||||
|
<SettingsModelsContent />
|
||||||
|
</SettingsServerScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsModelsContent: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
|
||||||
@@ -61,7 +70,10 @@ export const SettingsModels: Component = () => {
|
|||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||||
|
<SettingsServerPicker />
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DialogConnectProvider } from "./dialog-connect-provider"
|
|||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
|
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||||
|
|
||||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||||
@@ -28,6 +29,14 @@ const PROVIDER_NOTES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const SettingsProviders: Component = () => {
|
export const SettingsProviders: Component = () => {
|
||||||
|
return (
|
||||||
|
<SettingsServerScope>
|
||||||
|
<SettingsProvidersContent />
|
||||||
|
</SettingsServerScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsProvidersContent: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
@@ -129,8 +138,9 @@ export const SettingsProviders: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
||||||
|
<SettingsServerPicker />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { createMemo, For, type ParentProps, Show } from "solid-js"
|
||||||
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
|
import { ModelsProvider } from "@/context/models"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
|
||||||
|
export function SettingsServerScope(props: ParentProps) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={settings.general.newLayoutDesigns()} fallback={props.children}>
|
||||||
|
<Show when={global.settings.server.selected()}>
|
||||||
|
{(server) => <SettingsServerDataProviders server={server()}>{props.children}</SettingsServerDataProviders>}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsServerDataProviders(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverCtx = () => global.createServerCtx(props.server)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={serverCtx().queryClient}>
|
||||||
|
<ServerSDKProvider server={props.server}>
|
||||||
|
<ServerSyncProvider>
|
||||||
|
<ModelsProvider>{props.children}</ModelsProvider>
|
||||||
|
</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsServerPicker() {
|
||||||
|
const global = useGlobal()
|
||||||
|
const settings = useSettings()
|
||||||
|
const selected = createMemo(() =>
|
||||||
|
settings.general.newLayoutDesigns() ? global.settings.server.selected() : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={selected()}>
|
||||||
|
{(conn) => (
|
||||||
|
<DropdownMenu gutter={4} placement="bottom-end">
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={Button}
|
||||||
|
variant="secondary"
|
||||||
|
size="large"
|
||||||
|
class="h-8 max-w-[260px] gap-2 px-2 py-1.5 data-[expanded]:bg-surface-base-active"
|
||||||
|
>
|
||||||
|
<ServerHealthIndicator health={global.servers.health[ServerConnection.key(conn())]} />
|
||||||
|
<ServerRow
|
||||||
|
conn={conn()}
|
||||||
|
status={global.servers.health[ServerConnection.key(conn())]}
|
||||||
|
class="flex items-center gap-2 min-w-0 flex-1"
|
||||||
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
|
versionClass="hidden"
|
||||||
|
/>
|
||||||
|
<Icon name="chevron-down" size="small" class="text-icon-weak shrink-0" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="w-[320px] mt-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-2 [&_[data-slot=dropdown-menu-radio-item]]:pr-2">
|
||||||
|
<DropdownMenu.RadioGroup
|
||||||
|
value={global.settings.server.key}
|
||||||
|
onChange={(key) => {
|
||||||
|
if (typeof key === "string") global.settings.server.set(ServerConnection.Key.make(key))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={global.servers.list()}>
|
||||||
|
{(item) => {
|
||||||
|
const key = ServerConnection.key(item)
|
||||||
|
const blocked = () => global.servers.health[key]?.healthy === false
|
||||||
|
return (
|
||||||
|
<DropdownMenu.RadioItem value={key} disabled={blocked()}>
|
||||||
|
<ServerHealthIndicator health={global.servers.health[key]} />
|
||||||
|
<ServerRow
|
||||||
|
conn={item}
|
||||||
|
dimmed={blocked()}
|
||||||
|
status={global.servers.health[key]}
|
||||||
|
class="flex items-center gap-2 min-w-0 flex-1"
|
||||||
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
|
/>
|
||||||
|
<DropdownMenu.ItemIndicator>
|
||||||
|
<Icon name="check-small" size="small" class="text-icon-weak" />
|
||||||
|
</DropdownMenu.ItemIndicator>
|
||||||
|
</DropdownMenu.RadioItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</DropdownMenu.RadioGroup>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Show, type Component } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
|
||||||
|
|
||||||
|
export const SettingsServers: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const controller = useServerManagementController()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
|
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
|
||||||
|
<Show
|
||||||
|
when={controller.isFormMode()}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
|
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||||
|
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ServerConnectionList controller={controller} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
|
||||||
|
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
|
||||||
|
<ServerConnectionForm controller={controller} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { type ServerConnection } from "@/context/server"
|
||||||
|
import { useServerManagementController } from "../dialog-select-server"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
export const DialogServerV2: Component<{
|
||||||
|
mode: "add" | "edit"
|
||||||
|
server?: ServerConnection.Http
|
||||||
|
}> = (props) => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const controller = useServerManagementController({
|
||||||
|
onSelect: () => dialog.close(),
|
||||||
|
navigateOnAdd: false,
|
||||||
|
})
|
||||||
|
const [opened, setOpened] = createSignal(false)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (props.mode === "add") controller.startAdd()
|
||||||
|
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
||||||
|
setOpened(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
controller.resetForm()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!opened()) return
|
||||||
|
if (controller.isFormMode()) return
|
||||||
|
dialog.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Enter" || event.isComposing) return
|
||||||
|
event.preventDefault()
|
||||||
|
controller.submitForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = () =>
|
||||||
|
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||||
|
|
||||||
|
const submitLabel = () => {
|
||||||
|
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
||||||
|
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||||
|
return language.t("common.save")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title={title()} fit class="settings-v2-server-dialog">
|
||||||
|
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
|
||||||
|
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||||
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
|
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||||
|
<TextInputV2
|
||||||
|
type="text"
|
||||||
|
appearance="large"
|
||||||
|
class="!w-full self-stretch"
|
||||||
|
value={controller.formValue()}
|
||||||
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
|
invalid={!!controller.formError()}
|
||||||
|
disabled={controller.formBusy()}
|
||||||
|
autofocus
|
||||||
|
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
||||||
|
onKeyDown={keyDown}
|
||||||
|
/>
|
||||||
|
<Show when={controller.formError()}>
|
||||||
|
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||||
|
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.name")}</label>
|
||||||
|
<TextInputV2
|
||||||
|
type="text"
|
||||||
|
appearance="large"
|
||||||
|
class="!w-full self-stretch"
|
||||||
|
value={controller.formName()}
|
||||||
|
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||||
|
disabled={controller.formBusy()}
|
||||||
|
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
||||||
|
onKeyDown={keyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid w-full min-w-0 grid-cols-2 gap-4">
|
||||||
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
|
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.username")}</label>
|
||||||
|
<TextInputV2
|
||||||
|
type="text"
|
||||||
|
appearance="large"
|
||||||
|
class="!w-full self-stretch"
|
||||||
|
value={controller.formUsername()}
|
||||||
|
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||||
|
disabled={controller.formBusy()}
|
||||||
|
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
||||||
|
onKeyDown={keyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
|
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.password")}</label>
|
||||||
|
<TextInputV2
|
||||||
|
type="password"
|
||||||
|
appearance="large"
|
||||||
|
class="!w-full self-stretch"
|
||||||
|
value={controller.formPassword()}
|
||||||
|
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||||
|
disabled={controller.formBusy()}
|
||||||
|
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
||||||
|
onKeyDown={keyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
||||||
|
{submitLabel()}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,20 +9,15 @@ import { SettingsKeybinds } from "../settings-keybinds"
|
|||||||
import { SettingsProvidersV2 } from "./providers"
|
import { SettingsProvidersV2 } from "./providers"
|
||||||
import { SettingsModelsV2 } from "./models"
|
import { SettingsModelsV2 } from "./models"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
import { SettingsServersV2 } from "./servers"
|
||||||
|
|
||||||
export const DialogSettings: Component = () => {
|
export const DialogSettings: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog size="x-large" class="settings-v2-dialog" data-component="settings-v2-dialog">
|
<Dialog size="x-large" variant="settings" class="settings-v2-dialog">
|
||||||
<TabsV2
|
<TabsV2 orientation="vertical" variant="settings" defaultValue="general" class="settings-v2">
|
||||||
orientation="vertical"
|
|
||||||
variant="settings"
|
|
||||||
defaultValue="general"
|
|
||||||
class="settings-v2"
|
|
||||||
data-component="settings-v2"
|
|
||||||
>
|
|
||||||
<TabsV2.List>
|
<TabsV2.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full">
|
<div class="flex flex-col justify-between h-full w-full">
|
||||||
<div class="flex flex-col gap-3 w-full">
|
<div class="flex flex-col gap-3 w-full">
|
||||||
@@ -44,6 +39,10 @@ export const DialogSettings: Component = () => {
|
|||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
<div class="flex flex-col gap-1.5 w-full">
|
||||||
|
<TabsV2.Trigger value="servers">
|
||||||
|
<Icon name="server" />
|
||||||
|
{language.t("status.popover.tab.servers")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
<TabsV2.Trigger value="providers">
|
<TabsV2.Trigger value="providers">
|
||||||
<Icon name="providers" />
|
<Icon name="providers" />
|
||||||
{language.t("settings.providers.title")}
|
{language.t("settings.providers.title")}
|
||||||
@@ -68,6 +67,9 @@ export const DialogSettings: Component = () => {
|
|||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds v2 />
|
<SettingsKeybinds v2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||||
|
<SettingsServersV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
<SettingsProvidersV2 />
|
<SettingsProvidersV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
@@ -8,13 +7,13 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
monoFontFamily,
|
monoFontFamily,
|
||||||
@@ -93,9 +92,7 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const updater = useUpdaterAction()
|
||||||
checking: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
const dir = createMemo(() => decode64(params.dir))
|
||||||
@@ -125,66 +122,14 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
}
|
}
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const check = () => {
|
|
||||||
if (!platform.checkUpdate) return
|
|
||||||
setStore("checking", true)
|
|
||||||
|
|
||||||
void platform
|
|
||||||
.checkUpdate()
|
|
||||||
.then((result) => {
|
|
||||||
if (!result.updateAvailable) {
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("settings.updates.toast.latest.title"),
|
|
||||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = platform.updateAndRestart
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: language.t("toast.update.action.installRestart"),
|
|
||||||
onClick: async () => {
|
|
||||||
await platform.updateAndRestart!()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: language.t("toast.update.action.notYet"),
|
|
||||||
onClick: "dismiss" as const,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: language.t("toast.update.action.notYet"),
|
|
||||||
onClick: "dismiss" as const,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
showToast({
|
|
||||||
persistent: true,
|
|
||||||
icon: "download",
|
|
||||||
title: language.t("toast.update.title"),
|
|
||||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
|
||||||
actions,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
})
|
|
||||||
.finally(() => setStore("checking", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const globalSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const globalSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
() =>
|
() =>
|
||||||
globalSdk.client.pty
|
serverSdk.client.pty
|
||||||
.shells()
|
.shells()
|
||||||
.then((res) => res.data ?? [])
|
.then((res) => res.data ?? [])
|
||||||
.catch(() => [] as ShellOption[]),
|
.catch(() => [] as ShellOption[]),
|
||||||
@@ -208,11 +153,11 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||||
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
|
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
|
||||||
|
|
||||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||||
const list = shells.latest
|
const list = shells.latest
|
||||||
const current = globalSync.data.config.shell
|
const current = serverSync.data.config.shell
|
||||||
|
|
||||||
const nameCounts = new Map<string, number>()
|
const nameCounts = new Map<string, number>()
|
||||||
for (const s of list) {
|
for (const s of list) {
|
||||||
@@ -347,7 +292,7 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
if (option.value === currentShell()) return
|
if (option.value === currentShell()) return
|
||||||
globalSync.updateConfig({ shell: option.value })
|
serverSync.updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
@@ -740,19 +685,6 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||||
|
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.updates.row.startup.title")}
|
|
||||||
description={language.t("settings.updates.row.startup.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-updates-startup">
|
|
||||||
<Switch
|
|
||||||
checked={settings.updates.startup()}
|
|
||||||
disabled={!platform.checkUpdate}
|
|
||||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.releaseNotes.title")}
|
title={language.t("settings.general.row.releaseNotes.title")}
|
||||||
description={language.t("settings.general.row.releaseNotes.description")}
|
description={language.t("settings.general.row.releaseNotes.description")}
|
||||||
@@ -769,10 +701,8 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
title={language.t("settings.updates.row.check.title")}
|
title={language.t("settings.updates.row.check.title")}
|
||||||
description={language.t("settings.updates.row.check.description")}
|
description={language.t("settings.updates.row.check.description")}
|
||||||
>
|
>
|
||||||
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
||||||
{store.checking
|
{language.t(updater.action().label)}
|
||||||
? language.t("settings.updates.action.checking")
|
|
||||||
: language.t("settings.updates.action.checkNow")}
|
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ const PROVIDER_ICON_SIZE = 16
|
|||||||
export const SettingsProvidersV2: Component = () => {
|
export const SettingsProvidersV2: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const globalSDK = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const globalSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
@@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
|
|||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
const isConfigCustom = (providerID: string) => {
|
const isConfigCustom = (providerID: string) => {
|
||||||
const provider = globalSync.data.config.provider?.[providerID]
|
const provider = serverSync.data.config.provider?.[providerID]
|
||||||
if (!provider) return false
|
if (!provider) return false
|
||||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||||
@@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
const before = globalSync.data.config.disabled_providers ?? []
|
const before = serverSync.data.config.disabled_providers ?? []
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
globalSync.set("config", "disabled_providers", next)
|
serverSync.set("config", "disabled_providers", next)
|
||||||
|
|
||||||
await globalSync
|
await serverSync
|
||||||
.updateConfig({ disabled_providers: next })
|
.updateConfig({ disabled_providers: next })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
globalSync.set("config", "disabled_providers", before)
|
serverSync.set("config", "disabled_providers", before)
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
@@ -108,14 +108,14 @@ export const SettingsProvidersV2: Component = () => {
|
|||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
if (isConfigCustom(providerID)) {
|
if (isConfigCustom(providerID)) {
|
||||||
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
|
||||||
await disableProvider(providerID, name)
|
await disableProvider(providerID, name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await globalSDK.client.auth
|
await serverSdk.client.auth
|
||||||
.remove({ providerID })
|
.remove({ providerID })
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await globalSDK.client.global.dispose()
|
await serverSdk.client.global.dispose()
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: "circle-check",
|
icon: "circle-check",
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import { type Component, For, Show, createMemo } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||||
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
|
import { useServerManagementController } from "../dialog-select-server"
|
||||||
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { isWslServer, useFilteredWslServers, WslAddServerButton, WslServerSettings } from "@/wsl/settings"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
export const SettingsServersV2: Component = () => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const controller = useServerManagementController()
|
||||||
|
const [store, setStore] = createStore({ filter: "" })
|
||||||
|
const wslServers = useFilteredWslServers(() => store.filter)
|
||||||
|
|
||||||
|
const showSearch = createMemo(
|
||||||
|
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = createMemo(() => {
|
||||||
|
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||||
|
const query = store.filter.trim()
|
||||||
|
if (!query) return items
|
||||||
|
return fuzzysort
|
||||||
|
.go(query, items, {
|
||||||
|
keys: [(item) => serverName(item), (item) => item.http.url],
|
||||||
|
})
|
||||||
|
.map((result) => result.obj)
|
||||||
|
})
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
dialog.push(() => <DialogServerV2 mode="add" />)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (server: ServerConnection.Http) => {
|
||||||
|
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
class="settings-v2-tab-header settings-v2-servers-header"
|
||||||
|
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
||||||
|
>
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||||
|
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
|
||||||
|
{language.t("dialog.server.add.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
<WslAddServerButton />
|
||||||
|
</div>
|
||||||
|
<Show when={showSearch()}>
|
||||||
|
<div class="settings-v2-tab-search">
|
||||||
|
<TextInputV2
|
||||||
|
type="search"
|
||||||
|
appearance="base"
|
||||||
|
value={store.filter}
|
||||||
|
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||||
|
placeholder={language.t("dialog.server.search.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t("dialog.server.search.placeholder")}
|
||||||
|
/>
|
||||||
|
<Show when={store.filter}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
class="settings-v2-tab-search-clear"
|
||||||
|
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={() => setStore("filter", "")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body settings-v2-servers">
|
||||||
|
<Show
|
||||||
|
when={filtered().length > 0 || wslServers().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="settings-v2-servers-status">
|
||||||
|
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
|
||||||
|
<Show when={store.filter}>
|
||||||
|
<span class="settings-v2-servers-status-filter">"{store.filter}"</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsListV2>
|
||||||
|
<WslServerSettings controller={controller} servers={wslServers} />
|
||||||
|
<For each={filtered()}>
|
||||||
|
{(item) => {
|
||||||
|
const key = ServerConnection.key(item)
|
||||||
|
const health = () => controller.status()[key]
|
||||||
|
const isDefault = () => controller.defaultKey() === key
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-servers-row">
|
||||||
|
<div class="settings-v2-servers-lead">
|
||||||
|
<ServerHealthIndicator health={health()} />
|
||||||
|
<div class="settings-v2-servers-copy">
|
||||||
|
<span class="settings-v2-servers-name">{serverName(item)}</span>
|
||||||
|
<span class="settings-v2-servers-meta">
|
||||||
|
<Show when={health()?.version}>v{health()?.version}</Show>
|
||||||
|
<Show when={health()?.version && item.type === "http"}> • </Show>
|
||||||
|
<Show
|
||||||
|
when={item.type === "http" && item.http.username}
|
||||||
|
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
|
||||||
|
>
|
||||||
|
{item.http.username}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-servers-actions">
|
||||||
|
<Show when={controller.canDefault() && isDefault()}>
|
||||||
|
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||||
|
</Show>
|
||||||
|
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
@import "@opencode-ai/ui/v2/text-input-v2.css";
|
@import "@opencode-ai/ui/v2/text-input-v2.css";
|
||||||
@import "@opencode-ai/ui/v2/button-v2.css";
|
@import "@opencode-ai/ui/v2/button-v2.css";
|
||||||
|
|
||||||
[data-component="settings-v2"] {
|
[data-component="tabs-v2"][data-variant="settings"] {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="settings-v2-dialog"] [data-slot="dialog-body"] {
|
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-container"] {
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-body"] {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -81,10 +85,6 @@
|
|||||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="dialog-container"]:has(.settings-v2-dialog) {
|
|
||||||
box-shadow: var(--v2-elevation-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="settings-v2-row"] {
|
[data-component="settings-v2-row"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -153,12 +153,12 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="settings-v2-dialog"] [data-component="select-v2-root"] {
|
[data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="settings-v2-dialog"] [data-component="button-v2"] {
|
[data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -511,3 +511,144 @@
|
|||||||
.settings-v2-shortcuts-status-filter {
|
.settings-v2-shortcuts-status-filter {
|
||||||
color: var(--v2-text-text-base);
|
color: var(--v2-text-text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-body.settings-v2-servers {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-header.settings-v2-servers-header {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-header .settings-v2-tab-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-header.settings-v2-servers-header.settings-v2-tab-header--stacked {
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers [data-component="settings-v2-list"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-row:not(:last-child) {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-lead {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding-block: 48px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-servers-status-filter {
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||||
|
width: 480px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-content"] {
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-footer"] {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-server-dialog-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-server-dialog-error {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-state-fg-danger);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
@@ -11,15 +10,12 @@ import { createStore } from "solid-js/store"
|
|||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth } from "@/utils/server-health"
|
||||||
import { useQueryOptions } from "@/context/server-sync"
|
import { useGlobal } from "@/context/global"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useServers } from "@/context/servers"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
|
|
||||||
const pollMs = 10_000
|
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
@@ -59,7 +55,7 @@ const useDefaultServerKey = (
|
|||||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||||
) => {
|
) => {
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
url: undefined as string | undefined,
|
key: undefined as ServerConnection.Key | undefined,
|
||||||
tick: 0,
|
tick: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,7 +64,7 @@ const useDefaultServerKey = (
|
|||||||
let dead = false
|
let dead = false
|
||||||
const result = get?.()
|
const result = get?.()
|
||||||
if (!result) {
|
if (!result) {
|
||||||
setState("url", undefined)
|
setState("key", undefined)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
dead = true
|
dead = true
|
||||||
})
|
})
|
||||||
@@ -78,7 +74,7 @@ const useDefaultServerKey = (
|
|||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
void result.then((next) => {
|
void result.then((next) => {
|
||||||
if (dead) return
|
if (dead) return
|
||||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
setState("key", next ?? undefined)
|
||||||
})
|
})
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
dead = true
|
dead = true
|
||||||
@@ -86,7 +82,7 @@ const useDefaultServerKey = (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setState("url", normalizeServerUrl(result))
|
setState("key", ServerConnection.Key.make(result))
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
dead = true
|
dead = true
|
||||||
})
|
})
|
||||||
@@ -94,45 +90,12 @@ const useDefaultServerKey = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
key: () => {
|
key: () => {
|
||||||
const u = state.url
|
return state.key
|
||||||
if (!u) return
|
|
||||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
|
||||||
},
|
},
|
||||||
refresh: () => setState("tick", (value) => value + 1),
|
refresh: () => setState("tick", (value) => value + 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useMcpToggleMutation = () => {
|
|
||||||
const sync = useSync()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const language = useLanguage()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const queryOptions = useQueryOptions()
|
|
||||||
|
|
||||||
return useMutation(() => ({
|
|
||||||
mutationFn: async (name: string) => {
|
|
||||||
const status = sync.data.mcp[name]
|
|
||||||
if (status?.status === "connected") {
|
|
||||||
await sdk.client.mcp.disconnect({ name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (status?.status === "needs_auth") {
|
|
||||||
await sdk.client.mcp.auth.authenticate({ name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sdk.client.mcp.connect({ name })
|
|
||||||
},
|
|
||||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
|
||||||
onError: (err) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerStatusState = {
|
type ServerStatusState = {
|
||||||
servers: () => ServerStatusItem[]
|
servers: () => ServerStatusItem[]
|
||||||
defaultKey: () => ServerConnection.Key | undefined
|
defaultKey: () => ServerConnection.Key | undefined
|
||||||
@@ -153,13 +116,12 @@ type ServerStatusItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopoverServerBody() {
|
export function StatusPopoverServerBody() {
|
||||||
const servers = useServers()
|
const global = useGlobal()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
let dialogRun = 0
|
let dialogRun = 0
|
||||||
let dialogDead = false
|
let dialogDead = false
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -167,7 +129,7 @@ export function StatusPopoverServerBody() {
|
|||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
|
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const serverItems = createMemo(() =>
|
const serverItems = createMemo(() =>
|
||||||
sortedServers().map((conn) => {
|
sortedServers().map((conn) => {
|
||||||
@@ -175,8 +137,8 @@ export function StatusPopoverServerBody() {
|
|||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
conn,
|
conn,
|
||||||
health: servers.health[key],
|
health: global.servers.health[key],
|
||||||
blocked: servers.health[key]?.healthy === false,
|
blocked: global.servers.health[key]?.healthy === false,
|
||||||
active: !!server.current && key === ServerConnection.key(server.current),
|
active: !!server.current && key === ServerConnection.key(server.current),
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
navigate("/")
|
navigate("/")
|
||||||
@@ -288,12 +250,13 @@ function ServerStatusList(props: { state: ServerStatusState }) {
|
|||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const servers = useServers()
|
const global = useGlobal()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -313,8 +276,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
dialogDead = true
|
dialogDead = true
|
||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
|
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
||||||
const toggleMcp = useMcpToggleMutation()
|
const toggleMcp = useMcpToggle()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
@@ -333,15 +296,17 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
aria-label={language.t("status.popover.ariaLabel")}
|
aria-label={language.t("status.popover.ariaLabel")}
|
||||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||||
data-component="tabs"
|
data-component="tabs"
|
||||||
data-active="servers"
|
data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
|
||||||
defaultValue="servers"
|
defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
|
||||||
variant="alt"
|
variant="alt"
|
||||||
>
|
>
|
||||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||||
|
{!settings.general.newLayoutDesigns() && (
|
||||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||||
{servers.list().length > 0 ? `${servers.list().length} ` : ""}
|
{global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
|
||||||
{language.t("status.popover.tab.servers")}
|
{language.t("status.popover.tab.servers")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
)}
|
||||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||||
{language.t("status.popover.tab.mcp")}
|
{language.t("status.popover.tab.mcp")}
|
||||||
@@ -356,13 +321,14 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
|
{!settings.general.newLayoutDesigns() && (
|
||||||
<Tabs.Content value="servers">
|
<Tabs.Content value="servers">
|
||||||
<div class="flex flex-col px-2 pb-2">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
<For each={sortedServers()}>
|
<For each={sortedServers()}>
|
||||||
{(s) => {
|
{(s) => {
|
||||||
const key = ServerConnection.key(s)
|
const key = ServerConnection.key(s)
|
||||||
const blocked = () => servers.health[key]?.healthy === false
|
const blocked = () => global.servers.health[key]?.healthy === false
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -378,11 +344,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
queueMicrotask(() => server.setActive(key))
|
queueMicrotask(() => server.setActive(key))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerHealthIndicator health={servers.health[key]} />
|
<ServerHealthIndicator health={global.servers.health[key]} />
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={s}
|
conn={s}
|
||||||
dimmed={blocked()}
|
dimmed={blocked()}
|
||||||
status={servers.health[key]}
|
status={global.servers.health[key]}
|
||||||
class="flex items-center gap-2 w-full min-w-0"
|
class="flex items-center gap-2 w-full min-w-0"
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
versionClass="text-12-regular text-text-weak truncate"
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
@@ -420,6 +386,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs.Content value="mcp">
|
<Tabs.Content value="mcp">
|
||||||
<div class="flex flex-col px-2 pb-2">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useServers } from "@/context/servers"
|
import { useGlobal } from "@/context/global"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||||
@@ -15,10 +15,10 @@ const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ def
|
|||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const servers = useServers()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
|
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
|
||||||
const mcpIssue = createMemo(() => {
|
const mcpIssue = createMemo(() => {
|
||||||
const mcp = Object.values(sync.data.mcp ?? {})
|
const mcp = Object.values(sync.data.mcp ?? {})
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
||||||
@@ -26,8 +26,8 @@ export function StatusPopover() {
|
|||||||
if (failed) return "critical" as const
|
if (failed) return "critical" as const
|
||||||
if (warn) return "warning" as const
|
if (warn) return "warning" as const
|
||||||
})
|
})
|
||||||
const serverHealthy = () => servers.health[server.key]?.healthy === true
|
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
|
||||||
const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue())
|
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -82,10 +82,10 @@ export function StatusPopoverV2(props: { scope?: "server" }) {
|
|||||||
function DirectoryStatusPopover() {
|
function DirectoryStatusPopover() {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const servers = useServers()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
|
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
|
||||||
const mcpIssue = createMemo(() => {
|
const mcpIssue = createMemo(() => {
|
||||||
const mcp = Object.values(sync.data.mcp ?? {})
|
const mcp = Object.values(sync.data.mcp ?? {})
|
||||||
@@ -116,9 +116,9 @@ function DirectoryStatusPopover() {
|
|||||||
function ServerStatusPopover() {
|
function ServerStatusPopover() {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const servers = useServers()
|
const global = useGlobal()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const state = createMemo<StatusPopoverState>(() => ({
|
const state = createMemo<StatusPopoverState>(() => ({
|
||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: serverHealth() !== undefined,
|
ready: serverHealth() !== undefined,
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
|
import {
|
||||||
import { createStore, produce } from "solid-js/store"
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Match,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
startTransition,
|
||||||
|
Switch,
|
||||||
|
untrack,
|
||||||
|
} from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -9,7 +21,7 @@ import { useTheme } from "@opencode-ai/ui/theme/context"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
|
||||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
|
import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -17,18 +29,16 @@ import { useSettings } from "@/context/settings"
|
|||||||
import { WindowsAppMenu } from "./windows-app-menu"
|
import { WindowsAppMenu } from "./windows-app-menu"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { decodeDirectory } from "@/pages/directory-layout"
|
|
||||||
import { iife } from "@opencode-ai/core/util/iife"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import {
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||||
readSessionTabsRemovedDetail,
|
import { useGlobal } from "@/context/global"
|
||||||
SESSION_TABS_REMOVED_EVENT,
|
import { decode64 } from "@/utils/base64"
|
||||||
type SessionTabsRemovedDetail,
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
} from "@/components/titlebar-session-events"
|
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
@@ -56,8 +66,6 @@ const v2TitlebarHeight = 36
|
|||||||
const minTitlebarZoom = 0.25
|
const minTitlebarZoom = 0.25
|
||||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||||
|
|
||||||
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
|
|
||||||
|
|
||||||
export type TitlebarUpdate = {
|
export type TitlebarUpdate = {
|
||||||
version: () => string | undefined
|
version: () => string | undefined
|
||||||
installing: () => boolean
|
installing: () => boolean
|
||||||
@@ -71,6 +79,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const server = useServer()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -121,10 +130,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
||||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||||
|
const installing = props.update?.installing() ?? false
|
||||||
const version = props.update?.version()
|
const version = props.update?.version()
|
||||||
return {
|
return {
|
||||||
visible: version !== undefined,
|
visible: version !== undefined || installing,
|
||||||
installing: props.update?.installing() ?? false,
|
installing,
|
||||||
label: "Update",
|
label: "Update",
|
||||||
ariaLabel: language.t("toast.update.action.installRestart"),
|
ariaLabel: language.t("toast.update.action.installRestart"),
|
||||||
title: version ? `Update ${version}` : undefined,
|
title: version ? `Update ${version}` : undefined,
|
||||||
@@ -219,9 +229,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
classList={{
|
classList={{
|
||||||
"shrink-0 relative overflow-hidden flex flex-row": true,
|
"shrink-0 relative flex flex-row": true,
|
||||||
"h-9 bg-v2-background-bg-deep": useV2Titlebar(),
|
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
|
||||||
"h-10 bg-background-base": !useV2Titlebar(),
|
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"min-height": minHeight(),
|
"min-height": minHeight(),
|
||||||
@@ -242,6 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const homeMatch = useMatch(() => "/")
|
const homeMatch = useMatch(() => "/")
|
||||||
|
const layout = useLayout()
|
||||||
|
|
||||||
const newSessionHref = () => {
|
const newSessionHref = () => {
|
||||||
if (params.dir) return `/${params.dir}/session`
|
if (params.dir) return `/${params.dir}/session`
|
||||||
@@ -252,77 +263,64 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
return `/${base64Encode(project.worktree)}/session`
|
return `/${base64Encode(project.worktree)}/session`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = { dir: string; sessionId: string; href: string }
|
const tabs = useTabs()
|
||||||
|
const tabsStore = tabs.store
|
||||||
const [tabsStore, tabsStoreActions] = iife(() => {
|
const tabsStoreActions = tabs
|
||||||
const [store, setStore] = createStore<Tab[]>(
|
const navigateTab = (tab: Tab) => {
|
||||||
iife(() => {
|
const href = tabHref(tab)
|
||||||
if (!params.dir || !params.id) return []
|
if (tab.server === server.key) {
|
||||||
return [
|
navigate(href)
|
||||||
{
|
return
|
||||||
dir: decodeDirectory(params.dir) ?? "",
|
}
|
||||||
sessionId: params.id,
|
|
||||||
href: makeSessionHref(params.dir, params.id),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
addTab: (tab: Tab) => {
|
|
||||||
setStore(
|
|
||||||
produce((tabs) => {
|
|
||||||
if (tabs.some((t) => t.href === tab.href)) return
|
|
||||||
|
|
||||||
tabs.push(tab)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
removeTab: (href: string) => {
|
|
||||||
void startTransition(() => {
|
void startTransition(() => {
|
||||||
setStore(
|
server.setActive(tab.server)
|
||||||
produce((tabs) => {
|
navigate(href)
|
||||||
const index = tabs.findIndex((t) => t.href === href)
|
|
||||||
if (index === -1) return
|
|
||||||
tabs.splice(index, 1)
|
|
||||||
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
|
|
||||||
if (nextTab) navigate(nextTab.href)
|
|
||||||
else navigate("/")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
|
||||||
void startTransition(() => {
|
|
||||||
setStore(
|
|
||||||
produce((tabs) => {
|
|
||||||
const sessionIDs = new Set(input.sessionIDs)
|
|
||||||
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
|
|
||||||
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
|
|
||||||
const removedCurrent =
|
|
||||||
currentIndex !== -1 &&
|
|
||||||
tabs[currentIndex]?.dir === input.directory &&
|
|
||||||
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
|
|
||||||
|
|
||||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
|
||||||
const tab = tabs[i]
|
|
||||||
if (!tab) continue
|
|
||||||
if (tab.dir !== input.directory) continue
|
|
||||||
if (!sessionIDs.has(tab.sessionId)) continue
|
|
||||||
tabs.splice(i, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!removedCurrent) return
|
const matchRoute = (route: LayoutRoute) => {
|
||||||
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
|
if (route.type === "home") return
|
||||||
if (nextTab) navigate(nextTab.href)
|
if (route.type === "draft") {
|
||||||
else navigate("/")
|
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||||
}),
|
}
|
||||||
|
if (route.type === "session") {
|
||||||
|
const main = tabsStore.find(
|
||||||
|
(item) =>
|
||||||
|
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||||
)
|
)
|
||||||
})
|
if (main) return main
|
||||||
},
|
const sync = serverSync.createDirSyncContext(route.dir)
|
||||||
|
const session = sync.session.get(route.sessionId)
|
||||||
|
if (session?.parentID) {
|
||||||
|
const parentID = session.parentID
|
||||||
|
const parent = tabsStore.find(
|
||||||
|
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
||||||
|
)
|
||||||
|
if (parent) return parent
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [store, actions]
|
const currentTab = () => matchRoute(layout.route())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const route = layout.route()
|
||||||
|
if (!tabs.ready()) return
|
||||||
|
const tab = currentTab()
|
||||||
|
if (tab) return
|
||||||
|
|
||||||
|
if (route.type === "session") {
|
||||||
|
const sync = serverSync.createDirSyncContext(route.dir)
|
||||||
|
const session = sync.session.get(route.sessionId)
|
||||||
|
if (!session) return
|
||||||
|
const sessionId = session.parentID ?? session.id
|
||||||
|
const next = {
|
||||||
|
server: route.server ?? server.key,
|
||||||
|
dirBase64: route.dirBase64,
|
||||||
|
sessionId,
|
||||||
|
}
|
||||||
|
tabsStoreActions.addSessionTab(next)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
|
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
|
||||||
@@ -331,49 +329,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
tabsStoreActions.removeSessions(detail)
|
tabsStoreActions.removeSessions(detail)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const params = useParams()
|
|
||||||
if (!(params.dir && params.id)) return
|
|
||||||
|
|
||||||
tabsStoreActions.addTab({
|
|
||||||
dir: decodeDirectory(params.dir) ?? "",
|
|
||||||
sessionId: params.id,
|
|
||||||
href: makeSessionHref(params.dir, params.id),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const projects = createMemo(() => layout.projects.list())
|
|
||||||
const projectByID = createMemo(
|
|
||||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentSessionTab = () => {
|
|
||||||
if (!params.dir || !params.id) return
|
|
||||||
const href = makeSessionHref(params.dir, params.id)
|
|
||||||
return tabsStore.find((tab) => tab.href === href)
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeCurrentSessionTab = () => {
|
|
||||||
const tab = currentSessionTab()
|
|
||||||
if (!tab) return false
|
|
||||||
tabsStoreActions.removeTab(tab.href)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeNewSessionTab = () => {
|
|
||||||
if (!(params.dir && !params.id)) return false
|
|
||||||
const last = tabsStore[tabsStore.length - 1]
|
|
||||||
if (last) navigate(last.href)
|
|
||||||
else navigate("/")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openNewTab = () => navigate(newSessionHref())
|
const openNewTab = () => navigate(newSessionHref())
|
||||||
|
|
||||||
const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab()
|
command.register("tabs", () => {
|
||||||
|
const current = currentTab()
|
||||||
|
|
||||||
command.register(() => {
|
return [
|
||||||
const commands = [
|
|
||||||
{
|
{
|
||||||
id: "tab.new",
|
id: "tab.new",
|
||||||
category: "tab",
|
category: "tab",
|
||||||
@@ -382,13 +343,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
onSelect: openNewTab,
|
onSelect: openNewTab,
|
||||||
},
|
},
|
||||||
{
|
current && {
|
||||||
id: "tab.close",
|
id: "tab.close",
|
||||||
category: "tab",
|
category: "tab",
|
||||||
title: language.t("command.tab.close"),
|
title: language.t("command.tab.close"),
|
||||||
keybind: "mod+w",
|
keybind: "mod+w",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
onSelect: closeActiveTab,
|
onSelect: () => {
|
||||||
|
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `tab.prev`,
|
id: `tab.prev`,
|
||||||
@@ -397,14 +360,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
keybind: `mod+option+ArrowLeft`,
|
keybind: `mod+option+ArrowLeft`,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
|
|
||||||
index -= 1
|
index -= 1
|
||||||
if (index === -1) index = tabsStore.length - 1
|
if (index === -1) index = tabsStore.length - 1
|
||||||
|
|
||||||
const next = tabsStore[index]
|
const next = tabsStore[index]
|
||||||
if (next) navigate(next.href)
|
if (next) navigateTab(next)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -414,14 +377,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
keybind: `mod+option+ArrowRight`,
|
keybind: `mod+option+ArrowRight`,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
|
let index = tabsStore.findIndex((tab) => tab === currentTab())
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
|
|
||||||
index += 1
|
index += 1
|
||||||
if (index === tabsStore.length) index = 0
|
if (index === tabsStore.length) index = 0
|
||||||
|
|
||||||
const next = tabsStore[index]
|
const next = tabsStore[index]
|
||||||
if (next) navigate(next.href)
|
if (next) navigateTab(next)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...Array.from({ length: 9 }, (_, i) => {
|
...Array.from({ length: 9 }, (_, i) => {
|
||||||
@@ -436,31 +399,23 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const tab = tabsStore[index]
|
const tab = tabsStore[index]
|
||||||
if (tab) navigate(tab.href)
|
if (tab) navigateTab(tab)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
]
|
].filter((v) => v !== undefined)
|
||||||
|
|
||||||
return commands
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabsEnriched = iife(() => {
|
const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false)
|
||||||
const base = mapArray(
|
let tabScrollRef!: HTMLDivElement
|
||||||
() => tabsStore,
|
|
||||||
(tab) => {
|
|
||||||
const sync = serverSync.createDirSyncContext(tab.dir)
|
|
||||||
const session = sync.session.get(tab.sessionId)
|
|
||||||
return session ? { ...tab, info: session } : null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => base().flatMap((s) => (s ? [s] : []))
|
function refreshTabsAreOverflowing() {
|
||||||
})
|
setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 pt-2"
|
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
|
||||||
classList={{
|
classList={{
|
||||||
"pl-2": mac(),
|
"pl-2": mac(),
|
||||||
"pl-4": !mac(),
|
"pl-4": !mac(),
|
||||||
@@ -475,34 +430,99 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
size="large"
|
size="large"
|
||||||
as="a"
|
as="a"
|
||||||
href="/"
|
href="/"
|
||||||
class="!w-9"
|
class="!w-9 shrink-0"
|
||||||
icon={<IconV2 name="grid-plus" />}
|
icon={<IconV2 name="grid-plus" />}
|
||||||
state={!!homeMatch() ? "pressed" : undefined}
|
state={!!homeMatch() ? "pressed" : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
|
<div
|
||||||
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
|
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
|
||||||
<For each={tabsEnriched()}>
|
ref={tabScrollRef}
|
||||||
{(tab, i) => (
|
>
|
||||||
<>
|
<div class="flex min-w-0 flex-row items-center gap-1.5">
|
||||||
{i() !== 0 && (
|
<For each={tabsStore}>
|
||||||
|
{(tab, i) => {
|
||||||
|
let ref!: HTMLDivElement
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
refreshTabsAreOverflowing()
|
||||||
|
})
|
||||||
|
|
||||||
|
const divider = () =>
|
||||||
|
i() !== 0 && (
|
||||||
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||||
)}
|
)
|
||||||
<TabNavItem
|
|
||||||
href={tab.href}
|
if (tab.type === "draft") {
|
||||||
title={tab.info.title}
|
return (
|
||||||
project={projectForSession(tab.info, projects(), projectByID())}
|
<>
|
||||||
directory={tab.dir}
|
{divider()}
|
||||||
sessionId={tab.info.id}
|
<DraftTabItem
|
||||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
ref={ref}
|
||||||
|
href={tabHref(tab)}
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
active={currentTab() === tab}
|
||||||
|
onNavigate={() => {
|
||||||
|
navigateTab(tab)
|
||||||
|
ref.scrollIntoView({ behavior: "instant" })
|
||||||
|
}}
|
||||||
|
onClose={() => tabsStoreActions.removeTab(i())}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{divider()}
|
||||||
|
<TabNavItem
|
||||||
|
ref={ref}
|
||||||
|
href={tabHref(tab)}
|
||||||
|
server={tab.server}
|
||||||
|
directory={decode64(tab.dirBase64)!}
|
||||||
|
sessionId={tab.sessionId}
|
||||||
|
onNavigate={() => {
|
||||||
|
navigateTab(tab)
|
||||||
|
|
||||||
|
ref.scrollIntoView({ behavior: "instant" })
|
||||||
|
}}
|
||||||
|
onClose={() => tabsStoreActions.removeTab(i())}
|
||||||
|
active={currentTab() === tab}
|
||||||
|
activeServer={tab.server === server.key}
|
||||||
|
forceTruncate={tabsAreOverflowing()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={creating() && params.dir}>
|
||||||
|
{(_) => {
|
||||||
|
let ref!: HTMLDivElement
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
ref.scrollIntoView({ behavior: "instant" })
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
|
||||||
|
<NewSessionTabItem
|
||||||
|
ref={ref}
|
||||||
|
href={`/${params.dir}/session`}
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
onClose={() => {
|
||||||
|
const tab = tabsStore.at(-1)
|
||||||
|
if (tab) navigateTab(tab)
|
||||||
|
else navigate("/")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
</div>
|
||||||
when={creating() && params.dir}
|
<Show when={!(creating() && params.dir)}>
|
||||||
fallback={
|
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
@@ -513,16 +533,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
href={newSessionHref()}
|
href={newSessionHref()}
|
||||||
aria-label={language.t("command.session.new")}
|
aria-label={language.t("command.session.new")}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<NewSessionTabItem
|
|
||||||
href={`/${params.dir}/session`}
|
|
||||||
title={language.t("command.session.new")}
|
|
||||||
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
<div class="min-w-0 flex-1" />
|
<div class="flex-1" />
|
||||||
</div>
|
|
||||||
<TitlebarV2Right state={v2RightState()} />
|
<TitlebarV2Right state={v2RightState()} />
|
||||||
<Show when={windows() && !electronWindows()}>
|
<Show when={windows() && !electronWindows()}>
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||||
@@ -705,66 +717,123 @@ type TitlebarV2RightState = {
|
|||||||
|
|
||||||
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
|
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
|
||||||
return (
|
return (
|
||||||
<div class="flex shrink-0 items-center justify-end gap-0">
|
<div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
|
||||||
<TitlebarUpdatePill state={props.state.update} />
|
<Show when={props.state.update.visible}>
|
||||||
|
<TitlebarUpdateIconButton state={props.state.update} />
|
||||||
|
</Show>
|
||||||
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
|
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) {
|
function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||||
return (
|
return (
|
||||||
<Show when={props.state.visible}>
|
<div class="relative isolate mr-3 size-5 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-layer-03)] px-2.5 text-[11px] font-[530] leading-4 tracking-[0.05px] text-[var(--v2-text-text-base)] disabled:opacity-60"
|
class="group absolute right-0 top-0 z-10 flex h-5 w-5 items-center justify-end overflow-hidden rounded-full bg-v2-icon-icon-accent/20 text-v2-icon-icon-accent transition-[width,background-color] duration-150 ease-out hover:z-30 hover:w-[68px] hover:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:z-30 focus-visible:w-[68px] focus-visible:bg-[color-mix(in_srgb,var(--v2-icon-icon-accent)_20%,var(--v2-background-bg-deep))] focus-visible:outline-none disabled:opacity-60 motion-reduce:transition-none"
|
||||||
onClick={props.state.onInstall}
|
onClick={props.state.onInstall}
|
||||||
disabled={props.state.installing}
|
disabled={props.state.installing}
|
||||||
|
aria-busy={props.state.installing}
|
||||||
aria-label={props.state.ariaLabel}
|
aria-label={props.state.ariaLabel}
|
||||||
title={props.state.title}
|
|
||||||
>
|
>
|
||||||
{props.state.label}
|
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-visible:opacity-100 group-focus-visible:translate-x-0 motion-reduce:translate-x-0">
|
||||||
</button>
|
Update
|
||||||
|
</span>
|
||||||
|
<span class="flex size-5 shrink-0 items-center justify-center">
|
||||||
|
<Show
|
||||||
|
when={!props.state.installing}
|
||||||
|
fallback={<span data-slot="titlebar-update-loader" aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M7 11V3M3.5 7.63128L7 11L10.5 7.63128" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
</Show>
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabNavItem(props: {
|
function TabNavItem(props: {
|
||||||
|
ref?: HTMLDivElement
|
||||||
href: string
|
href: string
|
||||||
title: string
|
server: ServerConnection.Key
|
||||||
project?: LocalProject
|
|
||||||
directory: string
|
directory: string
|
||||||
sessionId: string
|
sessionId?: string
|
||||||
hideClose?: boolean
|
hideClose?: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onNavigate: () => void
|
||||||
|
active?: boolean
|
||||||
|
activeServer: boolean
|
||||||
|
forceTruncate?: boolean
|
||||||
}) {
|
}) {
|
||||||
const match = useMatch(() => props.href)
|
|
||||||
const isActive = () => !!match()
|
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverCtx = createMemo(() => {
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||||
|
if (conn) return global.createServerCtx(conn)
|
||||||
|
})
|
||||||
|
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
|
||||||
|
|
||||||
|
const [session] = createResource(
|
||||||
|
() => {
|
||||||
|
const ctx = dirSyncCtx()
|
||||||
|
if (!ctx || !props.sessionId) return
|
||||||
|
return [props.sessionId, ctx] as const
|
||||||
|
},
|
||||||
|
async ([sessionId, dirSyncCtx]) => {
|
||||||
|
await dirSyncCtx.session.sync(sessionId).catch(() => {})
|
||||||
|
return dirSyncCtx.session.get(sessionId)
|
||||||
|
},
|
||||||
|
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={props.ref}
|
||||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||||
data-active={isActive()}
|
data-active={props.active}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Show when={session.latest}>
|
||||||
|
{(session) => {
|
||||||
|
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||||
|
|
||||||
|
return (
|
||||||
<a
|
<a
|
||||||
href={props.href}
|
href={props.href}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||||
>
|
>
|
||||||
<span data-slot="project-avatar-slot">
|
<span data-slot="project-avatar-slot">
|
||||||
<ProjectTabAvatar project={props.project} directory={props.directory} sessionId={props.sessionId} />
|
<ProjectTabAvatar
|
||||||
|
project={project()}
|
||||||
|
directory={props.directory}
|
||||||
|
sessionId={session().id}
|
||||||
|
activeServer={props.activeServer}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="min-w-0 flex-1">{props.title}</span>
|
<span class="min-w-0 flex-1">{session().title}</span>
|
||||||
</a>
|
</a>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
<div
|
||||||
|
class="absolute not-group-hover:not-group-data-[active=true]:not-data-[truncate=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 data-[truncate=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2"
|
||||||
|
data-truncate={props.forceTruncate}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||||
style={{
|
style={{
|
||||||
@@ -784,10 +853,15 @@ function TabNavItem(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) {
|
function ProjectTabAvatar(props: {
|
||||||
|
project?: LocalProject
|
||||||
|
directory: string
|
||||||
|
sessionId: string
|
||||||
|
activeServer: boolean
|
||||||
|
}) {
|
||||||
const directory = () => props.directory
|
const directory = () => props.directory
|
||||||
const sessionId = () => props.sessionId
|
const sessionId = () => props.sessionId
|
||||||
const state = useSessionTabAvatarState(directory, sessionId)
|
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||||
return (
|
return (
|
||||||
<ProjectAvatar
|
<ProjectAvatar
|
||||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||||
@@ -799,7 +873,14 @@ function ProjectTabAvatar(props: { project?: LocalProject; directory: string; se
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
|
function DraftTabItem(props: {
|
||||||
|
ref?: HTMLDivElement
|
||||||
|
href: string
|
||||||
|
title: string
|
||||||
|
active?: boolean
|
||||||
|
onNavigate: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -807,7 +888,54 @@ function NewSessionTabItem(props: { href: string; title: string; onClose: () =>
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
ref={props.ref}
|
||||||
|
data-active={props.active}
|
||||||
|
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.button !== 1) return
|
||||||
|
closeTab(event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={props.href}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onNavigate()
|
||||||
|
}}
|
||||||
|
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
|
||||||
|
>
|
||||||
|
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
|
||||||
|
<IconV2 name="edit" />
|
||||||
|
</span>
|
||||||
|
<span class="truncate leading-5">{props.title}</span>
|
||||||
|
</a>
|
||||||
|
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
|
||||||
|
<IconButtonV2
|
||||||
|
size="small"
|
||||||
|
variant="ghost-muted"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={closeTab}
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
aria-label="Close tab"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
|
||||||
|
const closeTab = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={props.ref}
|
||||||
|
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 1) return
|
if (event.button !== 1) return
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { updaterAction } from "./updater-action"
|
||||||
|
|
||||||
|
describe("updaterAction", () => {
|
||||||
|
test("disables update actions when the platform has no updater", () => {
|
||||||
|
expect(updaterAction(undefined)).toEqual({ label: "settings.updates.action.checkNow" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("projects updater transitions into one settings action", () => {
|
||||||
|
expect(updaterAction({ status: "idle" })).toEqual({
|
||||||
|
label: "settings.updates.action.checkNow",
|
||||||
|
run: "check",
|
||||||
|
})
|
||||||
|
expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
|
||||||
|
expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
|
||||||
|
label: "settings.updates.action.downloading",
|
||||||
|
})
|
||||||
|
expect(updaterAction({ status: "ready", version: "2.0.0" })).toEqual({
|
||||||
|
label: "toast.update.action.installRestart",
|
||||||
|
run: "install",
|
||||||
|
})
|
||||||
|
expect(updaterAction({ status: "installing", version: "2.0.0" })).toEqual({
|
||||||
|
label: "settings.updates.action.installing",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import type { UpdaterState } from "@/updater"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
||||||
|
export function updaterAction(state: UpdaterState | undefined) {
|
||||||
|
if (!state) return { label: "settings.updates.action.checkNow" as const }
|
||||||
|
switch (state.status) {
|
||||||
|
case "checking":
|
||||||
|
return { label: "settings.updates.action.checking" as const }
|
||||||
|
case "downloading":
|
||||||
|
return { label: "settings.updates.action.downloading" as const }
|
||||||
|
case "ready":
|
||||||
|
return { label: "toast.update.action.installRestart" as const, run: "install" as const }
|
||||||
|
case "installing":
|
||||||
|
return { label: "settings.updates.action.installing" as const }
|
||||||
|
case "disabled":
|
||||||
|
return { label: "settings.updates.action.checkNow" as const }
|
||||||
|
default:
|
||||||
|
return { label: "settings.updates.action.checkNow" as const, run: "check" as const }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdaterAction() {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const language = useLanguage()
|
||||||
|
const action = createMemo(() => updaterAction(platform.updater?.state()))
|
||||||
|
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
async run() {
|
||||||
|
const run = action().run
|
||||||
|
if (run === "install") return platform.updater?.install()
|
||||||
|
if (run !== "check") return
|
||||||
|
|
||||||
|
const state = await platform.updater?.check()
|
||||||
|
if (state?.status === "up-to-date") {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("settings.updates.toast.latest.title"),
|
||||||
|
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (state?.status === "error") {
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: state.message })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ beforeAll(async () => {
|
|||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
useNavigate: () => () => undefined,
|
useNavigate: () => () => undefined,
|
||||||
useParams: () => ({}),
|
useParams: () => ({}),
|
||||||
|
useLocation: () => ({}),
|
||||||
|
useSearchParams: () => [{}, () => undefined],
|
||||||
}))
|
}))
|
||||||
mock.module("@opencode-ai/ui/context", () => ({
|
mock.module("@opencode-ai/ui/context", () => ({
|
||||||
createSimpleContext: () => ({
|
createSimpleContext: () => ({
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { useServerSDK } from "./server-sdk"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { createScopedCache } from "@/utils/scoped-cache"
|
import { createScopedCache } from "@/utils/scoped-cache"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import type { SelectedLineRange } from "@/context/file"
|
import type { SelectedLineRange } from "@/context/file"
|
||||||
@@ -166,11 +168,11 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
|
|||||||
return createCommentSessionState(store, setStore)
|
return createCommentSessionState(store, setStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCommentSession(dir: string, id: string | undefined) {
|
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
|
||||||
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.scoped(dir, id, "comments", [legacy]),
|
Persist.serverScoped(scope, dir, id, "comments", [legacy]),
|
||||||
createStore<CommentStore>({
|
createStore<CommentStore>({
|
||||||
comments: {},
|
comments: {},
|
||||||
}),
|
}),
|
||||||
@@ -200,11 +202,16 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const cache = createScopedCache(
|
const cache = createScopedCache(
|
||||||
(key) => {
|
(key) => {
|
||||||
const decoded = decodeSessionKey(key)
|
const decoded = decodeSessionKey(key)
|
||||||
return createRoot((dispose) => ({
|
return createRoot((dispose) => ({
|
||||||
value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
|
value: createCommentSession(
|
||||||
|
serverSDK.scope,
|
||||||
|
decoded.dir,
|
||||||
|
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
||||||
|
),
|
||||||
dispose,
|
dispose,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
getSessionPrefetchPromise,
|
getSessionPrefetchPromise,
|
||||||
setSessionPrefetch,
|
setSessionPrefetch,
|
||||||
} from "./global-sync/session-prefetch"
|
} from "./global-sync/session-prefetch"
|
||||||
import { createServerSyncContext } from "./server-sync"
|
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { createServerSdkContext, useServerSDK } from "./server-sdk"
|
||||||
|
import { type createServerSyncContextInner } from "./server-sync"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -171,8 +171,11 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => {
|
export const createDirSyncContext = (
|
||||||
const serverSDK = useServerSDK()
|
directory: string,
|
||||||
|
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||||
|
serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
|
||||||
|
) => {
|
||||||
const client = serverSDK.createClient({ directory, throwOnError: true })
|
const client = serverSDK.createClient({ directory, throwOnError: true })
|
||||||
|
|
||||||
type Child = ReturnType<(typeof serverSync)["child"]>
|
type Child = ReturnType<(typeof serverSync)["child"]>
|
||||||
@@ -273,7 +276,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
|||||||
|
|
||||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||||
if (sessionIDs.length === 0) return
|
if (sessionIDs.length === 0) return
|
||||||
clearSessionPrefetch(directory, sessionIDs)
|
clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
|
||||||
for (const sessionID of sessionIDs) {
|
for (const sessionID of sessionIDs) {
|
||||||
serverSync.todo.set(sessionID, undefined)
|
serverSync.todo.set(sessionID, undefined)
|
||||||
}
|
}
|
||||||
@@ -345,6 +348,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
|||||||
setMeta("cursor", key, next.cursor)
|
setMeta("cursor", key, next.cursor)
|
||||||
setMeta("complete", key, next.complete)
|
setMeta("complete", key, next.complete)
|
||||||
setSessionPrefetch({
|
setSessionPrefetch({
|
||||||
|
scope: serverSDK.scope,
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
limit: message.length,
|
limit: message.length,
|
||||||
@@ -435,7 +439,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
|||||||
|
|
||||||
touch(directory, setStore, sessionID)
|
touch(directory, setStore, sessionID)
|
||||||
|
|
||||||
const seeded = getSessionPrefetch(directory, sessionID)
|
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
|
||||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setMeta("limit", key, seeded.limit)
|
setMeta("limit", key, seeded.limit)
|
||||||
@@ -446,10 +450,10 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
|||||||
}
|
}
|
||||||
|
|
||||||
return runInflight(inflight, key, async () => {
|
return runInflight(inflight, key, async () => {
|
||||||
const pending = getSessionPrefetchPromise(directory, sessionID)
|
const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID)
|
||||||
if (pending) {
|
if (pending) {
|
||||||
await pending
|
await pending
|
||||||
const seeded = getSessionPrefetch(directory, sessionID)
|
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
|
||||||
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setMeta("limit", key, seeded.limit)
|
setMeta("limit", key, seeded.limit)
|
||||||
@@ -605,6 +609,9 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mcp: {
|
||||||
|
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
|
||||||
|
},
|
||||||
absolute,
|
absolute,
|
||||||
get directory() {
|
get directory() {
|
||||||
return current()[0].path.directory
|
return current()[0].path.directory
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
touchFileContent,
|
touchFileContent,
|
||||||
} from "./file/content-cache"
|
} from "./file/content-cache"
|
||||||
import { createFileViewCache } from "./file/view-cache"
|
import { createFileViewCache } from "./file/view-cache"
|
||||||
|
import { useServerSDK } from "./server-sdk"
|
||||||
|
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||||
import { createFileTreeStore } from "./file/tree-store"
|
import { createFileTreeStore } from "./file/tree-store"
|
||||||
import { invalidateFromWatcher } from "./file/watcher"
|
import { invalidateFromWatcher } from "./file/watcher"
|
||||||
import {
|
import {
|
||||||
@@ -56,12 +58,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
useSync()
|
useSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
||||||
const scope = createMemo(() => sdk.directory)
|
const scope = createMemo(() => sdk.directory)
|
||||||
const path = createPathHelpers(scope)
|
const path = createPathHelpers(scope)
|
||||||
const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const tabs = layout.tabs(() =>
|
||||||
|
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)),
|
||||||
|
)
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -107,7 +112,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewCache = createFileViewCache()
|
const viewCache = createFileViewCache(serverSDK.scope)
|
||||||
const view = createMemo(() => viewCache.load(scope(), params.id))
|
const view = createMemo(() => viewCache.load(scope(), params.id))
|
||||||
|
|
||||||
const ensure = (file: string) => {
|
const ensure = (file: string) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createStore, produce } from "solid-js/store"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { createScopedCache } from "@/utils/scoped-cache"
|
import { createScopedCache } from "@/utils/scoped-cache"
|
||||||
import type { FileViewState, SelectedLineRange } from "./types"
|
import type { FileViewState, SelectedLineRange } from "./types"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_FILE_VIEW_SESSIONS = 20
|
const MAX_FILE_VIEW_SESSIONS = 20
|
||||||
@@ -33,11 +34,11 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createViewSession(dir: string, id: string | undefined) {
|
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
|
||||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
const [view, setView, _, ready] = persisted(
|
const [view, setView, _, ready] = persisted(
|
||||||
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]),
|
||||||
createStore<{
|
createStore<{
|
||||||
file: Record<string, FileViewState>
|
file: Record<string, FileViewState>
|
||||||
}>({
|
}>({
|
||||||
@@ -119,14 +120,14 @@ function createViewSession(dir: string, id: string | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFileViewCache() {
|
export function createFileViewCache(scope: ServerScope) {
|
||||||
const cache = createScopedCache(
|
const cache = createScopedCache(
|
||||||
(key) => {
|
(key) => {
|
||||||
const split = key.lastIndexOf("\n")
|
const split = key.lastIndexOf("\n")
|
||||||
const dir = split >= 0 ? key.slice(0, split) : key
|
const dir = split >= 0 ? key.slice(0, split) : key
|
||||||
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
|
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
|
||||||
return createRoot((dispose) => ({
|
return createRoot((dispose) => ({
|
||||||
value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
|
value: createViewSession(scope, dir, id === WORKSPACE_KEY ? undefined : id),
|
||||||
dispose,
|
dispose,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { createStore } from "solid-js/store"
|
|||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
import { bootstrapDirectory } from "./bootstrap"
|
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ describe("bootstrapDirectory", () => {
|
|||||||
|
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory: "/project",
|
directory: "/project",
|
||||||
|
scope: ServerScope.local,
|
||||||
mcp: false,
|
mcp: false,
|
||||||
global: {
|
global: {
|
||||||
config: {} satisfies Config,
|
config: {} satisfies Config,
|
||||||
@@ -89,3 +91,18 @@ describe("bootstrapDirectory", () => {
|
|||||||
expect(mcpReads).toEqual([])
|
expect(mcpReads).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("query keys", () => {
|
||||||
|
test("partitions identical directories by server scope", () => {
|
||||||
|
const client = {} as OpencodeClient
|
||||||
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
|
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
|
||||||
|
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||||
|
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
||||||
|
"https://debian.example",
|
||||||
|
null,
|
||||||
|
"providers",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { formatServerError } from "@/utils/server-errors"
|
|||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery } from "../server-sync"
|
import { loadMcpQuery } from "../server-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -59,8 +60,8 @@ function errors(list: PromiseSettledResult<unknown>[]) {
|
|||||||
|
|
||||||
const providerRev = new Map<string, number>()
|
const providerRev = new Map<string, number>()
|
||||||
|
|
||||||
export function clearProviderRev(directory: string) {
|
export function clearProviderRev(scope: ServerScope, directory: string) {
|
||||||
providerRev.delete(directory)
|
providerRev.delete(ScopedKey.from(scope, directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
function runAll(list: Array<() => Promise<unknown>>) {
|
function runAll(list: Array<() => Promise<unknown>>) {
|
||||||
@@ -83,15 +84,15 @@ function showErrors(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadGlobalConfigQuery = (sdk: OpencodeClient) =>
|
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["config"],
|
queryKey: [scope, "config"],
|
||||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadProjectsQuery = (sdk: OpencodeClient) =>
|
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
sdk.project.list().then((x) => {
|
sdk.project.list().then((x) => {
|
||||||
@@ -106,6 +107,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
|
|||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverSDK: OpencodeClient
|
serverSDK: OpencodeClient
|
||||||
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
formatMoreCount: (count: number) => string
|
formatMoreCount: (count: number) => string
|
||||||
@@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
|
|||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||||
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.serverSDK))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
@@ -178,26 +180,27 @@ function warmSessions(input: {
|
|||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadProvidersQuery = (directory: string | null, sdk: OpencodeClient) =>
|
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [directory, "providers"],
|
queryKey: [scope, directory, "providers"],
|
||||||
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadAgentsQuery = (directory: string | null, sdk: OpencodeClient) =>
|
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [directory, "agents"],
|
queryKey: [scope, directory, "agents"],
|
||||||
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
|
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
@@ -223,14 +226,15 @@ export async function bootstrapDirectory(input: {
|
|||||||
}
|
}
|
||||||
if (loading) input.setStore("status", "partial")
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
const revKey = ScopedKey.from(input.scope, input.directory)
|
||||||
providerRev.set(input.directory, rev)
|
const rev = (providerRev.get(revKey) ?? 0) + 1
|
||||||
|
providerRev.set(revKey, rev)
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadAgentsQuery(input.directory, input.sdk))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
||||||
.then((data) => input.setStore("agent", data)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
() =>
|
||||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||||
@@ -239,7 +243,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
||||||
!seededPath &&
|
!seededPath &&
|
||||||
(() =>
|
(() =>
|
||||||
input.queryClient.ensureQueryData(loadPathQuery(input.directory, input.sdk)).then((data) => {
|
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
if (next) input.setStore("project", next)
|
if (next) input.setStore("project", next)
|
||||||
})),
|
})),
|
||||||
@@ -305,9 +309,9 @@ export async function bootstrapDirectory(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
|
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
|
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import { createStore } from "solid-js/store"
|
|||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import type { QueryOptionsApi } from "../server-sync"
|
import type { QueryOptionsApi } from "../server-sync"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||||
|
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||||
|
store[0],
|
||||||
|
store[1],
|
||||||
|
null,
|
||||||
|
Object.assign(() => true, { promise: undefined }),
|
||||||
|
]
|
||||||
|
|
||||||
const child = () => createStore({} as State)
|
const child = () => createStore({} as State)
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
@@ -42,12 +49,6 @@ function createOwner(callback: (owner: Owner) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mock.module("@/utils/persist", () => ({
|
|
||||||
Persist: {
|
|
||||||
workspace: (...parts: string[]) => parts.join(":"),
|
|
||||||
},
|
|
||||||
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
|
||||||
}))
|
|
||||||
mock.module("@tanstack/solid-query", () => ({
|
mock.module("@tanstack/solid-query", () => ({
|
||||||
useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
|
useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
|
||||||
querySingles.push(options)
|
querySingles.push(options)
|
||||||
@@ -80,6 +81,8 @@ describe("createChildStoreManager", () => {
|
|||||||
|
|
||||||
const manager = createChildStoreManager({
|
const manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
|
scope: ServerScope.local,
|
||||||
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
isLoadingSessions: () => false,
|
isLoadingSessions: () => false,
|
||||||
onBootstrap() {},
|
onBootstrap() {},
|
||||||
@@ -109,6 +112,8 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
|
scope: ServerScope.local,
|
||||||
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
isLoadingSessions: () => false,
|
isLoadingSessions: () => false,
|
||||||
onBootstrap(directory) {
|
onBootstrap(directory) {
|
||||||
@@ -128,6 +133,7 @@ describe("createChildStoreManager", () => {
|
|||||||
const [store] = manager.child("/project")
|
const [store] = manager.child("/project")
|
||||||
|
|
||||||
expect(store.status).toBe("loading")
|
expect(store.status).toBe("loading")
|
||||||
|
expect(store.limit).toBe(5)
|
||||||
expect(bootstraps).toEqual(["/project"])
|
expect(bootstraps).toEqual(["/project"])
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -140,6 +146,8 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
|
scope: ServerScope.local,
|
||||||
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
isLoadingSessions: () => false,
|
isLoadingSessions: () => false,
|
||||||
onBootstrap() {},
|
onBootstrap() {},
|
||||||
@@ -171,6 +179,8 @@ describe("createChildStoreManager", () => {
|
|||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
|
scope: ServerScope.local,
|
||||||
|
persist,
|
||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
isLoadingSessions: () => false,
|
isLoadingSessions: () => false,
|
||||||
onBootstrap() {},
|
onBootstrap() {},
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ import { useQuery } from "@tanstack/solid-query"
|
|||||||
import { QueryOptionsApi } from "../server-sync"
|
import { QueryOptionsApi } from "../server-sync"
|
||||||
import { directoryKey, type DirectoryKey } from "./utils"
|
import { directoryKey, type DirectoryKey } from "./utils"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
|
scope: ServerScope
|
||||||
|
persist: typeof persisted
|
||||||
isBooting: (directory: string) => boolean
|
isBooting: (directory: string) => boolean
|
||||||
isLoadingSessions: (directory: string) => boolean
|
isLoadingSessions: (directory: string) => boolean
|
||||||
onBootstrap: (directory: string) => void
|
onBootstrap: (directory: string) => void
|
||||||
@@ -147,8 +150,8 @@ export function createChildStoreManager(input: {
|
|||||||
if (!key) console.error("No directory provided")
|
if (!key) console.error("No directory provided")
|
||||||
if (!children[key]) {
|
if (!children[key]) {
|
||||||
const vcs = runWithOwner(input.owner, () =>
|
const vcs = runWithOwner(input.owner, () =>
|
||||||
persisted(
|
input.persist(
|
||||||
Persist.workspace(directory, "vcs", ["vcs.v1"]),
|
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
|
||||||
createStore({ value: undefined as VcsInfo | undefined }),
|
createStore({ value: undefined as VcsInfo | undefined }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -157,8 +160,8 @@ export function createChildStoreManager(input: {
|
|||||||
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
|
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
|
||||||
|
|
||||||
const meta = runWithOwner(input.owner, () =>
|
const meta = runWithOwner(input.owner, () =>
|
||||||
persisted(
|
input.persist(
|
||||||
Persist.workspace(directory, "project", ["project.v1"]),
|
Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]),
|
||||||
createStore({ value: undefined as ProjectMeta | undefined }),
|
createStore({ value: undefined as ProjectMeta | undefined }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -166,8 +169,8 @@ export function createChildStoreManager(input: {
|
|||||||
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
|
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
|
||||||
|
|
||||||
const icon = runWithOwner(input.owner, () =>
|
const icon = runWithOwner(input.owner, () =>
|
||||||
persisted(
|
input.persist(
|
||||||
Persist.workspace(directory, "icon", ["icon.v1"]),
|
Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]),
|
||||||
createStore({ value: undefined as string | undefined }),
|
createStore({ value: undefined as string | undefined }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -134,6 +134,27 @@ describe("applyGlobalEvent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("applyDirectoryEvent", () => {
|
describe("applyDirectoryEvent", () => {
|
||||||
|
test("preserves a Home-specific retained session limit", () => {
|
||||||
|
const [store, setStore] = createStore(
|
||||||
|
baseState({
|
||||||
|
limit: 1,
|
||||||
|
session: [rootSession({ id: "a" }), rootSession({ id: "b" }), rootSession({ id: "c" })],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "session.created", properties: { info: rootSession({ id: "d" }) } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
retainedLimit: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.session).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
test("inserts root sessions in sorted order and updates sessionTotal", () => {
|
test("inserts root sessions in sorted order and updates sessionTotal", () => {
|
||||||
const [store, setStore] = createStore(
|
const [store, setStore] = createStore(
|
||||||
baseState({
|
baseState({
|
||||||
|
|||||||
@@ -99,8 +99,10 @@ export function applyDirectoryEvent(input: {
|
|||||||
loadLsp: () => void
|
loadLsp: () => void
|
||||||
vcsCache?: VcsCache
|
vcsCache?: VcsCache
|
||||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||||
|
retainedLimit?: number
|
||||||
}) {
|
}) {
|
||||||
const event = input.event
|
const event = input.event
|
||||||
|
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "server.instance.disposed": {
|
case "server.instance.disposed": {
|
||||||
input.push(input.directory)
|
input.push(input.directory)
|
||||||
@@ -115,7 +117,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
}
|
}
|
||||||
const next = input.store.session.slice()
|
const next = input.store.session.slice()
|
||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||||
@@ -145,7 +147,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
}
|
}
|
||||||
const next = input.store.session.slice()
|
const next = input.store.session.slice()
|
||||||
next.splice(result.index, 0, info)
|
next.splice(result.index, 0, info)
|
||||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
|
||||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { toggleMcp } from "./mcp"
|
||||||
|
|
||||||
|
describe("toggleMcp", () => {
|
||||||
|
test("runs the status action before refreshing the owning query", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const input = (status: "connected" | "needs_auth" | "disabled") => ({
|
||||||
|
status,
|
||||||
|
connect: async () => {
|
||||||
|
calls.push("connect")
|
||||||
|
},
|
||||||
|
disconnect: async () => {
|
||||||
|
calls.push("disconnect")
|
||||||
|
},
|
||||||
|
authenticate: async () => {
|
||||||
|
calls.push("authenticate")
|
||||||
|
},
|
||||||
|
refresh: async () => {
|
||||||
|
calls.push("refresh")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await toggleMcp(input("connected"))
|
||||||
|
expect(calls).toEqual(["disconnect", "refresh"])
|
||||||
|
|
||||||
|
calls.length = 0
|
||||||
|
await toggleMcp(input("needs_auth"))
|
||||||
|
expect(calls).toEqual(["authenticate", "refresh"])
|
||||||
|
|
||||||
|
calls.length = 0
|
||||||
|
await toggleMcp(input("disabled"))
|
||||||
|
expect(calls).toEqual(["connect", "refresh"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
export async function toggleMcp(input: {
|
||||||
|
status: McpStatus["status"]
|
||||||
|
connect: () => Promise<void>
|
||||||
|
disconnect: () => Promise<void>
|
||||||
|
authenticate: () => Promise<void>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
await {
|
||||||
|
connected: input.disconnect,
|
||||||
|
needs_auth: input.authenticate,
|
||||||
|
disabled: input.connect,
|
||||||
|
failed: input.connect,
|
||||||
|
needs_client_registration: input.connect,
|
||||||
|
}[input.status]()
|
||||||
|
await input.refresh()
|
||||||
|
}
|
||||||
@@ -7,14 +7,18 @@ import {
|
|||||||
setSessionPrefetch,
|
setSessionPrefetch,
|
||||||
shouldSkipSessionPrefetch,
|
shouldSkipSessionPrefetch,
|
||||||
} from "./session-prefetch"
|
} from "./session-prefetch"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
const scope = ServerScope.local
|
||||||
|
|
||||||
describe("session prefetch", () => {
|
describe("session prefetch", () => {
|
||||||
test("stores and clears message metadata by directory", () => {
|
test("stores and clears message metadata by directory", () => {
|
||||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
|
||||||
clearSessionPrefetch("/tmp/b", ["ses_1"])
|
clearSessionPrefetch(scope, "/tmp/b", ["ses_1"])
|
||||||
|
|
||||||
setSessionPrefetch({
|
setSessionPrefetch({
|
||||||
directory: "/tmp/a",
|
directory: "/tmp/a",
|
||||||
|
scope,
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
limit: 200,
|
limit: 200,
|
||||||
cursor: "abc",
|
cursor: "abc",
|
||||||
@@ -22,21 +26,27 @@ describe("session prefetch", () => {
|
|||||||
at: 123,
|
at: 123,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
|
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toEqual({
|
||||||
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
|
limit: 200,
|
||||||
|
cursor: "abc",
|
||||||
|
complete: false,
|
||||||
|
at: 123,
|
||||||
|
})
|
||||||
|
expect(getSessionPrefetch(scope, "/tmp/b", "ses_1")).toBeUndefined()
|
||||||
|
|
||||||
clearSessionPrefetch("/tmp/a", ["ses_1"])
|
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
|
||||||
|
|
||||||
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
|
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("dedupes inflight work", async () => {
|
test("dedupes inflight work", async () => {
|
||||||
clearSessionPrefetch("/tmp/c", ["ses_2"])
|
clearSessionPrefetch(scope, "/tmp/c", ["ses_2"])
|
||||||
|
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const run = () =>
|
const run = () =>
|
||||||
runSessionPrefetch({
|
runSessionPrefetch({
|
||||||
directory: "/tmp/c",
|
directory: "/tmp/c",
|
||||||
|
scope,
|
||||||
sessionID: "ses_2",
|
sessionID: "ses_2",
|
||||||
task: async () => {
|
task: async () => {
|
||||||
calls += 1
|
calls += 1
|
||||||
@@ -52,15 +62,48 @@ describe("session prefetch", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("clears a whole directory", () => {
|
test("clears a whole directory", () => {
|
||||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
|
setSessionPrefetch({
|
||||||
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
|
scope,
|
||||||
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
|
directory: "/tmp/d",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
limit: 10,
|
||||||
|
cursor: "a",
|
||||||
|
complete: true,
|
||||||
|
at: 1,
|
||||||
|
})
|
||||||
|
setSessionPrefetch({
|
||||||
|
scope,
|
||||||
|
directory: "/tmp/d",
|
||||||
|
sessionID: "ses_2",
|
||||||
|
limit: 20,
|
||||||
|
cursor: "b",
|
||||||
|
complete: false,
|
||||||
|
at: 2,
|
||||||
|
})
|
||||||
|
setSessionPrefetch({
|
||||||
|
scope,
|
||||||
|
directory: "/tmp/e",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
limit: 30,
|
||||||
|
cursor: "c",
|
||||||
|
complete: true,
|
||||||
|
at: 3,
|
||||||
|
})
|
||||||
|
|
||||||
clearSessionPrefetchDirectory("/tmp/d")
|
clearSessionPrefetchDirectory(scope, "/tmp/d")
|
||||||
|
|
||||||
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
|
expect(getSessionPrefetch(scope, "/tmp/d", "ses_1")).toBeUndefined()
|
||||||
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
|
expect(getSessionPrefetch(scope, "/tmp/d", "ses_2")).toBeUndefined()
|
||||||
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
|
expect(getSessionPrefetch(scope, "/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isolates identical directories and sessions by server scope", () => {
|
||||||
|
const remote = "https://debian.example" as ServerScope
|
||||||
|
setSessionPrefetch({ scope, directory: "/repo", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
|
||||||
|
setSessionPrefetch({ scope: remote, directory: "/repo", sessionID: "ses_1", limit: 20, complete: true, at: 2 })
|
||||||
|
|
||||||
|
expect(getSessionPrefetch(scope, "/repo", "ses_1")?.limit).toBe(10)
|
||||||
|
expect(getSessionPrefetch(remote, "/repo", "ses_1")?.limit).toBe(20)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refreshes stale first-page prefetched history", () => {
|
test("refreshes stale first-page prefetched history", () => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
const key = (scope: ServerScope, directory: string, sessionID: string) => ScopedKey.from(scope, directory, sessionID)
|
||||||
|
|
||||||
export const SESSION_PREFETCH_TTL = 15_000
|
export const SESSION_PREFETCH_TTL = 15_000
|
||||||
|
|
||||||
@@ -27,28 +29,32 @@ const rev = new Map<string, number>()
|
|||||||
|
|
||||||
const version = (id: string) => rev.get(id) ?? 0
|
const version = (id: string) => rev.get(id) ?? 0
|
||||||
|
|
||||||
export function getSessionPrefetch(directory: string, sessionID: string) {
|
export function getSessionPrefetch(scope: ServerScope, directory: string, sessionID: string) {
|
||||||
return cache.get(key(directory, sessionID))
|
return cache.get(key(scope, directory, sessionID))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
|
export function getSessionPrefetchPromise(scope: ServerScope, directory: string, sessionID: string) {
|
||||||
return inflight.get(key(directory, sessionID))
|
return inflight.get(key(scope, directory, sessionID))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionPrefetchInflight() {
|
export function clearSessionPrefetchInflight(scope: ServerScope) {
|
||||||
inflight.clear()
|
const prefix = ScopedKey.prefix(scope)
|
||||||
|
for (const id of inflight.keys()) {
|
||||||
|
if (id.startsWith(prefix)) inflight.delete(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
|
export function isSessionPrefetchCurrent(scope: ServerScope, directory: string, sessionID: string, value: number) {
|
||||||
return version(key(directory, sessionID)) === value
|
return version(key(scope, directory, sessionID)) === value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runSessionPrefetch(input: {
|
export function runSessionPrefetch(input: {
|
||||||
directory: string
|
directory: string
|
||||||
|
scope: ServerScope
|
||||||
sessionID: string
|
sessionID: string
|
||||||
task: (value: number) => Promise<Meta | undefined>
|
task: (value: number) => Promise<Meta | undefined>
|
||||||
}) {
|
}) {
|
||||||
const id = key(input.directory, input.sessionID)
|
const id = key(input.scope, input.directory, input.sessionID)
|
||||||
const pending = inflight.get(id)
|
const pending = inflight.get(id)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
||||||
@@ -64,13 +70,14 @@ export function runSessionPrefetch(input: {
|
|||||||
|
|
||||||
export function setSessionPrefetch(input: {
|
export function setSessionPrefetch(input: {
|
||||||
directory: string
|
directory: string
|
||||||
|
scope: ServerScope
|
||||||
sessionID: string
|
sessionID: string
|
||||||
limit: number
|
limit: number
|
||||||
cursor?: string
|
cursor?: string
|
||||||
complete: boolean
|
complete: boolean
|
||||||
at?: number
|
at?: number
|
||||||
}) {
|
}) {
|
||||||
cache.set(key(input.directory, input.sessionID), {
|
cache.set(key(input.scope, input.directory, input.sessionID), {
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
cursor: input.cursor,
|
cursor: input.cursor,
|
||||||
complete: input.complete,
|
complete: input.complete,
|
||||||
@@ -78,18 +85,18 @@ export function setSessionPrefetch(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
|
export function clearSessionPrefetch(scope: ServerScope, directory: string, sessionIDs: Iterable<string>) {
|
||||||
for (const sessionID of sessionIDs) {
|
for (const sessionID of sessionIDs) {
|
||||||
if (!sessionID) continue
|
if (!sessionID) continue
|
||||||
const id = key(directory, sessionID)
|
const id = key(scope, directory, sessionID)
|
||||||
rev.set(id, version(id) + 1)
|
rev.set(id, version(id) + 1)
|
||||||
cache.delete(id)
|
cache.delete(id)
|
||||||
inflight.delete(id)
|
inflight.delete(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionPrefetchDirectory(directory: string) {
|
export function clearSessionPrefetchDirectory(scope: ServerScope, directory: string) {
|
||||||
const prefix = `${directory}\n`
|
const prefix = ScopedKey.prefix(scope, directory)
|
||||||
const keys = new Set([...cache.keys(), ...inflight.keys()])
|
const keys = new Set([...cache.keys(), ...inflight.keys()])
|
||||||
for (const id of keys) {
|
for (const id of keys) {
|
||||||
if (!id.startsWith(prefix)) continue
|
if (!id.startsWith(prefix)) continue
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { createEffect, createMemo, createRoot } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createServerProjects, ServerConnection, useServer } from "./server"
|
||||||
|
import { useServerHealth } from "@/utils/server-health"
|
||||||
|
import { createServerSdkContext } from "./server-sdk"
|
||||||
|
import { createServerSyncContext } from "./server-sync"
|
||||||
|
import { getOwner } from "solid-js/web"
|
||||||
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||||
|
name: "Global",
|
||||||
|
init: () => {
|
||||||
|
const server = useServer()
|
||||||
|
const serverHealth = useServerHealth(
|
||||||
|
() => server.list,
|
||||||
|
() => true,
|
||||||
|
)
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
settings: {
|
||||||
|
serverKey: undefined as ServerConnection.Key | undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsServer = createMemo(() => {
|
||||||
|
const list = server.list
|
||||||
|
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const conn = settingsServer()
|
||||||
|
const key = conn ? ServerConnection.key(conn) : undefined
|
||||||
|
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverCtxs = new Map<
|
||||||
|
ServerConnection.Key,
|
||||||
|
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
|
||||||
|
>()
|
||||||
|
|
||||||
|
const owner = getOwner()
|
||||||
|
|
||||||
|
const ensureServerCtx = (conn: ServerConnection.Any) => {
|
||||||
|
const key = ServerConnection.key(conn)
|
||||||
|
const existing = serverCtxs.get(key)
|
||||||
|
if (existing) return existing.serverCtx
|
||||||
|
const root = createRoot((dispose) => {
|
||||||
|
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
|
||||||
|
return { dispose, serverCtx }
|
||||||
|
}, owner as any)
|
||||||
|
serverCtxs.set(key, root)
|
||||||
|
return root.serverCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
createMemo(() => {
|
||||||
|
for (const conn of server.list) {
|
||||||
|
ensureServerCtx(conn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
for (const [key] of serverCtxs) {
|
||||||
|
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
|
||||||
|
const { dispose } = serverCtxs.get(key)!
|
||||||
|
dispose()
|
||||||
|
serverCtxs.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
servers: {
|
||||||
|
list: () => server.list,
|
||||||
|
health: serverHealth,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
server: {
|
||||||
|
get key() {
|
||||||
|
return store.settings.serverKey
|
||||||
|
},
|
||||||
|
selected: settingsServer,
|
||||||
|
set(key: ServerConnection.Key) {
|
||||||
|
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createServerCtx(conn: ServerConnection.Any) {
|
||||||
|
return ensureServerCtx(conn)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function createServerCtx(
|
||||||
|
conn: ServerConnection.Any,
|
||||||
|
scope: ServerScope,
|
||||||
|
projects: ReturnType<typeof createServerProjects>,
|
||||||
|
) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const sdk = createServerSdkContext(conn, scope)
|
||||||
|
const sync = createServerSyncContext(sdk)
|
||||||
|
|
||||||
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
|
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||||
|
const projectID = childStore.project
|
||||||
|
const metadata = projectID
|
||||||
|
? sync.data.project.find((x) => x.id === projectID)
|
||||||
|
: sync.data.project.find((x) => x.worktree === project.worktree)
|
||||||
|
|
||||||
|
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||||
|
// Without this, different subdirectories of the same git repo would share the same
|
||||||
|
// icon from the database instead of using their individual overrides.
|
||||||
|
const base = { ...metadata, ...project }
|
||||||
|
if (childStore.icon) {
|
||||||
|
return { ...base, icon: { ...base.icon, override: childStore.icon } }
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsList = createMemo(() => projects.list().map(enrich))
|
||||||
|
|
||||||
|
const isLocal =
|
||||||
|
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryClient,
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
isLocal,
|
||||||
|
projects: {
|
||||||
|
...projects,
|
||||||
|
list: projectsList,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerCtx = ReturnType<typeof createServerCtx>
|
||||||
|
|
||||||
|
function isLocalHost(url: string) {
|
||||||
|
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||||
|
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||||
|
}
|
||||||
@@ -198,6 +198,7 @@ if (warm !== "en") void loadDict(warm)
|
|||||||
|
|
||||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||||
name: "Language",
|
name: "Language",
|
||||||
|
gate: false,
|
||||||
init: (props: { locale?: Locale }) => {
|
init: (props: { locale?: Locale }) => {
|
||||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Accessor } from "solid-js"
|
||||||
|
|
||||||
|
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
|
||||||
|
touch(key)
|
||||||
|
seed(key)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
|
||||||
|
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
||||||
|
return () => {
|
||||||
|
const value = key()
|
||||||
|
ensure(value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneSessionKeys(input: {
|
||||||
|
keep?: string
|
||||||
|
max: number
|
||||||
|
used: Map<string, number>
|
||||||
|
view: string[]
|
||||||
|
tabs: string[]
|
||||||
|
}) {
|
||||||
|
if (!input.keep) return []
|
||||||
|
|
||||||
|
const keys = new Set<string>([...input.view, ...input.tabs])
|
||||||
|
if (keys.size <= input.max) return []
|
||||||
|
|
||||||
|
const score = (key: string) => {
|
||||||
|
if (key === input.keep) return Number.MAX_SAFE_INTEGER
|
||||||
|
return input.used.get(key) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(keys)
|
||||||
|
.sort((a, b) => score(b) - score(a))
|
||||||
|
.slice(input.max)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createRoot, createSignal } from "solid-js"
|
import { createRoot, createSignal } from "solid-js"
|
||||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
|
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||||
|
|
||||||
describe("layout session-key helpers", () => {
|
describe("layout session-key helpers", () => {
|
||||||
test("couples touch and scroll seed in order", () => {
|
test("couples touch and scroll seed in order", () => {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
|
import { useLocation } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useServerSync } from "./server-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
@@ -13,6 +14,10 @@ import { same } from "@/utils/same"
|
|||||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||||
import { createPathHelpers } from "./file/path"
|
import { createPathHelpers } from "./file/path"
|
||||||
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
||||||
|
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||||
|
|
||||||
|
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||||
|
|
||||||
export type { ProjectAvatarVariant }
|
export type { ProjectAvatarVariant }
|
||||||
|
|
||||||
@@ -60,6 +65,7 @@ type SessionView = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TabHandoff = {
|
type TabHandoff = {
|
||||||
|
scope: ServerScope
|
||||||
dir: string
|
dir: string
|
||||||
id: string
|
id: string
|
||||||
at: number
|
at: number
|
||||||
@@ -69,42 +75,11 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
|
|||||||
|
|
||||||
export type ReviewDiffStyle = "unified" | "split"
|
export type ReviewDiffStyle = "unified" | "split"
|
||||||
|
|
||||||
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
|
export type LayoutRoute =
|
||||||
touch(key)
|
| { type: "home" }
|
||||||
seed(key)
|
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
|
||||||
return key
|
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
||||||
}
|
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
|
||||||
|
|
||||||
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
|
|
||||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
|
||||||
return () => {
|
|
||||||
const value = key()
|
|
||||||
ensure(value)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pruneSessionKeys(input: {
|
|
||||||
keep?: string
|
|
||||||
max: number
|
|
||||||
used: Map<string, number>
|
|
||||||
view: string[]
|
|
||||||
tabs: string[]
|
|
||||||
}) {
|
|
||||||
if (!input.keep) return []
|
|
||||||
|
|
||||||
const keys = new Set<string>([...input.view, ...input.tabs])
|
|
||||||
if (keys.size <= input.max) return []
|
|
||||||
|
|
||||||
const score = (key: string) => {
|
|
||||||
if (key === input.keep) return Number.MAX_SAFE_INTEGER
|
|
||||||
return input.used.get(key) ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(keys)
|
|
||||||
.sort((a, b) => score(b) - score(a))
|
|
||||||
.slice(input.max)
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
||||||
const all = current?.all ?? []
|
const all = current?.all ?? []
|
||||||
@@ -115,7 +90,7 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionPath = (key: string) => {
|
const sessionPath = (key: string) => {
|
||||||
const dir = key.split("/")[0]
|
const dir = SessionStateKey.route(key).split("/")[0]
|
||||||
if (!dir) return
|
if (!dir) return
|
||||||
const root = decode64(dir)
|
const root = decode64(dir)
|
||||||
if (!root) return
|
if (!root) return
|
||||||
@@ -146,13 +121,41 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||||
|
const parts = pathname.split("/").filter(Boolean)
|
||||||
|
if (parts.length === 0) return { type: "home" }
|
||||||
|
|
||||||
|
if (parts[0] === "new-session") {
|
||||||
|
const draftID = new URLSearchParams(search).get("draftId")
|
||||||
|
if (!draftID) return { type: "home" }
|
||||||
|
return { type: "draft", draftID }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirBase64 = parts[0]
|
||||||
|
const dir = decode64(dirBase64)
|
||||||
|
if (!dir) return { type: "home" }
|
||||||
|
|
||||||
|
if (parts[1] !== "session") return { type: "home" }
|
||||||
|
|
||||||
|
const id = parts[2]
|
||||||
|
if (id) return { type: "session", dir, dirBase64, sessionId: id }
|
||||||
|
return { type: "dir-new-sesssion", dir, dirBase64 }
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const globalSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const location = useLocation()
|
||||||
|
const route = createMemo(() => {
|
||||||
|
const value = currentRoute(location.pathname, location.search)
|
||||||
|
if (value.type === "home") return value
|
||||||
|
return { ...value, server: server.key }
|
||||||
|
})
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
@@ -197,7 +200,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const sessionTabs = value.sessionTabs
|
const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs)
|
||||||
|
const sessionView = migrateLegacySessionStateKeys(value.sessionView)
|
||||||
const migratedSessionTabs = (() => {
|
const migratedSessionTabs = (() => {
|
||||||
if (!isRecord(sessionTabs)) return sessionTabs
|
if (!isRecord(sessionTabs)) return sessionTabs
|
||||||
|
|
||||||
@@ -226,7 +230,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
migratedSidebar === sidebar &&
|
migratedSidebar === sidebar &&
|
||||||
migratedReview === review &&
|
migratedReview === review &&
|
||||||
migratedFileTree === fileTree &&
|
migratedFileTree === fileTree &&
|
||||||
migratedSessionTabs === sessionTabs
|
migratedSessionTabs === value.sessionTabs &&
|
||||||
|
sessionView === value.sessionView
|
||||||
) {
|
) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -237,10 +242,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
review: migratedReview,
|
review: migratedReview,
|
||||||
fileTree: migratedFileTree,
|
fileTree: migratedFileTree,
|
||||||
sessionTabs: migratedSessionTabs,
|
sessionTabs: migratedSessionTabs,
|
||||||
|
sessionView,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = Persist.global("layout", ["layout.v6"])
|
const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{ ...target, migrate },
|
{ ...target, migrate },
|
||||||
createStore({
|
createStore({
|
||||||
@@ -293,15 +299,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const dropSessionState = (keys: string[]) => {
|
const dropSessionState = (keys: string[]) => {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const parts = key.split("/")
|
const scope = SessionStateKey.scope(key)
|
||||||
|
const parts = SessionStateKey.route(key).split("/")
|
||||||
const dir = parts[0]
|
const dir = parts[0]
|
||||||
const session = parts[1]
|
const session = parts[1]
|
||||||
if (!dir) continue
|
if (!dir) continue
|
||||||
|
|
||||||
for (const entry of SESSION_STATE_KEYS) {
|
for (const entry of SESSION_STATE_KEYS) {
|
||||||
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
|
const target = session
|
||||||
|
? Persist.serverSession(scope, dir, session, entry.key)
|
||||||
|
: Persist.serverWorkspace(scope, dir, entry.key)
|
||||||
void removePersisted(target, platform)
|
void removePersisted(target, platform)
|
||||||
|
|
||||||
|
if (scope !== ServerScope.local) continue
|
||||||
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
||||||
void removePersisted({ key: legacyKey }, platform)
|
void removePersisted({ key: legacyKey }, platform)
|
||||||
}
|
}
|
||||||
@@ -526,7 +536,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
void globalSdk.client.project
|
void serverSdk.client.project
|
||||||
.update({ projectID: project.id, directory: worktree, icon: { color } })
|
.update({ projectID: project.id, directory: worktree, icon: { color } })
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||||
@@ -557,11 +567,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
route,
|
||||||
ready,
|
ready,
|
||||||
handoff: {
|
handoff: {
|
||||||
tabs: createMemo(() => store.handoff?.tabs),
|
tabs: createMemo(() => store.handoff?.tabs),
|
||||||
setTabs(dir: string, id: string) {
|
setTabs(dir: string, id: string) {
|
||||||
setStore("handoff", "tabs", { dir, id, at: Date.now() })
|
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
|
||||||
},
|
},
|
||||||
clearTabs() {
|
clearTabs() {
|
||||||
if (!store.handoff?.tabs) return
|
if (!store.handoff?.tabs) return
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
|
import { useServerSDK } from "./server-sdk"
|
||||||
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
export type ModelKey = { providerID: string; modelID: string; variant?: string }
|
export type ModelKey = { providerID: string; modelID: string; variant?: string }
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ type Saved = {
|
|||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const handoff = new Map<string, State>()
|
const handoff = new Map<string, State>()
|
||||||
|
|
||||||
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
|
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
|
||||||
|
|
||||||
const migrate = (value: unknown) => {
|
const migrate = (value: unknown) => {
|
||||||
if (!value || typeof value !== "object") return { session: {} }
|
if (!value || typeof value !== "object") return { session: {} }
|
||||||
@@ -57,6 +59,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const [saved, setSaved] = persisted(
|
const [saved, setSaved] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
|
...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
|
||||||
migrate,
|
migrate,
|
||||||
},
|
},
|
||||||
createStore<Saved>({
|
createStore<Saved>({
|
||||||
@@ -121,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const scope = createMemo<State | undefined>(() => {
|
const scope = createMemo<State | undefined>(() => {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return store.draft
|
if (!session) return store.draft
|
||||||
return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
|
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const key = handoffKey(sdk.directory, session)
|
const key = handoffKey(serverSDK.scope, sdk.directory, session)
|
||||||
const next = handoff.get(key)
|
const next = handoff.get(key)
|
||||||
if (!next) return
|
if (!next) return
|
||||||
if (saved.session[session] !== undefined) {
|
if (saved.session[session] !== undefined) {
|
||||||
@@ -377,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handoff.set(handoffKey(dir, session), next)
|
handoff.set(handoffKey(serverSDK.scope, dir, session), next)
|
||||||
setStore("draft", undefined)
|
setStore("draft", undefined)
|
||||||
},
|
},
|
||||||
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||||
@@ -385,7 +388,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
if (msg.sessionID !== session) return
|
if (msg.sessionID !== session) return
|
||||||
if (saved.session[session] !== undefined) return
|
if (saved.session[session] !== undefined) return
|
||||||
if (handoff.has(handoffKey(sdk.directory, session))) return
|
if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
|
||||||
|
|
||||||
setSaved("session", session, {
|
setSaved("session", session, {
|
||||||
agent: msg.agent,
|
agent: msg.agent,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
|
||||||
|
export function useMcpToggle() {
|
||||||
|
const sync = useSync()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
return useMutation(() => ({
|
||||||
|
mutationFn: sync.mcp.toggle,
|
||||||
|
onError: (error) =>
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ function modelKey(model: ModelKey) {
|
|||||||
|
|
||||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||||
name: "Models",
|
name: "Models",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function buildNotificationIndex(list: Notification[]) {
|
|||||||
|
|
||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||||
name: "Notification",
|
name: "Notification",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
@@ -124,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
const currentSession = createMemo(() => params.id)
|
const currentSession = createMemo(() => params.id)
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("notification", ["notification.v1"]),
|
Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as Notification[],
|
list: [] as Notification[],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function hasPermissionPromptRules(permission: unknown) {
|
|||||||
|
|
||||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||||
name: "Permission",
|
name: "Permission",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
@@ -60,7 +61,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.global("permission", ["permission.v3"]),
|
...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
|
||||||
migrate(value) {
|
migrate(value) {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
|||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { DesktopMenuAction } from "../desktop-menu"
|
import type { DesktopMenuAction } from "../desktop-menu"
|
||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./server"
|
||||||
|
import type { WslServersPlatform } from "../wsl/types"
|
||||||
|
import type { UpdaterPlatform } from "../updater"
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
type OpenAttachmentPickerOptions = {
|
||||||
|
title?: string
|
||||||
|
multiple?: boolean
|
||||||
|
accept?: string[]
|
||||||
|
extensions?: string[]
|
||||||
|
defaultPath?: string
|
||||||
|
}
|
||||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
|
||||||
type PlatformName = "web" | "desktop"
|
type PlatformName = "web" | "desktop"
|
||||||
type DesktopOS = "macos" | "windows" | "linux"
|
type DesktopOS = "macos" | "windows" | "linux"
|
||||||
|
|
||||||
@@ -20,13 +27,7 @@ export type FatalRendererErrorLog = {
|
|||||||
os?: DesktopOS
|
os?: DesktopOS
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Platform = {
|
type PlatformBase = {
|
||||||
/** Platform discriminator */
|
|
||||||
platform: PlatformName
|
|
||||||
|
|
||||||
/** Desktop OS (Tauri only) */
|
|
||||||
os?: DesktopOS
|
|
||||||
|
|
||||||
/** App version */
|
/** App version */
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
@@ -48,23 +49,20 @@ export type Platform = {
|
|||||||
/** Send a system notification (optional deep link) */
|
/** Send a system notification (optional deep link) */
|
||||||
notify(title: string, description?: string, href?: string): Promise<void>
|
notify(title: string, description?: string, href?: string): Promise<void>
|
||||||
|
|
||||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||||
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
openAttachmentPickerDialog?(
|
||||||
|
opts: OpenAttachmentPickerOptions,
|
||||||
|
onFile: (file: File) => Promise<unknown>,
|
||||||
|
): Promise<void>
|
||||||
|
|
||||||
/** Open native file picker dialog (Tauri only) */
|
/** Open a native save file picker dialog (desktop only) */
|
||||||
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
|
|
||||||
|
|
||||||
/** Save file picker dialog (Tauri only) */
|
|
||||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||||
|
|
||||||
/** Storage mechanism, defaults to localStorage */
|
/** Storage mechanism, defaults to localStorage */
|
||||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||||
|
|
||||||
/** Check for a downloadable desktop update */
|
/** Application-global desktop updater */
|
||||||
checkUpdate?(): Promise<UpdateInfo>
|
updater?: UpdaterPlatform
|
||||||
|
|
||||||
/** Install the downloaded update using the platform restart flow */
|
|
||||||
updateAndRestart?(): Promise<void>
|
|
||||||
|
|
||||||
/** Fetch override */
|
/** Fetch override */
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
@@ -75,11 +73,8 @@ export type Platform = {
|
|||||||
/** Set the default server URL to use on app startup (platform-specific) */
|
/** Set the default server URL to use on app startup (platform-specific) */
|
||||||
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||||
|
|
||||||
/** Get the configured WSL integration (desktop only) */
|
/** Manage WSL sidecar servers (Electron on Windows only) */
|
||||||
getWslEnabled?(): Promise<boolean>
|
wslServers?: WslServersPlatform
|
||||||
|
|
||||||
/** Set the configured WSL integration (desktop only) */
|
|
||||||
setWslEnabled?(config: boolean): Promise<void> | void
|
|
||||||
|
|
||||||
/** Get the preferred display backend (desktop only) */
|
/** Get the preferred display backend (desktop only) */
|
||||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||||
@@ -115,6 +110,16 @@ export type Platform = {
|
|||||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Platform = PlatformBase &
|
||||||
|
(
|
||||||
|
| { platform: "web"; os?: never }
|
||||||
|
| {
|
||||||
|
platform: "desktop"
|
||||||
|
os?: DesktopOS
|
||||||
|
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export type DisplayBackend = "auto" | "wayland"
|
export type DisplayBackend = "auto" | "wayland"
|
||||||
|
|
||||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { checksum } from "@opencode-ai/core/util/encode"
|
import { checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { useServerSDK } from "./server-sdk"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
@@ -151,9 +153,11 @@ const MAX_PROMPT_SESSIONS = 20
|
|||||||
|
|
||||||
type PromptSession = ReturnType<typeof createPromptSession>
|
type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
|
|
||||||
type Scope = {
|
type Scope = { draftID: string } | { dir: string; id?: string }
|
||||||
dir: string
|
|
||||||
id?: string
|
function scopeKey(scope: Scope) {
|
||||||
|
if ("draftID" in scope) return `draft:${scope.draftID}`
|
||||||
|
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromptCacheEntry = {
|
type PromptCacheEntry = {
|
||||||
@@ -161,11 +165,15 @@ type PromptCacheEntry = {
|
|||||||
dispose: VoidFunction
|
dispose: VoidFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPromptSession(dir: string, id: string | undefined) {
|
function promptTarget(serverScope: ServerScope, scope: Scope) {
|
||||||
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||||
|
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||||
|
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.scoped(dir, id, "prompt", [legacy]),
|
promptTarget(serverScope, scope),
|
||||||
createStore<{
|
createStore<{
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
cursor?: number
|
cursor?: number
|
||||||
@@ -229,6 +237,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const cache = new Map<string, PromptCacheEntry>()
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
|
|
||||||
const disposeAll = () => {
|
const disposeAll = () => {
|
||||||
@@ -251,8 +261,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
const load = (dir: string, id: string | undefined) => {
|
const load = (scope: Scope) => {
|
||||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
const key = scopeKey(scope)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
@@ -262,7 +272,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const entry = createRoot(
|
const entry = createRoot(
|
||||||
(dispose) => ({
|
(dispose) => ({
|
||||||
value: createPromptSession(dir, id),
|
value: createPromptSession(serverSDK.scope, scope),
|
||||||
dispose,
|
dispose,
|
||||||
}),
|
}),
|
||||||
owner,
|
owner,
|
||||||
@@ -273,8 +283,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
return entry.value
|
return entry.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(params.dir!, params.id))
|
const session = createMemo(() =>
|
||||||
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
|
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
|
||||||
|
)
|
||||||
|
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => session().ready,
|
ready: () => session().ready,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
|
|
||||||
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
|
let starts = 0
|
||||||
|
const start = () => starts++
|
||||||
|
|
||||||
|
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
|
||||||
|
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
|
||||||
|
|
||||||
|
expect(starts).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,11 +8,20 @@ import { useLanguage } from "./language"
|
|||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
|
import { useGlobal } from "./global"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
const isAbortError = (error: unknown) =>
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
|
|
||||||
function createServerSdkContext(server: ServerConnection.Any) {
|
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||||
|
|
||||||
|
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||||
|
if (!event.persisted) return
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
|
|
||||||
@@ -95,11 +104,10 @@ function createServerSdkContext(server: ServerConnection.Any) {
|
|||||||
|
|
||||||
let streamErrorLogged = false
|
let streamErrorLogged = false
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
const aborted = isAbortError
|
|
||||||
|
|
||||||
let attempt: AbortController | undefined
|
let attempt: AbortController | undefined
|
||||||
let run: Promise<void> | undefined
|
let run: Promise<void> | undefined
|
||||||
let started = false
|
let started = false
|
||||||
|
let generation = 0
|
||||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||||
let lastEventAt = Date.now()
|
let lastEventAt = Date.now()
|
||||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||||
@@ -119,9 +127,12 @@ function createServerSdkContext(server: ServerConnection.Any) {
|
|||||||
const start = () => {
|
const start = () => {
|
||||||
if (started) return run
|
if (started) return run
|
||||||
started = true
|
started = true
|
||||||
run = (async () => {
|
const active = ++generation
|
||||||
|
const previous = run
|
||||||
|
const current = (async () => {
|
||||||
|
if (previous) await previous
|
||||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||||
while (!abort.signal.aborted && started) {
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
attempt = new AbortController()
|
attempt = new AbortController()
|
||||||
lastEventAt = Date.now()
|
lastEventAt = Date.now()
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
@@ -132,7 +143,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
|
|||||||
const events = await eventSdk.global.event({
|
const events = await eventSdk.global.event({
|
||||||
signal: attempt.signal,
|
signal: attempt.signal,
|
||||||
onSseError: (error) => {
|
onSseError: (error) => {
|
||||||
if (aborted(error)) return
|
if (isStreamClosed(error, attempt?.signal)) return
|
||||||
if (streamErrorLogged) return
|
if (streamErrorLogged) return
|
||||||
streamErrorLogged = true
|
streamErrorLogged = true
|
||||||
console.error("[global-sdk] event stream error", {
|
console.error("[global-sdk] event stream error", {
|
||||||
@@ -175,7 +186,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
|
|||||||
await wait(0)
|
await wait(0)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!aborted(error) && !streamErrorLogged) {
|
if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
|
||||||
streamErrorLogged = true
|
streamErrorLogged = true
|
||||||
console.error("[global-sdk] event stream failed", {
|
console.error("[global-sdk] event stream failed", {
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
@@ -189,23 +200,28 @@ function createServerSdkContext(server: ServerConnection.Any) {
|
|||||||
clearHeartbeat()
|
clearHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abort.signal.aborted || !started) return
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
await wait(RECONNECT_DELAY_MS)
|
await wait(RECONNECT_DELAY_MS)
|
||||||
}
|
}
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
|
if (run !== current) return
|
||||||
run = undefined
|
run = undefined
|
||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
|
run = current
|
||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
started = false
|
started = false
|
||||||
|
generation++
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
clearHeartbeat()
|
clearHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
makeEventListener(window, "pagehide", stop)
|
||||||
|
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||||
makeEventListener(document, "visibilitychange", () => {
|
makeEventListener(document, "visibilitychange", () => {
|
||||||
if (document.visibilityState !== "visible") return
|
if (document.visibilityState !== "visible") return
|
||||||
if (!started) return
|
if (!started) return
|
||||||
@@ -227,6 +243,7 @@ function createServerSdkContext(server: ServerConnection.Any) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
scope,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
client: sdk,
|
client: sdk,
|
||||||
event: {
|
event: {
|
||||||
@@ -244,18 +261,22 @@ function createServerSdkContext(server: ServerConnection.Any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServerSDK = ReturnType<typeof createServerSdkContext>
|
||||||
|
|
||||||
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
|
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
|
||||||
name: "ServerSDK",
|
name: "ServerSDK",
|
||||||
init: () => {
|
init: (props: { server?: ServerConnection.Any }) => {
|
||||||
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
|
||||||
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
const conn = props.server ?? server.current
|
||||||
const sdk = createServerSdkContext(server.current)
|
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||||
return {
|
|
||||||
...sdk,
|
const ctx = global.createServerCtx(conn)
|
||||||
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
|
return Object.assign(ctx.sdk, {
|
||||||
}
|
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, ctx.sdk)),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -263,7 +284,7 @@ type SDKEventMap = {
|
|||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
|
||||||
const client = serverSDK.createClient({
|
const client = serverSDK.createClient({
|
||||||
directory,
|
directory,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
@@ -277,6 +298,7 @@ function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof cre
|
|||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
scope: serverSDK.scope,
|
||||||
directory,
|
directory,
|
||||||
client,
|
client,
|
||||||
event: emitter,
|
event: emitter,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { ServerSDK, useServerSDK } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
@@ -31,7 +31,12 @@ import { PathKey } from "@/utils/path-key"
|
|||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
|
import { useGlobal } from "./global"
|
||||||
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -47,35 +52,40 @@ type GlobalStore = {
|
|||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
|
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [directory, "mcp"] as const,
|
queryKey: [scope, directory, "mcp"] as const,
|
||||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
|
function makeQueryOptionsApi(
|
||||||
|
scope: ServerScope,
|
||||||
|
serverSDK: () => OpencodeClient,
|
||||||
|
sdkFor: (dir: PathKey) => OpencodeClient,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
|
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||||
projects: () => loadProjectsQuery(serverSDK()),
|
projects: () => loadProjectsQuery(scope, serverSDK()),
|
||||||
providers: (directory: PathKey | null) =>
|
providers: (directory: PathKey | null) =>
|
||||||
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
|
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||||
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
|
path: (directory: PathKey | null) =>
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
|
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
|
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
||||||
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
|
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||||
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||||
|
|
||||||
export function createServerSyncContext() {
|
export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||||
@@ -97,7 +107,7 @@ export function createServerSyncContext() {
|
|||||||
return sdk
|
return sdk
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
|
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
|
||||||
|
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||||
@@ -105,7 +115,7 @@ export function createServerSyncContext() {
|
|||||||
|
|
||||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||||
get ready() {
|
get ready() {
|
||||||
return bootstrap.isPending
|
return !bootstrap.isPending
|
||||||
},
|
},
|
||||||
project: [],
|
project: [],
|
||||||
session_todo: {},
|
session_todo: {},
|
||||||
@@ -128,6 +138,7 @@ export function createServerSyncContext() {
|
|||||||
return updateConfigMutation.isPending ? "pending" : undefined
|
return updateConfigMutation.isPending ? "pending" : undefined
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
let bootedAt = 0
|
let bootedAt = 0
|
||||||
@@ -153,10 +164,11 @@ export function createServerSyncContext() {
|
|||||||
}) as typeof setGlobalStore
|
}) as typeof setGlobalStore
|
||||||
|
|
||||||
const bootstrap = useQuery(() => ({
|
const bootstrap = useQuery(() => ({
|
||||||
queryKey: ["bootstrap"],
|
queryKey: [serverSDK.scope, "bootstrap"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await bootstrapGlobal({
|
await bootstrapGlobal({
|
||||||
serverSDK: serverSDK.client,
|
serverSDK: serverSDK.client,
|
||||||
|
scope: serverSDK.scope,
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
@@ -195,12 +207,14 @@ export function createServerSyncContext() {
|
|||||||
const queue = createRefreshQueue({
|
const queue = createRefreshQueue({
|
||||||
paused,
|
paused,
|
||||||
key: directoryKey,
|
key: directoryKey,
|
||||||
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
|
bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }),
|
||||||
bootstrapInstance,
|
bootstrapInstance,
|
||||||
})
|
})
|
||||||
|
|
||||||
const children = createChildStoreManager({
|
const children = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
|
scope: serverSDK.scope,
|
||||||
|
persist: persisted,
|
||||||
isBooting: (directory) => booting.has(directory),
|
isBooting: (directory) => booting.has(directory),
|
||||||
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
||||||
onBootstrap: (directory) => {
|
onBootstrap: (directory) => {
|
||||||
@@ -224,8 +238,8 @@ export function createServerSyncContext() {
|
|||||||
queue.clear(key)
|
queue.clear(key)
|
||||||
sessionMeta.delete(key)
|
sessionMeta.delete(key)
|
||||||
sdkCache.delete(key)
|
sdkCache.delete(key)
|
||||||
clearProviderRev(key)
|
clearProviderRev(serverSDK.scope, key)
|
||||||
clearSessionPrefetchDirectory(key)
|
clearSessionPrefetchDirectory(serverSDK.scope, key)
|
||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
@@ -234,17 +248,21 @@ export function createServerSyncContext() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadSessions(directory: string) {
|
async function loadSessions(directory: string, options?: { limit?: number }) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const pending = sessionLoads.get(key)
|
const pending = sessionLoads.get(key)
|
||||||
if (pending) return pending
|
if (pending) {
|
||||||
|
await pending
|
||||||
|
return loadSessions(directory, options)
|
||||||
|
}
|
||||||
|
|
||||||
children.pin(key)
|
children.pin(key)
|
||||||
const [store, setStore] = children.child(directory, { bootstrap: false })
|
const [store, setStore] = children.child(directory, { bootstrap: false })
|
||||||
const meta = sessionMeta.get(key)
|
const meta = sessionMeta.get(key)
|
||||||
if (meta && meta.limit >= store.limit) {
|
const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0)
|
||||||
|
if (meta && meta.limit >= retainedLimit) {
|
||||||
const next = trimSessions(store.session, {
|
const next = trimSessions(store.session, {
|
||||||
limit: store.limit,
|
limit: retainedLimit,
|
||||||
permission: store.permission,
|
permission: store.permission,
|
||||||
})
|
})
|
||||||
if (next.length !== store.session.length) {
|
if (next.length !== store.session.length) {
|
||||||
@@ -255,7 +273,7 @@ export function createServerSyncContext() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||||
const promise = queryClient
|
const promise = queryClient
|
||||||
.fetchQuery({
|
.fetchQuery({
|
||||||
...queryOptionsApi.sessions(key),
|
...queryOptionsApi.sessions(key),
|
||||||
@@ -270,7 +288,7 @@ export function createServerSyncContext() {
|
|||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
.filter((s) => !s.time?.archived)
|
.filter((s) => !s.time?.archived)
|
||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
const limit = store.limit
|
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
|
||||||
const childSessions = store.session.filter((s) => !!s.parentID)
|
const childSessions = store.session.filter((s) => !!s.parentID)
|
||||||
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
const sessions = trimSessions([...nonArchived, ...childSessions], {
|
||||||
limit,
|
limit,
|
||||||
@@ -325,6 +343,7 @@ export function createServerSyncContext() {
|
|||||||
const sdk = sdkFor(directory)
|
const sdk = sdkFor(directory)
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
|
scope: serverSDK.scope,
|
||||||
mcp: children.mcp(key),
|
mcp: children.mcp(key),
|
||||||
global: {
|
global: {
|
||||||
config: globalStore.config,
|
config: globalStore.config,
|
||||||
@@ -386,6 +405,7 @@ export function createServerSyncContext() {
|
|||||||
setStore,
|
setStore,
|
||||||
push: queue.push,
|
push: queue.push,
|
||||||
setSessionTodo,
|
setSessionTodo,
|
||||||
|
retainedLimit: sessionMeta.get(key)?.limit,
|
||||||
vcsCache: children.vcsCache.get(key),
|
vcsCache: children.vcsCache.get(key),
|
||||||
loadLsp: () => {
|
loadLsp: () => {
|
||||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||||
@@ -436,8 +456,10 @@ export function createServerSyncContext() {
|
|||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
// Invalidate all provider queries so newly configured custom providers
|
// Invalidate all provider queries so newly configured custom providers
|
||||||
// appear immediately in the available provider list across all directories.
|
// appear immediately in the available provider list across all directories.
|
||||||
queryClient.invalidateQueries({ queryKey: [null, "providers"] })
|
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
|
||||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -460,22 +482,55 @@ export function createServerSyncContext() {
|
|||||||
todo: {
|
todo: {
|
||||||
set: setSessionTodo,
|
set: setSessionTodo,
|
||||||
},
|
},
|
||||||
|
mcp: {
|
||||||
|
toggle: async (directory: string, name: string) => {
|
||||||
|
const key = directoryKey(directory)
|
||||||
|
const sdk = sdkFor(key)
|
||||||
|
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||||
|
await toggleMcp({
|
||||||
|
status,
|
||||||
|
connect: async () => {
|
||||||
|
await sdk.mcp.connect({ name })
|
||||||
|
},
|
||||||
|
disconnect: async () => {
|
||||||
|
await sdk.mcp.disconnect({ name })
|
||||||
|
},
|
||||||
|
authenticate: async () => {
|
||||||
|
await sdk.mcp.auth.authenticate({ name })
|
||||||
|
},
|
||||||
|
refresh: async () => {
|
||||||
|
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createServerSyncContext(_serverSDK?: ServerSDK) {
|
||||||
|
const inner = createServerSyncContextInner(_serverSDK)
|
||||||
|
return Object.assign(inner, {
|
||||||
|
createDirSyncContext: createRefCountMap(
|
||||||
|
(dir) => createDirSyncContext(dir, inner, _serverSDK),
|
||||||
|
(dir) => inner.disableMcp(dir),
|
||||||
|
directoryKey,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
||||||
name: "ServerSync",
|
name: "ServerSync",
|
||||||
init: () => {
|
gate: false,
|
||||||
const sync = createServerSyncContext()
|
init: (props: { server?: ServerConnection.Any }) => {
|
||||||
|
const global = useGlobal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
|
||||||
return {
|
const conn = props.server ?? server.current
|
||||||
...sync,
|
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
||||||
createDirSyncContext: createRefCountMap(
|
const ctx = global.createServerCtx(conn)
|
||||||
(dir) => createDirSyncContext(dir, sync),
|
|
||||||
(dir) => sync.disableMcp(dir),
|
return ctx.sync
|
||||||
directoryKey,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { resolveServerList, ServerConnection } from "./server"
|
import { createRoot, createSignal } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import {
|
||||||
|
createServerProjects,
|
||||||
|
migrateCanonicalLocalServerState,
|
||||||
|
nextServerAfterRemoval,
|
||||||
|
resolveServerList,
|
||||||
|
ServerConnection,
|
||||||
|
} from "./server"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
describe("resolveServerList", () => {
|
describe("resolveServerList", () => {
|
||||||
test("lets startup auth_token credentials override a persisted same-url server", () => {
|
test("lets startup auth_token credentials override a persisted same-url server", () => {
|
||||||
@@ -51,3 +60,104 @@ describe("resolveServerList", () => {
|
|||||||
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
|
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("treats WSL sidecars as remote server connections", () => {
|
||||||
|
expect(
|
||||||
|
ServerConnection.local({
|
||||||
|
type: "sidecar",
|
||||||
|
variant: "wsl",
|
||||||
|
distro: "Debian",
|
||||||
|
http: { url: "http://127.0.0.1:4097" },
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
expect(ServerConnection.local({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(true)
|
||||||
|
expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("active server removal falls back across built-in and persisted servers", () => {
|
||||||
|
const local = { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } } as const
|
||||||
|
const debian = {
|
||||||
|
type: "sidecar",
|
||||||
|
variant: "wsl",
|
||||||
|
distro: "Debian",
|
||||||
|
http: { url: "http://127.0.0.1:4097" },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(
|
||||||
|
nextServerAfterRemoval(
|
||||||
|
[local, debian],
|
||||||
|
ServerConnection.Key.make("wsl:Debian"),
|
||||||
|
ServerConnection.Key.make("sidecar"),
|
||||||
|
),
|
||||||
|
).toBe(ServerConnection.Key.make("sidecar"))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createServerProjects", () => {
|
||||||
|
test("keeps active and explicit server buckets in one reactive store", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [scope] = createSignal(ServerScope.local)
|
||||||
|
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
|
||||||
|
const active = createServerProjects({ scope, store, setStore })
|
||||||
|
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
|
||||||
|
|
||||||
|
remote.open("/repo")
|
||||||
|
expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }])
|
||||||
|
expect(active.list()).toEqual([])
|
||||||
|
|
||||||
|
const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
|
||||||
|
expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }])
|
||||||
|
|
||||||
|
adopted.close("/repo")
|
||||||
|
expect(remote.list()).toEqual([])
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("migrateCanonicalLocalServerState", () => {
|
||||||
|
test("moves an existing canonical web bucket into local scope", () => {
|
||||||
|
expect(
|
||||||
|
migrateCanonicalLocalServerState(
|
||||||
|
{
|
||||||
|
list: [],
|
||||||
|
projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] },
|
||||||
|
lastProject: { "https://opencode.example.com": "/remote" },
|
||||||
|
},
|
||||||
|
ServerConnection.Key.make("https://opencode.example.com"),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
list: [],
|
||||||
|
projects: { local: [{ worktree: "/remote", expanded: true }] },
|
||||||
|
lastProject: { local: "/remote" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves existing local state while merging a canonical web bucket", () => {
|
||||||
|
expect(
|
||||||
|
migrateCanonicalLocalServerState(
|
||||||
|
{
|
||||||
|
projects: {
|
||||||
|
local: [{ worktree: "/local", expanded: false }],
|
||||||
|
"https://opencode.example.com": [
|
||||||
|
{ worktree: "/local", expanded: true },
|
||||||
|
{ worktree: "/remote", expanded: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
lastProject: { local: "/local", "https://opencode.example.com": "/remote" },
|
||||||
|
},
|
||||||
|
ServerConnection.Key.make("https://opencode.example.com"),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
projects: {
|
||||||
|
local: [
|
||||||
|
{ worktree: "/local", expanded: false },
|
||||||
|
{ worktree: "/remote", expanded: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
lastProject: { local: "/local" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { type Accessor, batch, createMemo } from "solid-js"
|
import { type Accessor, batch, createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||||
|
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
|
||||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||||
|
|
||||||
export function normalizeServerUrl(input: string) {
|
export function normalizeServerUrl(input: string) {
|
||||||
@@ -20,18 +22,95 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals
|
|||||||
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectsKey(key: ServerConnection.Key) {
|
|
||||||
if (!key) return ""
|
|
||||||
if (key === "sidecar") return "local"
|
|
||||||
if (isLocalHost(key)) return "local"
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLocalHost(url: string) {
|
function isLocalHost(url: string) {
|
||||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
|
||||||
|
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
|
||||||
|
if (!isRecord(value)) return value
|
||||||
|
const projects = isRecord(value.projects) ? value.projects : undefined
|
||||||
|
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
|
||||||
|
const previousProjects = projects?.[canonicalLocalServer]
|
||||||
|
const previousLastProject = lastProject?.[canonicalLocalServer]
|
||||||
|
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
|
||||||
|
|
||||||
|
const next = { ...value }
|
||||||
|
if (projects && Array.isArray(previousProjects)) {
|
||||||
|
const local = Array.isArray(projects.local) ? projects.local : []
|
||||||
|
const worktrees = new Set(
|
||||||
|
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
|
||||||
|
)
|
||||||
|
const migrated = previousProjects.filter((project) => {
|
||||||
|
if (!isRecord(project) || typeof project.worktree !== "string") return true
|
||||||
|
if (worktrees.has(project.worktree)) return false
|
||||||
|
worktrees.add(project.worktree)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
|
||||||
|
delete nextProjects[canonicalLocalServer]
|
||||||
|
next.projects = nextProjects
|
||||||
|
}
|
||||||
|
if (lastProject && typeof previousLastProject === "string") {
|
||||||
|
const nextLastProject = { ...lastProject }
|
||||||
|
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
|
||||||
|
delete nextLastProject[canonicalLocalServer]
|
||||||
|
next.lastProject = nextLastProject
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerProjects<T extends ServerProjectState>(input: {
|
||||||
|
scope: Accessor<ServerScope>
|
||||||
|
store: Store<T>
|
||||||
|
setStore: SetStoreFunction<T>
|
||||||
|
}) {
|
||||||
|
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
|
||||||
|
const current = () => input.store.projects[input.scope()] ?? []
|
||||||
|
return {
|
||||||
|
list: current,
|
||||||
|
open(directory: string) {
|
||||||
|
const scope = input.scope()
|
||||||
|
if (current().some((project) => project.worktree === directory)) return
|
||||||
|
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
|
||||||
|
},
|
||||||
|
close(directory: string) {
|
||||||
|
setStore(
|
||||||
|
"projects",
|
||||||
|
input.scope(),
|
||||||
|
current().filter((project) => project.worktree !== directory),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
expand(directory: string) {
|
||||||
|
const index = current().findIndex((project) => project.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
|
||||||
|
},
|
||||||
|
collapse(directory: string) {
|
||||||
|
const index = current().findIndex((project) => project.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
|
||||||
|
},
|
||||||
|
move(directory: string, toIndex: number) {
|
||||||
|
const fromIndex = current().findIndex((project) => project.worktree === directory)
|
||||||
|
if (fromIndex === -1 || fromIndex === toIndex) return
|
||||||
|
const next = [...current()]
|
||||||
|
const [item] = next.splice(fromIndex, 1)
|
||||||
|
next.splice(toIndex, 0, item)
|
||||||
|
setStore("projects", input.scope(), next)
|
||||||
|
},
|
||||||
|
last() {
|
||||||
|
return input.store.lastProject[input.scope()]
|
||||||
|
},
|
||||||
|
touch(directory: string) {
|
||||||
|
setStore("lastProject", input.scope(), directory)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveServerList(input: {
|
export function resolveServerList(input: {
|
||||||
props?: Array<ServerConnection.Any>
|
props?: Array<ServerConnection.Any>
|
||||||
stored: StoredServer[]
|
stored: StoredServer[]
|
||||||
@@ -66,7 +145,7 @@ export function resolveServerList(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace ServerConnection {
|
export namespace ServerConnection {
|
||||||
type Base = { displayName?: string }
|
type Base = { displayName?: string; label?: string }
|
||||||
|
|
||||||
export type HttpBase = {
|
export type HttpBase = {
|
||||||
url: string
|
url: string
|
||||||
@@ -123,13 +202,35 @@ export namespace ServerConnection {
|
|||||||
|
|
||||||
export type Key = string & { _brand: "Key" }
|
export type Key = string & { _brand: "Key" }
|
||||||
export const Key = { make: (v: string) => v as Key }
|
export const Key = { make: (v: string) => v as Key }
|
||||||
|
|
||||||
|
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
|
||||||
|
export const local = (conn?: Any) =>
|
||||||
|
!!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextServerAfterRemoval(
|
||||||
|
servers: ServerConnection.Any[],
|
||||||
|
removed: ServerConnection.Key,
|
||||||
|
fallback: ServerConnection.Key,
|
||||||
|
) {
|
||||||
|
const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
|
||||||
|
const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
|
||||||
|
return next ? ServerConnection.key(next) : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
name: "Server",
|
name: "Server",
|
||||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
gate: true,
|
||||||
|
init: (props: {
|
||||||
|
defaultServer: ServerConnection.Key
|
||||||
|
canonicalLocalServer?: ServerConnection.Key
|
||||||
|
servers?: Array<ServerConnection.Any>
|
||||||
|
}) => {
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("server", ["server.v3"]),
|
{
|
||||||
|
...Persist.global("server", ["server.v3"]),
|
||||||
|
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
|
||||||
|
},
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as StoredServer[],
|
list: [] as StoredServer[],
|
||||||
projects: {} as Record<string, StoredProject[]>,
|
projects: {} as Record<string, StoredProject[]>,
|
||||||
@@ -168,27 +269,30 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function remove(key: ServerConnection.Key) {
|
function remove(key: ServerConnection.Key) {
|
||||||
|
const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
|
||||||
const list = store.list.filter((x) => url(x) !== key)
|
const list = store.list.filter((x) => url(x) !== key)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("list", list)
|
setStore("list", list)
|
||||||
if (state.active === key) {
|
if (state.active === key) setState("active", next)
|
||||||
const next = list[0]
|
|
||||||
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!state.active)
|
const isReady = createMemo(() => ready() && !!state.active)
|
||||||
|
|
||||||
const origin = createMemo(() => projectsKey(state.active))
|
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
|
||||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
const projects = createServerProjects({ scope, store, setStore })
|
||||||
|
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
|
||||||
|
const projectsForServer = (key: ServerConnection.Key) => {
|
||||||
|
const existing = projectStores.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
const next = createServerProjects({ scope: () => scope(key), store, setStore })
|
||||||
|
projectStores.set(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
||||||
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
|
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
|
||||||
)
|
)
|
||||||
const isLocal = createMemo(() => {
|
const isLocal = createMemo(() => ServerConnection.local(current()))
|
||||||
const c = current()
|
|
||||||
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: isReady,
|
ready: isReady,
|
||||||
@@ -208,60 +312,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
setActive,
|
setActive,
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
|
scope,
|
||||||
projects: {
|
projects: {
|
||||||
list: projectsList,
|
...projects,
|
||||||
open(directory: string) {
|
forServer: projectsForServer,
|
||||||
const key = origin()
|
|
||||||
if (!key) return
|
|
||||||
const current = store.projects[key] ?? []
|
|
||||||
if (current.find((x) => x.worktree === directory)) return
|
|
||||||
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
|
|
||||||
},
|
|
||||||
close(directory: string) {
|
|
||||||
const key = origin()
|
|
||||||
if (!key) return
|
|
||||||
const current = store.projects[key] ?? []
|
|
||||||
setStore(
|
|
||||||
"projects",
|
|
||||||
key,
|
|
||||||
current.filter((x) => x.worktree !== directory),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
expand(directory: string) {
|
|
||||||
const key = origin()
|
|
||||||
if (!key) return
|
|
||||||
const current = store.projects[key] ?? []
|
|
||||||
const index = current.findIndex((x) => x.worktree === directory)
|
|
||||||
if (index !== -1) setStore("projects", key, index, "expanded", true)
|
|
||||||
},
|
|
||||||
collapse(directory: string) {
|
|
||||||
const key = origin()
|
|
||||||
if (!key) return
|
|
||||||
const current = store.projects[key] ?? []
|
|
||||||
const index = current.findIndex((x) => x.worktree === directory)
|
|
||||||
if (index !== -1) setStore("projects", key, index, "expanded", false)
|
|
||||||
},
|
|
||||||
move(directory: string, toIndex: number) {
|
|
||||||
const key = origin()
|
|
||||||
if (!key) return
|
|
||||||
const current = store.projects[key] ?? []
|
|
||||||
const fromIndex = current.findIndex((x) => x.worktree === directory)
|
|
||||||
if (fromIndex === -1 || fromIndex === toIndex) return
|
|
||||||
const result = [...current]
|
|
||||||
const [item] = result.splice(fromIndex, 1)
|
|
||||||
result.splice(toIndex, 0, item)
|
|
||||||
setStore("projects", key, result)
|
|
||||||
},
|
|
||||||
last() {
|
|
||||||
const key = origin()
|
|
||||||
if (!key) return
|
|
||||||
return store.lastProject[key]
|
|
||||||
},
|
|
||||||
touch(directory: string) {
|
|
||||||
const key = origin()
|
|
||||||
if (!key) return
|
|
||||||
setStore("lastProject", key, directory)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
||||||
import { useServer } from "./server"
|
|
||||||
import { useServerHealth } from "@/utils/server-health"
|
|
||||||
|
|
||||||
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
|
|
||||||
name: "Servers",
|
|
||||||
init: () => {
|
|
||||||
const server = useServer()
|
|
||||||
|
|
||||||
const health = useServerHealth(
|
|
||||||
() => server.list,
|
|
||||||
() => true,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
list: () => server.list,
|
|
||||||
health,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -35,9 +35,6 @@ export interface Settings {
|
|||||||
showCustomAgents: boolean
|
showCustomAgents: boolean
|
||||||
newLayoutDesigns?: boolean
|
newLayoutDesigns?: boolean
|
||||||
}
|
}
|
||||||
updates: {
|
|
||||||
startup: boolean
|
|
||||||
}
|
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
mono: string
|
mono: string
|
||||||
@@ -122,9 +119,6 @@ const defaultSettings: Settings = {
|
|||||||
showSessionProgressBar: true,
|
showSessionProgressBar: true,
|
||||||
showCustomAgents: false,
|
showCustomAgents: false,
|
||||||
},
|
},
|
||||||
updates: {
|
|
||||||
startup: true,
|
|
||||||
},
|
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
mono: "",
|
mono: "",
|
||||||
@@ -156,6 +150,7 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
|
|||||||
|
|
||||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||||
|
|
||||||
@@ -249,12 +244,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("general", "newLayoutDesigns", value)
|
setStore("general", "newLayoutDesigns", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
updates: {
|
|
||||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
|
||||||
setStartup(value: boolean) {
|
|
||||||
setStore("updates", "startup", value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||||
setFontSize(value: number) {
|
setFontSize(value: number) {
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||||
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
import { createEffect, startTransition } from "solid-js"
|
||||||
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { usePlatform } from "./platform"
|
||||||
|
import { uuid } from "@/utils/uuid"
|
||||||
|
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||||
|
|
||||||
|
export type SessionTab = {
|
||||||
|
type: "session"
|
||||||
|
server: ServerConnection.Key
|
||||||
|
dirBase64: string
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DraftTab = {
|
||||||
|
type: "draft"
|
||||||
|
draftID: string
|
||||||
|
server: ServerConnection.Key
|
||||||
|
directory: string
|
||||||
|
worktree?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Tab = SessionTab | DraftTab
|
||||||
|
|
||||||
|
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||||
|
|
||||||
|
export const tabHref = (tab: Tab) =>
|
||||||
|
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
|
||||||
|
|
||||||
|
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
|
||||||
|
|
||||||
|
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
||||||
|
const dirBase64 = base64Encode(session.directory)
|
||||||
|
return tabs.some(
|
||||||
|
(tab) =>
|
||||||
|
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
|
name: "Tabs",
|
||||||
|
gate: false,
|
||||||
|
init: () => {
|
||||||
|
const server = useServer()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const fallback = server.key
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
{
|
||||||
|
...Persist.global("tabs"),
|
||||||
|
migrate: (value: unknown) => {
|
||||||
|
if (!Array.isArray(value)) return value
|
||||||
|
return value.map((tab) => {
|
||||||
|
if (!tab || typeof tab !== "object" || "server" in tab) return tab
|
||||||
|
return { ...tab, server: fallback }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createStore<Tab[]>([]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const closing = new Set<string>()
|
||||||
|
|
||||||
|
const removeDraftPersisted = (draftID: string) => {
|
||||||
|
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
const servers = new Set(server.list.map(ServerConnection.key))
|
||||||
|
if (store.every((tab) => servers.has(tab.server))) return
|
||||||
|
setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const navigateTab = (tab: Tab) => {
|
||||||
|
const href = tabHref(tab)
|
||||||
|
if (tab.server === server.key) {
|
||||||
|
navigate(href)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void startTransition(() => {
|
||||||
|
server.setActive(tab.server)
|
||||||
|
navigate(href)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||||
|
const next = { type: "session" as const, ...tab }
|
||||||
|
if (closing.has(tabKey(next))) return
|
||||||
|
setStore(
|
||||||
|
produce((tabs) => {
|
||||||
|
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
||||||
|
tabs.push(next)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
draft(draftID: string) {
|
||||||
|
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||||
|
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||||
|
return tab
|
||||||
|
},
|
||||||
|
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||||
|
const draftID = uuid()
|
||||||
|
setStore(
|
||||||
|
produce((tabs) => {
|
||||||
|
tabs.push({ type: "draft", draftID, ...draft })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||||
|
},
|
||||||
|
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||||
|
setStore(
|
||||||
|
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||||
|
produce((tab) => Object.assign(tab, draft)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||||
|
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
|
||||||
|
// replaces the draft tab with a session tab, so the draft route would stop resolving
|
||||||
|
// and fall back home. Navigate to the new session first so we leave /new-session
|
||||||
|
// before the draft is removed from the store.
|
||||||
|
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||||
|
startTransition(() => {
|
||||||
|
setStore(
|
||||||
|
produce((tabs) => {
|
||||||
|
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||||
|
if (index !== -1) tabs[index] = { type: "session", ...session }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (active) navigateTab({ type: "session", ...session })
|
||||||
|
})
|
||||||
|
removeDraftPersisted(draftID)
|
||||||
|
},
|
||||||
|
removeTab: (index: number) => {
|
||||||
|
const tab = store[index]
|
||||||
|
if (!tab) return
|
||||||
|
const key = tabKey(tab)
|
||||||
|
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||||
|
const nextTab = store[index + 1] ?? store[index - 1]
|
||||||
|
closing.add(key)
|
||||||
|
void startTransition(() => {
|
||||||
|
setStore(
|
||||||
|
produce((tabs) => {
|
||||||
|
tabs.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (nextTab) navigateTab(nextTab)
|
||||||
|
else navigate("/")
|
||||||
|
}).finally(() => closing.delete(key))
|
||||||
|
if (draftID) removeDraftPersisted(draftID)
|
||||||
|
},
|
||||||
|
removeServer(key: ServerConnection.Key) {
|
||||||
|
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||||
|
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||||
|
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||||
|
if (server.key === key) navigate("/")
|
||||||
|
},
|
||||||
|
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||||
|
void startTransition(() => {
|
||||||
|
setStore(
|
||||||
|
produce((tabs) => {
|
||||||
|
const sessionIDs = new Set(input.sessionIDs)
|
||||||
|
const currentHref =
|
||||||
|
params.dir && params.id
|
||||||
|
? tabHref({
|
||||||
|
type: "session",
|
||||||
|
server: server.key,
|
||||||
|
dirBase64: params.dir,
|
||||||
|
sessionId: params.id,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
const currentIndex = currentHref
|
||||||
|
? tabs.findIndex(
|
||||||
|
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
|
||||||
|
)
|
||||||
|
: -1
|
||||||
|
const currentTab = tabs[currentIndex]
|
||||||
|
const removedCurrent =
|
||||||
|
currentTab?.type === "session" &&
|
||||||
|
currentTab.server === server.key &&
|
||||||
|
atob(currentTab.dirBase64) === input.directory &&
|
||||||
|
sessionIDs.has(currentTab.sessionId)
|
||||||
|
|
||||||
|
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||||
|
const tab = tabs[i]
|
||||||
|
if (!tab || tab.type !== "session") continue
|
||||||
|
if (tab.server !== server.key) continue
|
||||||
|
if (atob(tab.dirBase64) !== input.directory) continue
|
||||||
|
if (!sessionIDs.has(tab.sessionId)) continue
|
||||||
|
tabs.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!removedCurrent) return
|
||||||
|
const nextTab =
|
||||||
|
tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
|
||||||
|
tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
|
||||||
|
if (nextTab) navigateTab(nextTab)
|
||||||
|
else navigate("/")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...actions, store, ready }
|
||||||
|
},
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user