mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-02 10:46:46 +00:00
Merge remote-tracking branch 'origin/dev' into sqlite2
This commit is contained in:
2
.github/actions/setup-bun/action.yml
vendored
2
.github/actions/setup-bun/action.yml
vendored
@@ -6,7 +6,7 @@ runs:
|
|||||||
- name: Mount Bun Cache
|
- name: Mount Bun Cache
|
||||||
uses: useblacksmith/stickydisk@v1
|
uses: useblacksmith/stickydisk@v1
|
||||||
with:
|
with:
|
||||||
key: ${{ github.repository }}-bun-cache
|
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
||||||
path: ~/.bun
|
path: ~/.bun
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
|||||||
135
.github/workflows/test.yml
vendored
135
.github/workflows/test.yml
vendored
@@ -7,8 +7,32 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
unit:
|
||||||
name: test (${{ matrix.settings.name }})
|
name: unit (linux)
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Configure git identity
|
||||||
|
run: |
|
||||||
|
git config --global user.email "bot@opencode.ai"
|
||||||
|
git config --global user.name "opencode"
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: bun turbo test
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: e2e (${{ matrix.settings.name }})
|
||||||
|
needs: unit
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -16,17 +40,12 @@ jobs:
|
|||||||
- name: linux
|
- name: linux
|
||||||
host: blacksmith-4vcpu-ubuntu-2404
|
host: blacksmith-4vcpu-ubuntu-2404
|
||||||
playwright: bunx playwright install --with-deps
|
playwright: bunx playwright install --with-deps
|
||||||
workdir: .
|
|
||||||
command: |
|
|
||||||
git config --global user.email "bot@opencode.ai"
|
|
||||||
git config --global user.name "opencode"
|
|
||||||
bun turbo test
|
|
||||||
- name: windows
|
- name: windows
|
||||||
host: windows-latest
|
host: blacksmith-4vcpu-windows-2025
|
||||||
playwright: bunx playwright install
|
playwright: bunx playwright install
|
||||||
workdir: packages/app
|
|
||||||
command: bun test:e2e:local
|
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH: 0
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -43,87 +62,10 @@ jobs:
|
|||||||
working-directory: packages/app
|
working-directory: packages/app
|
||||||
run: ${{ matrix.settings.playwright }}
|
run: ${{ matrix.settings.playwright }}
|
||||||
|
|
||||||
- name: Set OS-specific paths
|
- name: Run app e2e tests
|
||||||
run: |
|
run: bun --cwd packages/app test:e2e:local
|
||||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
|
||||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
|
|
||||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Seed opencode data
|
|
||||||
if: matrix.settings.name != 'windows'
|
|
||||||
working-directory: packages/opencode
|
|
||||||
run: bun script/seed-e2e.ts
|
|
||||||
env:
|
|
||||||
OPENCODE_DISABLE_SHARE: "true"
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
|
||||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
|
||||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
|
||||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
|
||||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
|
||||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
|
||||||
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
|
|
||||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
|
|
||||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
|
|
||||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
|
|
||||||
|
|
||||||
- name: Run opencode server
|
|
||||||
if: matrix.settings.name != 'windows'
|
|
||||||
working-directory: packages/opencode
|
|
||||||
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
|
|
||||||
env:
|
|
||||||
OPENCODE_DISABLE_SHARE: "true"
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
|
||||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
|
||||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
|
||||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
|
||||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
|
||||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
|
||||||
OPENCODE_CLIENT: "app"
|
|
||||||
|
|
||||||
- name: Wait for opencode server
|
|
||||||
if: matrix.settings.name != 'windows'
|
|
||||||
run: |
|
|
||||||
for i in {1..120}; do
|
|
||||||
curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: run
|
|
||||||
working-directory: ${{ matrix.settings.workdir }}
|
|
||||||
run: ${{ matrix.settings.command }}
|
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
OPENCODE_DISABLE_SHARE: "true"
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
|
||||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
|
||||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
|
||||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
|
||||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
|
||||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
|
||||||
PLAYWRIGHT_SERVER_HOST: "127.0.0.1"
|
|
||||||
PLAYWRIGHT_SERVER_PORT: "4096"
|
|
||||||
VITE_OPENCODE_SERVER_HOST: "127.0.0.1"
|
|
||||||
VITE_OPENCODE_SERVER_PORT: "4096"
|
|
||||||
OPENCODE_CLIENT: "app"
|
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
@@ -136,3 +78,18 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|
||||||
|
required:
|
||||||
|
name: test (linux)
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
needs:
|
||||||
|
- unit
|
||||||
|
- e2e
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Verify upstream test jobs passed
|
||||||
|
run: |
|
||||||
|
echo "unit=${{ needs.unit.result }}"
|
||||||
|
echo "e2e=${{ needs.e2e.result }}"
|
||||||
|
test "${{ needs.unit.result }}" = "success"
|
||||||
|
test "${{ needs.e2e.result }}" = "success"
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sst-env.d.ts
|
sst-env.d.ts
|
||||||
desktop/src/bindings.ts
|
packages/desktop/src/bindings.ts
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
- 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.
|
||||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||||
|
|
||||||
## Style Guide
|
## Style Guide
|
||||||
|
|||||||
14
bun.lock
14
bun.lock
@@ -188,6 +188,7 @@
|
|||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
|
"@solidjs/meta": "catalog:",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-deep-link": "~2",
|
"@tauri-apps/plugin-deep-link": "~2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
@@ -286,7 +287,7 @@
|
|||||||
"@ai-sdk/vercel": "1.0.33",
|
"@ai-sdk/vercel": "1.0.33",
|
||||||
"@ai-sdk/xai": "2.0.51",
|
"@ai-sdk/xai": "2.0.51",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@gitlab/gitlab-ai-provider": "3.4.0",
|
"@gitlab/gitlab-ai-provider": "3.5.0",
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.2",
|
"@gitlab/opencode-gitlab-auth": "1.3.2",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
@@ -499,6 +500,9 @@
|
|||||||
"web-tree-sitter",
|
"web-tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
],
|
],
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
@@ -518,7 +522,7 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.5",
|
"@types/bun": "1.3.8",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
@@ -961,7 +965,7 @@
|
|||||||
|
|
||||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||||
|
|
||||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="],
|
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-OoAwCz4fOci3h/2l+PRHMclclh3IaFq8w1es2wvBJ8ca7vtglKsBYT7dvmYpsXlu7pg9mopbjcexvmVCQEUTAQ=="],
|
||||||
|
|
||||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="],
|
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="],
|
||||||
|
|
||||||
@@ -1843,7 +1847,7 @@
|
|||||||
|
|
||||||
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
@@ -2159,7 +2163,7 @@
|
|||||||
|
|
||||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||||
|
|
||||||
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-ufEpxjmlJeft9tI+WxxO+Zbh1pdAaLOURCDBpoQqR0w=",
|
"x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=",
|
||||||
"aarch64-linux": "sha256-z3K6W5oYZNUdV0rjoAZjvNQcifM5bXamLIrD+ZvJ4kA=",
|
"aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=",
|
||||||
"aarch64-darwin": "sha256-+QikplmNhxGF2Nd4L1BG/xyl+24GVhDYMTtK6xCKy/s=",
|
"aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=",
|
||||||
"x86_64-darwin": "sha256-hAcrCT2X02ymwgj/0BAmD2gF66ylGYzbfcqPta/LVEU="
|
"x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@types/bun": "1.3.5",
|
"@types/bun": "1.3.8",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
@@ -102,5 +102,7 @@
|
|||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {}
|
"patchedDependencies": {
|
||||||
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./vite": "./vite.js"
|
"./vite": "./vite.js",
|
||||||
|
"./index.css": "./src/index.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsgo -b",
|
"typecheck": "tsgo -b",
|
||||||
@@ -13,7 +14,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "playwright test",
|
"test": "bun run test:unit",
|
||||||
|
"test:unit": "bun test --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": "bun script/e2e-local.ts",
|
"test:e2e:local": "bun script/e2e-local.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe.skip("SerializeAddon", () => {
|
describe("SerializeAddon", () => {
|
||||||
describe("ANSI color preservation", () => {
|
describe("ANSI color preservation", () => {
|
||||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||||
const { term, addon } = createTerminal()
|
const { term, addon } = createTerminal()
|
||||||
|
|||||||
@@ -56,6 +56,39 @@ interface IBufferCell {
|
|||||||
isDim(): boolean
|
isDim(): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalBuffers = {
|
||||||
|
active?: IBuffer
|
||||||
|
normal?: IBuffer
|
||||||
|
alternate?: IBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBuffer = (value: unknown): value is IBuffer => {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
if (typeof value.length !== "number") return false
|
||||||
|
if (typeof value.cursorX !== "number") return false
|
||||||
|
if (typeof value.cursorY !== "number") return false
|
||||||
|
if (typeof value.baseY !== "number") return false
|
||||||
|
if (typeof value.viewportY !== "number") return false
|
||||||
|
if (typeof value.getLine !== "function") return false
|
||||||
|
if (typeof value.getNullCell !== "function") return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => {
|
||||||
|
if (!isRecord(value)) return
|
||||||
|
const raw = value.buffer
|
||||||
|
if (!isRecord(raw)) return
|
||||||
|
const active = isBuffer(raw.active) ? raw.active : undefined
|
||||||
|
const normal = isBuffer(raw.normal) ? raw.normal : undefined
|
||||||
|
const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined
|
||||||
|
if (!active && !normal) return
|
||||||
|
return { active, normal, alternate }
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -498,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon {
|
|||||||
throw new Error("Cannot use addon until it has been loaded")
|
throw new Error("Cannot use addon until it has been loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminal = this._terminal as any
|
const buffer = getTerminalBuffers(this._terminal)
|
||||||
const buffer = terminal.buffer
|
|
||||||
|
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalBuffer = buffer.normal || buffer.active
|
const normalBuffer = buffer.normal ?? buffer.active
|
||||||
const altBuffer = buffer.alternate
|
const altBuffer = buffer.alternate
|
||||||
|
|
||||||
if (!normalBuffer) {
|
if (!normalBuffer) {
|
||||||
@@ -533,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon {
|
|||||||
throw new Error("Cannot use addon until it has been loaded")
|
throw new Error("Cannot use addon until it has been loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminal = this._terminal as any
|
const buffer = getTerminalBuffers(this._terminal)
|
||||||
const buffer = terminal.buffer
|
|
||||||
|
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeBuffer = buffer.active || buffer.normal
|
const activeBuffer = buffer.active ?? buffer.normal
|
||||||
if (!activeBuffer) {
|
if (!activeBuffer) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights"
|
|||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Suspense } from "solid-js"
|
import { Suspense, JSX } from "solid-js"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
@@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppInterface(props: { defaultUrl?: string }) {
|
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const stored = (() => {
|
const stored = (() => {
|
||||||
@@ -111,7 +111,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
|||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Router
|
<Router
|
||||||
root={(props) => (
|
root={(routerProps) => (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
@@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
|||||||
<ModelsProvider>
|
<ModelsProvider>
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
<HighlightsProvider>
|
<HighlightsProvider>
|
||||||
<Layout>{props.children}</Layout>
|
<Layout>
|
||||||
|
{props.children}
|
||||||
|
{routerProps.children}
|
||||||
|
</Layout>
|
||||||
</HighlightsProvider>
|
</HighlightsProvider>
|
||||||
</CommandProvider>
|
</CommandProvider>
|
||||||
</ModelsProvider>
|
</ModelsProvider>
|
||||||
|
|||||||
@@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const key = apiKey && !env ? apiKey : undefined
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
const idError = !providerID
|
const idError = !providerID
|
||||||
? "Provider ID is required"
|
? language.t("provider.custom.error.providerID.required")
|
||||||
: !PROVIDER_ID.test(providerID)
|
: !PROVIDER_ID.test(providerID)
|
||||||
? "Use lowercase letters, numbers, hyphens, or underscores"
|
? language.t("provider.custom.error.providerID.format")
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const nameError = !name ? "Display name is required" : undefined
|
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
|
||||||
const urlError = !baseURL
|
const urlError = !baseURL
|
||||||
? "Base URL is required"
|
? language.t("provider.custom.error.baseURL.required")
|
||||||
: !/^https?:\/\//.test(baseURL)
|
: !/^https?:\/\//.test(baseURL)
|
||||||
? "Must start with http:// or https://"
|
? language.t("provider.custom.error.baseURL.format")
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
||||||
@@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const existsError = idError
|
const existsError = idError
|
||||||
? undefined
|
? undefined
|
||||||
: existingProvider && !disabled
|
: existingProvider && !disabled
|
||||||
? "That provider ID already exists"
|
? language.t("provider.custom.error.providerID.exists")
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const seenModels = new Set<string>()
|
const seenModels = new Set<string>()
|
||||||
const modelErrors = form.models.map((m) => {
|
const modelErrors = form.models.map((m) => {
|
||||||
const id = m.id.trim()
|
const id = m.id.trim()
|
||||||
const modelIdError = !id
|
const modelIdError = !id
|
||||||
? "Required"
|
? language.t("provider.custom.error.required")
|
||||||
: seenModels.has(id)
|
: seenModels.has(id)
|
||||||
? "Duplicate"
|
? language.t("provider.custom.error.duplicate")
|
||||||
: (() => {
|
: (() => {
|
||||||
seenModels.add(id)
|
seenModels.add(id)
|
||||||
return undefined
|
return undefined
|
||||||
})()
|
})()
|
||||||
const modelNameError = !m.name.trim() ? "Required" : undefined
|
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
|
||||||
return { id: modelIdError, name: modelNameError }
|
return { id: modelIdError, name: modelNameError }
|
||||||
})
|
})
|
||||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||||
@@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
|
|
||||||
if (!key && !value) return {}
|
if (!key && !value) return {}
|
||||||
const keyError = !key
|
const keyError = !key
|
||||||
? "Required"
|
? language.t("provider.custom.error.required")
|
||||||
: seenHeaders.has(key.toLowerCase())
|
: seenHeaders.has(key.toLowerCase())
|
||||||
? "Duplicate"
|
? language.t("provider.custom.error.duplicate")
|
||||||
: (() => {
|
: (() => {
|
||||||
seenHeaders.add(key.toLowerCase())
|
seenHeaders.add(key.toLowerCase())
|
||||||
return undefined
|
return undefined
|
||||||
})()
|
})()
|
||||||
const valueError = !value ? "Required" : undefined
|
const valueError = !value ? language.t("provider.custom.error.required") : undefined
|
||||||
return { key: keyError, value: valueError }
|
return { key: keyError, value: valueError }
|
||||||
})
|
})
|
||||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||||
@@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
<div class="text-16-medium text-text-strong">Custom provider</div>
|
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||||
<p class="text-14-regular text-text-base">
|
<p class="text-14-regular text-text-base">
|
||||||
Configure an OpenAI-compatible provider. See the{" "}
|
{language.t("provider.custom.description.prefix")}
|
||||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||||
provider config docs
|
{language.t("provider.custom.description.link")}
|
||||||
</Link>
|
</Link>
|
||||||
.
|
{language.t("provider.custom.description.suffix")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus
|
||||||
label="Provider ID"
|
label={language.t("provider.custom.field.providerID.label")}
|
||||||
placeholder="myprovider"
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description="Lowercase letters, numbers, hyphens, or underscores"
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={setForm.bind(null, "providerID")}
|
onChange={setForm.bind(null, "providerID")}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={errors.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={errors.providerID}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Display name"
|
label={language.t("provider.custom.field.name.label")}
|
||||||
placeholder="My AI Provider"
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={setForm.bind(null, "name")}
|
onChange={setForm.bind(null, "name")}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={errors.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Base URL"
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
placeholder="https://api.myprovider.com/v1"
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={setForm.bind(null, "baseURL")}
|
onChange={setForm.bind(null, "baseURL")}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={errors.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={errors.baseURL}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="API key"
|
label={language.t("provider.custom.field.apiKey.label")}
|
||||||
placeholder="API key"
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
description="Optional. Leave empty if you manage auth via headers."
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={setForm.bind(null, "apiKey")}
|
onChange={setForm.bind(null, "apiKey")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<label class="text-12-medium text-text-weak">Models</label>
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||||
<For each={form.models}>
|
<For each={form.models}>
|
||||||
{(m, i) => (
|
{(m, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label="ID"
|
label={language.t("provider.custom.models.id.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder="model-id"
|
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||||
value={m.id}
|
value={m.id}
|
||||||
onChange={(v) => setForm("models", i(), "id", v)}
|
onChange={(v) => setForm("models", i(), "id", v)}
|
||||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||||
@@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label={language.t("provider.custom.models.name.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder="Display Name"
|
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||||
value={m.name}
|
value={m.name}
|
||||||
onChange={(v) => setForm("models", i(), "name", v)}
|
onChange={(v) => setForm("models", i(), "name", v)}
|
||||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||||
@@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
class="mt-1.5"
|
class="mt-1.5"
|
||||||
onClick={() => removeModel(i())}
|
onClick={() => removeModel(i())}
|
||||||
disabled={form.models.length <= 1}
|
disabled={form.models.length <= 1}
|
||||||
aria-label="Remove model"
|
aria-label={language.t("provider.custom.models.remove")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||||
Add model
|
{language.t("provider.custom.models.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<label class="text-12-medium text-text-weak">Headers (optional)</label>
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||||
<For each={form.headers}>
|
<For each={form.headers}>
|
||||||
{(h, i) => (
|
{(h, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label="Header"
|
label={language.t("provider.custom.headers.key.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder="Header-Name"
|
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||||
value={h.key}
|
value={h.key}
|
||||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||||
@@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label="Value"
|
label={language.t("provider.custom.headers.value.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder="value"
|
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||||
value={h.value}
|
value={h.value}
|
||||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||||
@@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
class="mt-1.5"
|
class="mt-1.5"
|
||||||
onClick={() => removeHeader(i())}
|
onClick={() => removeHeader(i())}
|
||||||
disabled={form.headers.length <= 1}
|
disabled={form.headers.length <= 1}
|
||||||
aria-label="Remove header"
|
aria-label={language.t("provider.custom.headers.remove")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||||
Add header
|
{language.t("provider.custom.headers.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||||
{form.saving ? "Saving..." : language.t("common.submit")}
|
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
value={store.startup}
|
value={store.startup}
|
||||||
onChange={(v) => setStore("startup", v)}
|
onChange={(v) => setStore("startup", v)}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
class="max-h-40 w-full font-mono text-xs no-scrollbar"
|
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
|
||||||
type EntryType = "command" | "file" | "session"
|
type EntryType = "command" | "file" | "session"
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ type Entry = {
|
|||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
archived?: number
|
archived?: number
|
||||||
|
updated?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogSelectFileMode = "all" | "files"
|
type DialogSelectFileMode = "all" | "files"
|
||||||
@@ -120,6 +122,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
archived?: number
|
archived?: number
|
||||||
|
updated?: number
|
||||||
}): Entry => ({
|
}): Entry => ({
|
||||||
id: `session:${input.directory}:${input.id}`,
|
id: `session:${input.directory}:${input.id}`,
|
||||||
type: "session",
|
type: "session",
|
||||||
@@ -129,6 +132,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
sessionID: input.id,
|
sessionID: input.id,
|
||||||
archived: input.archived,
|
archived: input.archived,
|
||||||
|
updated: input.updated,
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = createMemo(() => allowed().map(commandItem))
|
const list = createMemo(() => allowed().map(commandItem))
|
||||||
@@ -214,6 +218,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
description,
|
description,
|
||||||
directory,
|
directory,
|
||||||
archived: s.time?.archived,
|
archived: s.time?.archived,
|
||||||
|
updated: s.time?.updated,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
||||||
@@ -384,6 +389,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={item.updated}>
|
||||||
|
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
||||||
|
{getRelativeTime(new Date(item.updated!).toISOString())}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -87,11 +87,13 @@ const ModelList: Component<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
|
||||||
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
triggerAs?: T
|
triggerAs?: ValidComponent
|
||||||
triggerProps?: ComponentProps<T>
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -176,11 +178,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={8}
|
gutter={8}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger
|
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
ref={(el) => setStore("trigger", el)}
|
|
||||||
as={props.triggerAs ?? "div"}
|
|
||||||
{...(props.triggerProps as any)}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
|
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
import { normalizeServerUrl, useServer } from "@/context/server"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { ServerRow } from "@/components/server/server-row"
|
||||||
type ServerStatus = { healthy: boolean; version?: string }
|
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface AddRowProps {
|
interface AddRowProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -40,19 +38,6 @@ interface EditRowProps {
|
|||||||
onBlur: () => void
|
onBlur: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
|
||||||
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
|
|
||||||
const sdk = createOpencodeClient({
|
|
||||||
baseUrl: url,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
return sdk.global
|
|
||||||
.health()
|
|
||||||
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
|
||||||
.catch(() => ({ healthy: false }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddRow(props: AddRowProps) {
|
function AddRow(props: AddRowProps) {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||||
@@ -131,7 +116,7 @@ export function DialogSelectServer() {
|
|||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<string, ServerStatus | undefined>,
|
status: {} as Record<string, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
adding: false,
|
adding: false,
|
||||||
@@ -165,6 +150,7 @@ export function DialogSelectServer() {
|
|||||||
{ initialValue: null },
|
{ initialValue: null },
|
||||||
)
|
)
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||||
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
const looksComplete = (value: string) => {
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
@@ -180,7 +166,7 @@ export function DialogSelectServer() {
|
|||||||
if (!looksComplete(value)) return
|
if (!looksComplete(value)) return
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) return
|
if (!normalized) return
|
||||||
const result = await checkHealth(normalized, platform)
|
const result = await checkServerHealth(normalized, fetcher)
|
||||||
setStatus(result.healthy)
|
setStatus(result.healthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +213,7 @@ export function DialogSelectServer() {
|
|||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
const rank = (value?: ServerStatus) => {
|
const rank = (value?: ServerHealth) => {
|
||||||
if (value?.healthy === true) return 0
|
if (value?.healthy === true) return 0
|
||||||
if (value?.healthy === false) return 2
|
if (value?.healthy === false) return 2
|
||||||
return 1
|
return 1
|
||||||
@@ -242,10 +228,10 @@ export function DialogSelectServer() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function refreshHealth() {
|
async function refreshHealth() {
|
||||||
const results: Record<string, ServerStatus> = {}
|
const results: Record<string, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items().map(async (url) => {
|
items().map(async (url) => {
|
||||||
results[url] = await checkHealth(url, platform)
|
results[url] = await checkServerHealth(url, fetcher)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
@@ -300,7 +286,7 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
setStore("addServer", { adding: true, error: "" })
|
||||||
|
|
||||||
const result = await checkHealth(normalized, platform)
|
const result = await checkServerHealth(normalized, fetcher)
|
||||||
setStore("addServer", { adding: false })
|
setStore("addServer", { adding: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
@@ -327,7 +313,7 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
setStore("editServer", { busy: true, error: "" })
|
||||||
|
|
||||||
const result = await checkHealth(normalized, platform)
|
const result = await checkServerHealth(normalized, fetcher)
|
||||||
setStore("editServer", { busy: false })
|
setStore("editServer", { busy: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
@@ -369,6 +355,9 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
async function handleRemove(url: string) {
|
async function handleRemove(url: string) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
|
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||||
|
platform.setDefaultServerUrl?.(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -410,35 +399,6 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const [truncated, setTruncated] = createSignal(false)
|
|
||||||
let nameRef: HTMLSpanElement | undefined
|
|
||||||
let versionRef: HTMLSpanElement | undefined
|
|
||||||
|
|
||||||
const check = () => {
|
|
||||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
|
||||||
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
|
||||||
setTruncated(nameTruncated || versionTruncated)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
check()
|
|
||||||
window.addEventListener("resize", check)
|
|
||||||
onCleanup(() => window.removeEventListener("resize", check))
|
|
||||||
})
|
|
||||||
|
|
||||||
const tooltipValue = () => {
|
|
||||||
const name = serverDisplayName(i)
|
|
||||||
const version = store.status[i]?.version
|
|
||||||
return (
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<span>{name}</span>
|
|
||||||
<Show when={version}>
|
|
||||||
<span class="text-text-invert-base">{version}</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||||
<Show
|
<Show
|
||||||
@@ -456,34 +416,19 @@ export function DialogSelectServer() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
<ServerRow
|
||||||
<div
|
url={i}
|
||||||
|
status={store.status[i]}
|
||||||
|
dimmed={store.status[i]?.healthy === false}
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||||
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
badge={
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"size-1.5 rounded-full shrink-0": true,
|
|
||||||
"bg-icon-success-base": store.status[i]?.healthy === true,
|
|
||||||
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
|
||||||
"bg-border-weak-base": store.status[i] === undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span ref={nameRef} class="truncate">
|
|
||||||
{serverDisplayName(i)}
|
|
||||||
</span>
|
|
||||||
<Show when={store.status[i]?.version}>
|
|
||||||
<span ref={versionRef} class="text-text-weak text-14-regular truncate">
|
|
||||||
{store.status[i]?.version}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={defaultUrl() === i}>
|
<Show when={defaultUrl() === i}>
|
||||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-weak 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>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
}
|
||||||
</Tooltip>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={store.editServer.id !== i}>
|
<Show when={store.editServer.id !== i}>
|
||||||
<div class="flex items-center justify-center gap-5 pl-4">
|
<div class="flex items-center justify-center gap-5 pl-4">
|
||||||
|
|||||||
77
packages/app/src/components/file-tree.test.ts
Normal file
77
packages/app/src/components/file-tree.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
let shouldListRoot: typeof import("./file-tree").shouldListRoot
|
||||||
|
let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
|
||||||
|
let dirsToExpand: typeof import("./file-tree").dirsToExpand
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mock.module("@solidjs/router", () => ({
|
||||||
|
useParams: () => ({}),
|
||||||
|
}))
|
||||||
|
mock.module("@/context/file", () => ({
|
||||||
|
useFile: () => ({
|
||||||
|
tree: {
|
||||||
|
state: () => undefined,
|
||||||
|
list: () => Promise.resolve(),
|
||||||
|
children: () => [],
|
||||||
|
expand: () => {},
|
||||||
|
collapse: () => {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
mock.module("@opencode-ai/ui/collapsible", () => ({
|
||||||
|
Collapsible: {
|
||||||
|
Trigger: (props: { children?: unknown }) => props.children,
|
||||||
|
Content: (props: { children?: unknown }) => props.children,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
|
||||||
|
mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
|
||||||
|
mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
|
||||||
|
const mod = await import("./file-tree")
|
||||||
|
shouldListRoot = mod.shouldListRoot
|
||||||
|
shouldListExpanded = mod.shouldListExpanded
|
||||||
|
dirsToExpand = mod.dirsToExpand
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("file tree fetch discipline", () => {
|
||||||
|
test("root lists on mount unless already loaded or loading", () => {
|
||||||
|
expect(shouldListRoot({ level: 0 })).toBe(true)
|
||||||
|
expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
|
||||||
|
expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
|
||||||
|
expect(shouldListRoot({ level: 1 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("nested dirs list only when expanded and stale", () => {
|
||||||
|
expect(shouldListExpanded({ level: 1 })).toBe(false)
|
||||||
|
expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
|
||||||
|
expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
|
||||||
|
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
|
||||||
|
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
|
||||||
|
expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allowed auto-expand picks only collapsed dirs", () => {
|
||||||
|
const expanded = new Set<string>()
|
||||||
|
const filter = { dirs: new Set(["src", "src/components"]) }
|
||||||
|
|
||||||
|
const first = dirsToExpand({
|
||||||
|
level: 0,
|
||||||
|
filter,
|
||||||
|
expanded: (dir) => expanded.has(dir),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(first).toEqual(["src", "src/components"])
|
||||||
|
|
||||||
|
for (const dir of first) expanded.add(dir)
|
||||||
|
|
||||||
|
const second = dirsToExpand({
|
||||||
|
level: 0,
|
||||||
|
filter,
|
||||||
|
expanded: (dir) => expanded.has(dir),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(second).toEqual([])
|
||||||
|
expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createMemo,
|
createMemo,
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
|
on,
|
||||||
Show,
|
Show,
|
||||||
splitProps,
|
splitProps,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -18,6 +19,14 @@ import {
|
|||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
function pathToFileUrl(filepath: string): string {
|
||||||
|
const encodedPath = filepath
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/")
|
||||||
|
return `file://${encodedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
type Kind = "add" | "del" | "mix"
|
type Kind = "add" | "del" | "mix"
|
||||||
|
|
||||||
type Filter = {
|
type Filter = {
|
||||||
@@ -25,6 +34,34 @@ type Filter = {
|
|||||||
dirs: Set<string>
|
dirs: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
|
||||||
|
if (input.level !== 0) return false
|
||||||
|
if (input.dir?.loaded) return false
|
||||||
|
if (input.dir?.loading) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldListExpanded(input: {
|
||||||
|
level: number
|
||||||
|
dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
|
||||||
|
}) {
|
||||||
|
if (input.level === 0) return false
|
||||||
|
if (!input.dir?.expanded) return false
|
||||||
|
if (input.dir.loaded) return false
|
||||||
|
if (input.dir.loading) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dirsToExpand(input: {
|
||||||
|
level: number
|
||||||
|
filter?: { dirs: Set<string> }
|
||||||
|
expanded: (dir: string) => boolean
|
||||||
|
}) {
|
||||||
|
if (input.level !== 0) return []
|
||||||
|
if (!input.filter) return []
|
||||||
|
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
||||||
|
}
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
path: string
|
path: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -111,19 +148,30 @@ export default function FileTree(props: {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = filter()
|
const current = filter()
|
||||||
if (!current) return
|
const dirs = dirsToExpand({
|
||||||
if (level !== 0) return
|
level,
|
||||||
|
filter: current,
|
||||||
for (const dir of current.dirs) {
|
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||||
const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
|
})
|
||||||
if (expanded) continue
|
for (const dir of dirs) file.tree.expand(dir)
|
||||||
file.tree.expand(dir)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.path,
|
||||||
|
(path) => {
|
||||||
|
const dir = untrack(() => file.tree.state(path))
|
||||||
|
if (!shouldListRoot({ level, dir })) return
|
||||||
|
void file.tree.list(path)
|
||||||
|
},
|
||||||
|
{ defer: false },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const path = props.path
|
const dir = file.tree.state(props.path)
|
||||||
untrack(() => void file.tree.list(path))
|
if (!shouldListExpanded({ level, dir })) return
|
||||||
|
void file.tree.list(props.path)
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodes = createMemo(() => {
|
const nodes = createMemo(() => {
|
||||||
@@ -207,7 +255,7 @@ export default function FileTree(props: {
|
|||||||
onDragStart={(e: DragEvent) => {
|
onDragStart={(e: DragEvent) => {
|
||||||
if (!draggable()) return
|
if (!draggable()) return
|
||||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||||
e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
|
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
||||||
|
|
||||||
const dragImage = document.createElement("div")
|
const dragImage = document.createElement("div")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
132
packages/app/src/components/prompt-input/attachments.ts
Normal file
132
packages/app/src/components/prompt-input/attachments.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { onCleanup, onMount } from "solid-js"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getCursorPosition } from "./editor-dom"
|
||||||
|
|
||||||
|
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
|
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||||
|
|
||||||
|
type PromptAttachmentsInput = {
|
||||||
|
editor: () => HTMLDivElement | undefined
|
||||||
|
isFocused: () => boolean
|
||||||
|
isDialogActive: () => boolean
|
||||||
|
setDragging: (value: boolean) => void
|
||||||
|
addPart: (part: ContentPart) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const addImageAttachment = async (file: File) => {
|
||||||
|
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const editor = input.editor()
|
||||||
|
if (!editor) return
|
||||||
|
const dataUrl = reader.result as string
|
||||||
|
const attachment: ImageAttachmentPart = {
|
||||||
|
type: "image",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
filename: file.name,
|
||||||
|
mime: file.type,
|
||||||
|
dataUrl,
|
||||||
|
}
|
||||||
|
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
|
||||||
|
prompt.set([...prompt.current(), attachment], cursorPosition)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeImageAttachment = (id: string) => {
|
||||||
|
const current = prompt.current()
|
||||||
|
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||||
|
prompt.set(next, prompt.cursor())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
|
if (!input.isFocused()) return
|
||||||
|
const clipboardData = event.clipboardData
|
||||||
|
if (!clipboardData) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const items = Array.from(clipboardData.items)
|
||||||
|
const fileItems = items.filter((item) => item.kind === "file")
|
||||||
|
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||||
|
|
||||||
|
if (imageItems.length > 0) {
|
||||||
|
for (const item of imageItems) {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) await addImageAttachment(file)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileItems.length > 0) {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||||
|
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||||
|
if (!plainText) return
|
||||||
|
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
|
if (input.isDialogActive()) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const hasFiles = event.dataTransfer?.types.includes("Files")
|
||||||
|
if (hasFiles) {
|
||||||
|
input.setDragging(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGlobalDragLeave = (event: DragEvent) => {
|
||||||
|
if (input.isDialogActive()) return
|
||||||
|
if (!event.relatedTarget) {
|
||||||
|
input.setDragging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGlobalDrop = async (event: DragEvent) => {
|
||||||
|
if (input.isDialogActive()) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
input.setDragging(false)
|
||||||
|
|
||||||
|
const dropped = event.dataTransfer?.files
|
||||||
|
if (!dropped) return
|
||||||
|
|
||||||
|
for (const file of Array.from(dropped)) {
|
||||||
|
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
||||||
|
await addImageAttachment(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener("dragover", handleGlobalDragOver)
|
||||||
|
document.addEventListener("dragleave", handleGlobalDragLeave)
|
||||||
|
document.addEventListener("drop", handleGlobalDrop)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
document.removeEventListener("dragover", handleGlobalDragOver)
|
||||||
|
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
||||||
|
document.removeEventListener("drop", handleGlobalDrop)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
addImageAttachment,
|
||||||
|
removeImageAttachment,
|
||||||
|
handlePaste,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
|
|
||||||
|
describe("buildRequestParts", () => {
|
||||||
|
test("builds typed request and optimistic parts without cast path", () => {
|
||||||
|
const prompt: Prompt = [
|
||||||
|
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "src/foo.ts",
|
||||||
|
content: "@src/foo.ts",
|
||||||
|
start: 5,
|
||||||
|
end: 16,
|
||||||
|
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
||||||
|
},
|
||||||
|
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = buildRequestParts({
|
||||||
|
prompt,
|
||||||
|
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||||
|
images: [
|
||||||
|
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||||
|
],
|
||||||
|
text: "hello @src/foo.ts @planner",
|
||||||
|
messageID: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.requestParts[0]?.type).toBe("text")
|
||||||
|
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
|
||||||
|
expect(
|
||||||
|
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
||||||
|
).toBe(true)
|
||||||
|
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
||||||
|
|
||||||
|
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||||
|
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deduplicates context files when prompt already includes same path", () => {
|
||||||
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
const result = buildRequestParts({
|
||||||
|
prompt,
|
||||||
|
context: [
|
||||||
|
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||||
|
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
text: "@src/foo.ts",
|
||||||
|
messageID: "msg_2",
|
||||||
|
sessionID: "ses_2",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
const fooFiles = result.requestParts.filter(
|
||||||
|
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
|
||||||
|
)
|
||||||
|
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
|
||||||
|
|
||||||
|
expect(fooFiles).toHaveLength(2)
|
||||||
|
expect(synthetic).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
180
packages/app/src/components/prompt-input/build-request-parts.ts
Normal file
180
packages/app/src/components/prompt-input/build-request-parts.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { FileSelection } from "@/context/file"
|
||||||
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
|
import { Identifier } from "@/utils/id"
|
||||||
|
|
||||||
|
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||||
|
|
||||||
|
type ContextFile = {
|
||||||
|
key: string
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
comment?: string
|
||||||
|
commentID?: string
|
||||||
|
commentOrigin?: "review" | "file"
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildRequestPartsInput = {
|
||||||
|
prompt: Prompt
|
||||||
|
context: ContextFile[]
|
||||||
|
images: ImageAttachmentPart[]
|
||||||
|
text: string
|
||||||
|
messageID: string
|
||||||
|
sessionID: string
|
||||||
|
sessionDirectory: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = (directory: string, path: string) =>
|
||||||
|
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
|
||||||
|
|
||||||
|
const encodeFilePath = (filepath: string): string =>
|
||||||
|
filepath
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/")
|
||||||
|
|
||||||
|
const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
|
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||||
|
|
||||||
|
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||||
|
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||||
|
|
||||||
|
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
|
||||||
|
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
|
||||||
|
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
|
||||||
|
const range =
|
||||||
|
start === undefined || end === undefined
|
||||||
|
? "this file"
|
||||||
|
: start === end
|
||||||
|
? `line ${start}`
|
||||||
|
: `lines ${start} through ${end}`
|
||||||
|
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
||||||
|
if (part.type === "text") {
|
||||||
|
return {
|
||||||
|
id: part.id,
|
||||||
|
type: "text",
|
||||||
|
text: part.text,
|
||||||
|
synthetic: part.synthetic,
|
||||||
|
ignored: part.ignored,
|
||||||
|
time: part.time,
|
||||||
|
metadata: part.metadata,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (part.type === "file") {
|
||||||
|
return {
|
||||||
|
id: part.id,
|
||||||
|
type: "file",
|
||||||
|
mime: part.mime,
|
||||||
|
filename: part.filename,
|
||||||
|
url: part.url,
|
||||||
|
source: part.source,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: part.id,
|
||||||
|
type: "agent",
|
||||||
|
name: part.name,
|
||||||
|
source: part.source,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
|
const requestParts: PromptRequestPart[] = [
|
||||||
|
{
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "text",
|
||||||
|
text: input.text,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||||
|
const path = absolute(input.sessionDirectory, attachment.path)
|
||||||
|
return {
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||||
|
filename: getFilename(attachment.path),
|
||||||
|
source: {
|
||||||
|
type: "file",
|
||||||
|
text: {
|
||||||
|
value: attachment.content,
|
||||||
|
start: attachment.start,
|
||||||
|
end: attachment.end,
|
||||||
|
},
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
} satisfies PromptRequestPart
|
||||||
|
})
|
||||||
|
|
||||||
|
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
||||||
|
return {
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "agent",
|
||||||
|
name: attachment.name,
|
||||||
|
source: {
|
||||||
|
value: attachment.content,
|
||||||
|
start: attachment.start,
|
||||||
|
end: attachment.end,
|
||||||
|
},
|
||||||
|
} satisfies PromptRequestPart
|
||||||
|
})
|
||||||
|
|
||||||
|
const used = new Set(files.map((part) => part.url))
|
||||||
|
const context = input.context.flatMap((item) => {
|
||||||
|
const path = absolute(input.sessionDirectory, item.path)
|
||||||
|
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||||
|
const comment = item.comment?.trim()
|
||||||
|
if (!comment && used.has(url)) return []
|
||||||
|
used.add(url)
|
||||||
|
|
||||||
|
const filePart = {
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url,
|
||||||
|
filename: getFilename(item.path),
|
||||||
|
} satisfies PromptRequestPart
|
||||||
|
|
||||||
|
if (!comment) return [filePart]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "text",
|
||||||
|
text: commentNote(item.path, item.selection, comment),
|
||||||
|
synthetic: true,
|
||||||
|
} satisfies PromptRequestPart,
|
||||||
|
filePart,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const images = input.images.map((attachment) => {
|
||||||
|
return {
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file",
|
||||||
|
mime: attachment.mime,
|
||||||
|
url: attachment.dataUrl,
|
||||||
|
filename: attachment.filename,
|
||||||
|
} satisfies PromptRequestPart
|
||||||
|
})
|
||||||
|
|
||||||
|
requestParts.push(...files, ...context, ...agents, ...images)
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestParts,
|
||||||
|
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
|
||||||
|
}
|
||||||
|
}
|
||||||
82
packages/app/src/components/prompt-input/context-items.tsx
Normal file
82
packages/app/src/components/prompt-input/context-items.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Component, For, Show } from "solid-js"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||||
|
import type { ContextItem } from "@/context/prompt"
|
||||||
|
|
||||||
|
type PromptContextItem = ContextItem & { key: string }
|
||||||
|
|
||||||
|
type ContextItemsProps = {
|
||||||
|
items: PromptContextItem[]
|
||||||
|
active: (item: PromptContextItem) => boolean
|
||||||
|
openComment: (item: PromptContextItem) => void
|
||||||
|
remove: (item: PromptContextItem) => void
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Show when={props.items.length > 0}>
|
||||||
|
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||||
|
<For each={props.items}>
|
||||||
|
{(item) => (
|
||||||
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<span class="flex max-w-[300px]">
|
||||||
|
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||||
|
{getDirectory(item.path)}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0">{getFilename(item.path)}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
openDelay={2000}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||||
|
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
|
||||||
|
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||||
|
props.active(item),
|
||||||
|
"bg-background-stronger": !props.active(item),
|
||||||
|
}}
|
||||||
|
onClick={() => props.openComment(item)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
|
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||||
|
<Show when={item.selection}>
|
||||||
|
{(sel) => (
|
||||||
|
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||||
|
{sel().startLine === sel().endLine
|
||||||
|
? `:${sel().startLine}`
|
||||||
|
: `:${sel().startLine}-${sel().endLine}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.remove(item)
|
||||||
|
}}
|
||||||
|
aria-label={props.t("prompt.context.removeFile")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={item.comment}>
|
||||||
|
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
packages/app/src/components/prompt-input/drag-overlay.tsx
Normal file
20
packages/app/src/components/prompt-input/drag-overlay.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Component, Show } from "solid-js"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
|
||||||
|
type PromptDragOverlayProps = {
|
||||||
|
dragging: boolean
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Show when={props.dragging}>
|
||||||
|
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||||
|
<Icon name="photo" class="size-8" />
|
||||||
|
<span class="text-14-regular">{props.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
packages/app/src/components/prompt-input/editor-dom.test.ts
Normal file
51
packages/app/src/components/prompt-input/editor-dom.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
||||||
|
|
||||||
|
describe("prompt-input editor dom", () => {
|
||||||
|
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
|
||||||
|
const fragment = createTextFragment("foo\n\nbar")
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(fragment)
|
||||||
|
|
||||||
|
expect(container.childNodes.length).toBe(5)
|
||||||
|
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||||
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
|
expect(container.childNodes[2]?.textContent).toBe("\u200B")
|
||||||
|
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
|
||||||
|
expect(container.childNodes[4]?.textContent).toBe("bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(document.createTextNode("ab\u200B"))
|
||||||
|
container.appendChild(document.createElement("br"))
|
||||||
|
container.appendChild(document.createTextNode("cd"))
|
||||||
|
|
||||||
|
expect(getNodeLength(container.childNodes[0]!)).toBe(2)
|
||||||
|
expect(getNodeLength(container.childNodes[1]!)).toBe(1)
|
||||||
|
expect(getTextLength(container)).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
|
||||||
|
const container = document.createElement("div")
|
||||||
|
const pill = document.createElement("span")
|
||||||
|
pill.dataset.type = "file"
|
||||||
|
pill.textContent = "@file"
|
||||||
|
container.appendChild(document.createTextNode("ab"))
|
||||||
|
container.appendChild(pill)
|
||||||
|
container.appendChild(document.createElement("br"))
|
||||||
|
container.appendChild(document.createTextNode("cd"))
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
setCursorPosition(container, 2)
|
||||||
|
expect(getCursorPosition(container)).toBe(2)
|
||||||
|
|
||||||
|
setCursorPosition(container, 7)
|
||||||
|
expect(getCursorPosition(container)).toBe(7)
|
||||||
|
|
||||||
|
setCursorPosition(container, 8)
|
||||||
|
expect(getCursorPosition(container)).toBe(8)
|
||||||
|
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
135
packages/app/src/components/prompt-input/editor-dom.ts
Normal file
135
packages/app/src/components/prompt-input/editor-dom.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
export function createTextFragment(content: string): DocumentFragment {
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
const segments = content.split("\n")
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
if (segment) {
|
||||||
|
fragment.appendChild(document.createTextNode(segment))
|
||||||
|
} else if (segments.length > 1) {
|
||||||
|
fragment.appendChild(document.createTextNode("\u200B"))
|
||||||
|
}
|
||||||
|
if (index < segments.length - 1) {
|
||||||
|
fragment.appendChild(document.createElement("br"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeLength(node: Node): number {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||||
|
return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextLength(node: Node): number {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||||
|
let length = 0
|
||||||
|
for (const child of Array.from(node.childNodes)) {
|
||||||
|
length += getTextLength(child)
|
||||||
|
}
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCursorPosition(parent: HTMLElement): number {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return 0
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!parent.contains(range.startContainer)) return 0
|
||||||
|
const preCaretRange = range.cloneRange()
|
||||||
|
preCaretRange.selectNodeContents(parent)
|
||||||
|
preCaretRange.setEnd(range.startContainer, range.startOffset)
|
||||||
|
return getTextLength(preCaretRange.cloneContents())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCursorPosition(parent: HTMLElement, position: number) {
|
||||||
|
let remaining = position
|
||||||
|
let node = parent.firstChild
|
||||||
|
while (node) {
|
||||||
|
const length = getNodeLength(node)
|
||||||
|
const isText = node.nodeType === Node.TEXT_NODE
|
||||||
|
const isPill =
|
||||||
|
node.nodeType === Node.ELEMENT_NODE &&
|
||||||
|
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||||
|
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||||
|
|
||||||
|
if (isText && remaining <= length) {
|
||||||
|
const range = document.createRange()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
range.setStart(node, remaining)
|
||||||
|
range.collapse(true)
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isPill || isBreak) && remaining <= length) {
|
||||||
|
const range = document.createRange()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStartBefore(node)
|
||||||
|
}
|
||||||
|
if (remaining > 0 && isPill) {
|
||||||
|
range.setStartAfter(node)
|
||||||
|
}
|
||||||
|
if (remaining > 0 && isBreak) {
|
||||||
|
const next = node.nextSibling
|
||||||
|
if (next && next.nodeType === Node.TEXT_NODE) {
|
||||||
|
range.setStart(next, 0)
|
||||||
|
}
|
||||||
|
if (!next || next.nodeType !== Node.TEXT_NODE) {
|
||||||
|
range.setStartAfter(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
range.collapse(true)
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= length
|
||||||
|
node = node.nextSibling
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRange = document.createRange()
|
||||||
|
const fallbackSelection = window.getSelection()
|
||||||
|
const last = parent.lastChild
|
||||||
|
if (last && last.nodeType === Node.TEXT_NODE) {
|
||||||
|
const len = last.textContent ? last.textContent.length : 0
|
||||||
|
fallbackRange.setStart(last, len)
|
||||||
|
}
|
||||||
|
if (!last || last.nodeType !== Node.TEXT_NODE) {
|
||||||
|
fallbackRange.selectNodeContents(parent)
|
||||||
|
}
|
||||||
|
fallbackRange.collapse(false)
|
||||||
|
fallbackSelection?.removeAllRanges()
|
||||||
|
fallbackSelection?.addRange(fallbackRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
|
||||||
|
let remaining = offset
|
||||||
|
const nodes = Array.from(parent.childNodes)
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const length = getNodeLength(node)
|
||||||
|
const isText = node.nodeType === Node.TEXT_NODE
|
||||||
|
const isPill =
|
||||||
|
node.nodeType === Node.ELEMENT_NODE &&
|
||||||
|
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||||
|
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||||
|
|
||||||
|
if (isText && remaining <= length) {
|
||||||
|
if (edge === "start") range.setStart(node, remaining)
|
||||||
|
if (edge === "end") range.setEnd(node, remaining)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isPill || isBreak) && remaining <= length) {
|
||||||
|
if (edge === "start" && remaining === 0) range.setStartBefore(node)
|
||||||
|
if (edge === "start" && remaining > 0) range.setStartAfter(node)
|
||||||
|
if (edge === "end" && remaining === 0) range.setEndBefore(node)
|
||||||
|
if (edge === "end" && remaining > 0) range.setEndAfter(node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= length
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/app/src/components/prompt-input/history.test.ts
Normal file
69
packages/app/src/components/prompt-input/history.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
|
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||||
|
|
||||||
|
describe("prompt-input history", () => {
|
||||||
|
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
|
||||||
|
const first = prependHistoryEntry([], DEFAULT_PROMPT)
|
||||||
|
expect(first).toEqual([])
|
||||||
|
|
||||||
|
const withOne = prependHistoryEntry([], text("hello"))
|
||||||
|
expect(withOne).toHaveLength(1)
|
||||||
|
|
||||||
|
const deduped = prependHistoryEntry(withOne, text("hello"))
|
||||||
|
expect(deduped).toBe(withOne)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||||
|
const entries = [text("third"), text("second"), text("first")]
|
||||||
|
const up = navigatePromptHistory({
|
||||||
|
direction: "up",
|
||||||
|
entries,
|
||||||
|
historyIndex: -1,
|
||||||
|
currentPrompt: text("draft"),
|
||||||
|
savedPrompt: null,
|
||||||
|
})
|
||||||
|
expect(up.handled).toBe(true)
|
||||||
|
if (!up.handled) throw new Error("expected handled")
|
||||||
|
expect(up.historyIndex).toBe(0)
|
||||||
|
expect(up.cursor).toBe("start")
|
||||||
|
|
||||||
|
const down = navigatePromptHistory({
|
||||||
|
direction: "down",
|
||||||
|
entries,
|
||||||
|
historyIndex: up.historyIndex,
|
||||||
|
currentPrompt: text("ignored"),
|
||||||
|
savedPrompt: up.savedPrompt,
|
||||||
|
})
|
||||||
|
expect(down.handled).toBe(true)
|
||||||
|
if (!down.handled) throw new Error("expected handled")
|
||||||
|
expect(down.historyIndex).toBe(-1)
|
||||||
|
expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("helpers clone prompt and count text content length", () => {
|
||||||
|
const original: Prompt = [
|
||||||
|
{ type: "text", content: "one", start: 0, end: 3 },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "src/a.ts",
|
||||||
|
content: "@src/a.ts",
|
||||||
|
start: 3,
|
||||||
|
end: 12,
|
||||||
|
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||||
|
},
|
||||||
|
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
|
||||||
|
]
|
||||||
|
const copy = clonePromptParts(original)
|
||||||
|
expect(copy).not.toBe(original)
|
||||||
|
expect(promptLength(copy)).toBe(12)
|
||||||
|
if (copy[1]?.type !== "file") throw new Error("expected file")
|
||||||
|
copy[1].selection!.startLine = 9
|
||||||
|
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||||
|
expect(original[1].selection?.startLine).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
160
packages/app/src/components/prompt-input/history.ts
Normal file
160
packages/app/src/components/prompt-input/history.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
|
export const MAX_HISTORY = 100
|
||||||
|
|
||||||
|
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||||
|
return prompt.map((part) => {
|
||||||
|
if (part.type === "text") return { ...part }
|
||||||
|
if (part.type === "image") return { ...part }
|
||||||
|
if (part.type === "agent") return { ...part }
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
selection: part.selection ? { ...part.selection } : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptLength(prompt: Prompt) {
|
||||||
|
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
|
||||||
|
const text = prompt
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
.trim()
|
||||||
|
const hasImages = prompt.some((part) => part.type === "image")
|
||||||
|
if (!text && !hasImages) return entries
|
||||||
|
|
||||||
|
const entry = clonePromptParts(prompt)
|
||||||
|
const last = entries[0]
|
||||||
|
if (last && isPromptEqual(last, entry)) return entries
|
||||||
|
return [entry, ...entries].slice(0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromptEqual(promptA: Prompt, promptB: Prompt) {
|
||||||
|
if (promptA.length !== promptB.length) return false
|
||||||
|
for (let i = 0; i < promptA.length; i++) {
|
||||||
|
const partA = promptA[i]
|
||||||
|
const partB = promptB[i]
|
||||||
|
if (partA.type !== partB.type) return false
|
||||||
|
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
|
||||||
|
if (partA.type === "file") {
|
||||||
|
if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
|
||||||
|
const a = partA.selection
|
||||||
|
const b = partB.type === "file" ? partB.selection : undefined
|
||||||
|
const sameSelection =
|
||||||
|
(!a && !b) ||
|
||||||
|
(!!a &&
|
||||||
|
!!b &&
|
||||||
|
a.startLine === b.startLine &&
|
||||||
|
a.startChar === b.startChar &&
|
||||||
|
a.endLine === b.endLine &&
|
||||||
|
a.endChar === b.endChar)
|
||||||
|
if (!sameSelection) return false
|
||||||
|
}
|
||||||
|
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
|
||||||
|
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryNavInput = {
|
||||||
|
direction: "up" | "down"
|
||||||
|
entries: Prompt[]
|
||||||
|
historyIndex: number
|
||||||
|
currentPrompt: Prompt
|
||||||
|
savedPrompt: Prompt | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryNavResult =
|
||||||
|
| {
|
||||||
|
handled: false
|
||||||
|
historyIndex: number
|
||||||
|
savedPrompt: Prompt | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
handled: true
|
||||||
|
historyIndex: number
|
||||||
|
savedPrompt: Prompt | null
|
||||||
|
prompt: Prompt
|
||||||
|
cursor: "start" | "end"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
|
||||||
|
if (input.direction === "up") {
|
||||||
|
if (input.entries.length === 0) {
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
historyIndex: input.historyIndex,
|
||||||
|
savedPrompt: input.savedPrompt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.historyIndex === -1) {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
historyIndex: 0,
|
||||||
|
savedPrompt: clonePromptParts(input.currentPrompt),
|
||||||
|
prompt: input.entries[0],
|
||||||
|
cursor: "start",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.historyIndex < input.entries.length - 1) {
|
||||||
|
const next = input.historyIndex + 1
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
historyIndex: next,
|
||||||
|
savedPrompt: input.savedPrompt,
|
||||||
|
prompt: input.entries[next],
|
||||||
|
cursor: "start",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
historyIndex: input.historyIndex,
|
||||||
|
savedPrompt: input.savedPrompt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.historyIndex > 0) {
|
||||||
|
const next = input.historyIndex - 1
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
historyIndex: next,
|
||||||
|
savedPrompt: input.savedPrompt,
|
||||||
|
prompt: input.entries[next],
|
||||||
|
cursor: "end",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.historyIndex === 0) {
|
||||||
|
if (input.savedPrompt) {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
historyIndex: -1,
|
||||||
|
savedPrompt: null,
|
||||||
|
prompt: input.savedPrompt,
|
||||||
|
cursor: "end",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
historyIndex: -1,
|
||||||
|
savedPrompt: null,
|
||||||
|
prompt: DEFAULT_PROMPT,
|
||||||
|
cursor: "end",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
historyIndex: input.historyIndex,
|
||||||
|
savedPrompt: input.savedPrompt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Component, For, Show } from "solid-js"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
|
||||||
|
type PromptImageAttachmentsProps = {
|
||||||
|
attachments: ImageAttachmentPart[]
|
||||||
|
onOpen: (attachment: ImageAttachmentPart) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
removeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Show when={props.attachments.length > 0}>
|
||||||
|
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||||
|
<For each={props.attachments}>
|
||||||
|
{(attachment) => (
|
||||||
|
<div class="relative group">
|
||||||
|
<Show
|
||||||
|
when={attachment.mime.startsWith("image/")}
|
||||||
|
fallback={
|
||||||
|
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
||||||
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={attachment.dataUrl}
|
||||||
|
alt={attachment.filename}
|
||||||
|
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||||
|
onClick={() => props.onOpen(attachment)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onRemove(attachment.id)}
|
||||||
|
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||||
|
aria-label={props.removeLabel}
|
||||||
|
>
|
||||||
|
<Icon name="close" class="size-3 text-text-weak" />
|
||||||
|
</button>
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
||||||
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
packages/app/src/components/prompt-input/placeholder.test.ts
Normal file
35
packages/app/src/components/prompt-input/placeholder.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { promptPlaceholder } from "./placeholder"
|
||||||
|
|
||||||
|
describe("promptPlaceholder", () => {
|
||||||
|
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
|
||||||
|
|
||||||
|
test("returns shell placeholder in shell mode", () => {
|
||||||
|
const value = promptPlaceholder({
|
||||||
|
mode: "shell",
|
||||||
|
commentCount: 0,
|
||||||
|
example: "example",
|
||||||
|
t,
|
||||||
|
})
|
||||||
|
expect(value).toBe("prompt.placeholder.shell")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns summarize placeholders for comment context", () => {
|
||||||
|
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
|
||||||
|
"prompt.placeholder.summarizeComment",
|
||||||
|
)
|
||||||
|
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
|
||||||
|
"prompt.placeholder.summarizeComments",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns default placeholder with example", () => {
|
||||||
|
const value = promptPlaceholder({
|
||||||
|
mode: "normal",
|
||||||
|
commentCount: 0,
|
||||||
|
example: "translated-example",
|
||||||
|
t,
|
||||||
|
})
|
||||||
|
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
||||||
|
})
|
||||||
|
})
|
||||||
13
packages/app/src/components/prompt-input/placeholder.ts
Normal file
13
packages/app/src/components/prompt-input/placeholder.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
type PromptPlaceholderInput = {
|
||||||
|
mode: "normal" | "shell"
|
||||||
|
commentCount: number
|
||||||
|
example: string
|
||||||
|
t: (key: string, params?: Record<string, string>) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptPlaceholder(input: PromptPlaceholderInput) {
|
||||||
|
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
|
||||||
|
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
||||||
|
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
||||||
|
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||||
|
}
|
||||||
144
packages/app/src/components/prompt-input/slash-popover.tsx
Normal file
144
packages/app/src/components/prompt-input/slash-popover.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { Component, For, Match, Show, Switch } from "solid-js"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
|
export type AtOption =
|
||||||
|
| { type: "agent"; name: string; display: string }
|
||||||
|
| { type: "file"; path: string; display: string; recent?: boolean }
|
||||||
|
|
||||||
|
export interface SlashCommand {
|
||||||
|
id: string
|
||||||
|
trigger: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
keybind?: string
|
||||||
|
type: "builtin" | "custom"
|
||||||
|
source?: "command" | "mcp" | "skill"
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptPopoverProps = {
|
||||||
|
popover: "at" | "slash" | null
|
||||||
|
setSlashPopoverRef: (el: HTMLDivElement) => void
|
||||||
|
atFlat: AtOption[]
|
||||||
|
atActive?: string
|
||||||
|
atKey: (item: AtOption) => string
|
||||||
|
setAtActive: (id: string) => void
|
||||||
|
onAtSelect: (item: AtOption) => void
|
||||||
|
slashFlat: SlashCommand[]
|
||||||
|
slashActive?: string
|
||||||
|
setSlashActive: (id: string) => void
|
||||||
|
onSlashSelect: (item: SlashCommand) => void
|
||||||
|
commandKeybind: (id: string) => string | undefined
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<Show when={props.popover}>
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||||
|
}}
|
||||||
|
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||||
|
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
||||||
|
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.popover === "at"}>
|
||||||
|
<Show
|
||||||
|
when={props.atFlat.length > 0}
|
||||||
|
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||||
|
>
|
||||||
|
<For each={props.atFlat.slice(0, 10)}>
|
||||||
|
{(item) => (
|
||||||
|
<button
|
||||||
|
classList={{
|
||||||
|
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
||||||
|
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
|
||||||
|
}}
|
||||||
|
onClick={() => props.onAtSelect(item)}
|
||||||
|
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={item.type === "agent"}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<FileIcon
|
||||||
|
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
|
||||||
|
class="shrink-0 size-4"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
|
||||||
|
{item.type === "file"
|
||||||
|
? item.path.endsWith("/")
|
||||||
|
? item.path
|
||||||
|
: getDirectory(item.path)
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
<Show when={item.type === "file" && !item.path.endsWith("/")}>
|
||||||
|
<span class="text-text-strong whitespace-nowrap">
|
||||||
|
{item.type === "file" ? getFilename(item.path) : ""}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||||
|
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
||||||
|
@{item.type === "agent" ? item.name : ""}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.popover === "slash"}>
|
||||||
|
<Show
|
||||||
|
when={props.slashFlat.length > 0}
|
||||||
|
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
|
||||||
|
>
|
||||||
|
<For each={props.slashFlat}>
|
||||||
|
{(cmd) => (
|
||||||
|
<button
|
||||||
|
data-slash-id={cmd.id}
|
||||||
|
classList={{
|
||||||
|
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
||||||
|
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
||||||
|
}}
|
||||||
|
onClick={() => props.onSlashSelect(cmd)}
|
||||||
|
onMouseEnter={() => props.setSlashActive(cmd.id)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||||
|
<Show when={cmd.description}>
|
||||||
|
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||||
|
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||||
|
{cmd.source === "skill"
|
||||||
|
? props.t("prompt.slash.badge.skill")
|
||||||
|
: cmd.source === "mcp"
|
||||||
|
? props.t("prompt.slash.badge.mcp")
|
||||||
|
: props.t("prompt.slash.badge.custom")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.commandKeybind(cmd.id)}>
|
||||||
|
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
411
packages/app/src/components/prompt-input/submit.ts
Normal file
411
packages/app/src/components/prompt-input/submit.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { Accessor } from "solid-js"
|
||||||
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { Identifier } from "@/utils/id"
|
||||||
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
|
import type { FileSelection } from "@/context/file"
|
||||||
|
import { setCursorPosition } from "./editor-dom"
|
||||||
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
|
|
||||||
|
type PendingPrompt = {
|
||||||
|
abort: AbortController
|
||||||
|
cleanup: VoidFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = new Map<string, PendingPrompt>()
|
||||||
|
|
||||||
|
type PromptSubmitInput = {
|
||||||
|
info: Accessor<{ id: string } | undefined>
|
||||||
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
|
commentCount: Accessor<number>
|
||||||
|
mode: Accessor<"normal" | "shell">
|
||||||
|
working: Accessor<boolean>
|
||||||
|
editor: () => HTMLDivElement | undefined
|
||||||
|
queueScroll: () => void
|
||||||
|
promptLength: (prompt: Prompt) => number
|
||||||
|
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||||
|
resetHistoryNavigation: () => void
|
||||||
|
setMode: (mode: "normal" | "shell") => void
|
||||||
|
setPopover: (popover: "at" | "slash" | null) => void
|
||||||
|
newSessionWorktree?: string
|
||||||
|
onNewSessionWorktreeReset?: () => void
|
||||||
|
onSubmit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentItem = {
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
comment?: string
|
||||||
|
commentID?: string
|
||||||
|
commentOrigin?: "review" | "file"
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const local = useLocal()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const layout = useLayout()
|
||||||
|
const language = useLanguage()
|
||||||
|
const params = useParams()
|
||||||
|
|
||||||
|
const errorMessage = (err: unknown) => {
|
||||||
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
|
const data = (err as { data?: { message?: string } }).data
|
||||||
|
if (data?.message) return data.message
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message
|
||||||
|
return language.t("common.requestFailed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const abort = async () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return Promise.resolve()
|
||||||
|
const queued = pending.get(sessionID)
|
||||||
|
if (queued) {
|
||||||
|
queued.abort.abort()
|
||||||
|
queued.cleanup()
|
||||||
|
pending.delete(sessionID)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return sdk.client.session
|
||||||
|
.abort({
|
||||||
|
sessionID,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreCommentItems = (items: CommentItem[]) => {
|
||||||
|
for (const item of items) {
|
||||||
|
prompt.context.add({
|
||||||
|
type: "file",
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCommentItems = (items: { key: string }[]) => {
|
||||||
|
for (const item of items) {
|
||||||
|
prompt.context.remove(item.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (event: Event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const currentPrompt = prompt.current()
|
||||||
|
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
|
const images = input.imageAttachments().slice()
|
||||||
|
const mode = input.mode()
|
||||||
|
|
||||||
|
if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
|
||||||
|
if (input.working()) abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentModel = local.model.current()
|
||||||
|
const currentAgent = local.agent.current()
|
||||||
|
if (!currentModel || !currentAgent) {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||||
|
description: language.t("prompt.toast.modelAgentRequired.description"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addToHistory(currentPrompt, mode)
|
||||||
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
|
const projectDirectory = sdk.directory
|
||||||
|
const isNewSession = !params.id
|
||||||
|
const worktreeSelection = input.newSessionWorktree ?? "main"
|
||||||
|
|
||||||
|
let sessionDirectory = projectDirectory
|
||||||
|
let client = sdk.client
|
||||||
|
|
||||||
|
if (isNewSession) {
|
||||||
|
if (worktreeSelection === "create") {
|
||||||
|
const createdWorktree = await client.worktree
|
||||||
|
.create({ directory: projectDirectory })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!createdWorktree?.directory) {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
description: language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WorktreeState.pending(createdWorktree.directory)
|
||||||
|
sessionDirectory = createdWorktree.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||||
|
sessionDirectory = worktreeSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionDirectory !== projectDirectory) {
|
||||||
|
client = createOpencodeClient({
|
||||||
|
baseUrl: sdk.url,
|
||||||
|
fetch: platform.fetch,
|
||||||
|
directory: sessionDirectory,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
globalSync.child(sessionDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
input.onNewSessionWorktreeReset?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = input.info()
|
||||||
|
if (!session && isNewSession) {
|
||||||
|
session = await client.session
|
||||||
|
.create()
|
||||||
|
.then((x) => x.data ?? undefined)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (session) {
|
||||||
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
|
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
input.onSubmit?.()
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
modelID: currentModel.id,
|
||||||
|
providerID: currentModel.provider.id,
|
||||||
|
}
|
||||||
|
const agent = currentAgent.name
|
||||||
|
const variant = local.model.variant.current()
|
||||||
|
|
||||||
|
const clearInput = () => {
|
||||||
|
prompt.reset()
|
||||||
|
input.setMode("normal")
|
||||||
|
input.setPopover(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreInput = () => {
|
||||||
|
prompt.set(currentPrompt, input.promptLength(currentPrompt))
|
||||||
|
input.setMode(mode)
|
||||||
|
input.setPopover(null)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const editor = input.editor()
|
||||||
|
if (!editor) return
|
||||||
|
editor.focus()
|
||||||
|
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||||
|
input.queueScroll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "shell") {
|
||||||
|
clearInput()
|
||||||
|
client.session
|
||||||
|
.shell({
|
||||||
|
sessionID: session.id,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
command: text,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith("/")) {
|
||||||
|
const [cmdName, ...args] = text.split(" ")
|
||||||
|
const commandName = cmdName.slice(1)
|
||||||
|
const customCommand = sync.data.command.find((c) => c.name === commandName)
|
||||||
|
if (customCommand) {
|
||||||
|
clearInput()
|
||||||
|
client.session
|
||||||
|
.command({
|
||||||
|
sessionID: session.id,
|
||||||
|
command: commandName,
|
||||||
|
arguments: args.join(" "),
|
||||||
|
agent,
|
||||||
|
model: `${model.providerID}/${model.modelID}`,
|
||||||
|
variant,
|
||||||
|
parts: images.map((attachment) => ({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file" as const,
|
||||||
|
mime: attachment.mime,
|
||||||
|
url: attachment.dataUrl,
|
||||||
|
filename: attachment.filename,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = prompt.context.items().slice()
|
||||||
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
|
|
||||||
|
const messageID = Identifier.ascending("message")
|
||||||
|
const { requestParts, optimisticParts } = buildRequestParts({
|
||||||
|
prompt: currentPrompt,
|
||||||
|
context,
|
||||||
|
images,
|
||||||
|
text,
|
||||||
|
sessionID: session.id,
|
||||||
|
messageID,
|
||||||
|
sessionDirectory,
|
||||||
|
})
|
||||||
|
|
||||||
|
const optimisticMessage: Message = {
|
||||||
|
id: messageID,
|
||||||
|
sessionID: session.id,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
}
|
||||||
|
|
||||||
|
const addOptimisticMessage = () =>
|
||||||
|
sync.session.optimistic.add({
|
||||||
|
directory: sessionDirectory,
|
||||||
|
sessionID: session.id,
|
||||||
|
message: optimisticMessage,
|
||||||
|
parts: optimisticParts,
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeOptimisticMessage = () =>
|
||||||
|
sync.session.optimistic.remove({
|
||||||
|
directory: sessionDirectory,
|
||||||
|
sessionID: session.id,
|
||||||
|
messageID,
|
||||||
|
})
|
||||||
|
|
||||||
|
removeCommentItems(commentItems)
|
||||||
|
clearInput()
|
||||||
|
addOptimisticMessage()
|
||||||
|
|
||||||
|
const waitForWorktree = async () => {
|
||||||
|
const worktree = WorktreeState.get(sessionDirectory)
|
||||||
|
if (!worktree || worktree.status !== "pending") return true
|
||||||
|
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
sync.set("session_status", session.id, { type: "busy" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const cleanup = () => {
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
sync.set("session_status", session.id, { type: "idle" })
|
||||||
|
}
|
||||||
|
removeOptimisticMessage()
|
||||||
|
restoreCommentItems(commentItems)
|
||||||
|
restoreInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.set(session.id, { abort: controller, cleanup })
|
||||||
|
|
||||||
|
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
resolve({ status: "failed", message: "aborted" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controller.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
resolve({ status: "failed", message: "aborted" })
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutMs = 5 * 60 * 1000
|
||||||
|
const timer = { id: undefined as number | undefined }
|
||||||
|
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
|
timer.id = window.setTimeout(() => {
|
||||||
|
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
|
||||||
|
if (timer.id === undefined) return
|
||||||
|
clearTimeout(timer.id)
|
||||||
|
})
|
||||||
|
pending.delete(session.id)
|
||||||
|
if (controller.signal.aborted) return false
|
||||||
|
if (result.status === "failed") throw new Error(result.message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const ok = await waitForWorktree()
|
||||||
|
if (!ok) return
|
||||||
|
await client.session.prompt({
|
||||||
|
sessionID: session.id,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
messageID,
|
||||||
|
parts: requestParts,
|
||||||
|
variant,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
void send().catch((err) => {
|
||||||
|
pending.delete(session.id)
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
sync.set("session_status", session.id, { type: "idle" })
|
||||||
|
}
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
removeOptimisticMessage()
|
||||||
|
restoreCommentItems(commentItems)
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
abort,
|
||||||
|
handleSubmit,
|
||||||
|
}
|
||||||
|
}
|
||||||
77
packages/app/src/components/server/server-row.tsx
Normal file
77
packages/app/src/components/server/server-row.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { serverDisplayName } from "@/context/server"
|
||||||
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
|
interface ServerRowProps extends ParentProps {
|
||||||
|
url: string
|
||||||
|
status?: ServerHealth
|
||||||
|
class?: string
|
||||||
|
nameClass?: string
|
||||||
|
versionClass?: string
|
||||||
|
dimmed?: boolean
|
||||||
|
badge?: JSXElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerRow(props: ServerRowProps) {
|
||||||
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
|
let nameRef: HTMLSpanElement | undefined
|
||||||
|
let versionRef: HTMLSpanElement | undefined
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||||
|
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
||||||
|
setTruncated(nameTruncated || versionTruncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.url
|
||||||
|
props.status?.version
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
requestAnimationFrame(check)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
check()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
check()
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.addEventListener("resize", check)
|
||||||
|
onCleanup(() => window.removeEventListener("resize", check))
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipValue = () => (
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span>{serverDisplayName(props.url)}</span>
|
||||||
|
<Show when={props.status?.version}>
|
||||||
|
<span class="text-text-invert-base">{props.status?.version}</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||||
|
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full shrink-0": true,
|
||||||
|
"bg-icon-success-base": props.status?.healthy === true,
|
||||||
|
"bg-icon-critical-base": props.status?.healthy === false,
|
||||||
|
"bg-border-weak-base": props.status === undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||||
|
{serverDisplayName(props.url)}
|
||||||
|
</span>
|
||||||
|
<Show when={props.status?.version}>
|
||||||
|
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||||
|
{props.status?.version}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
{props.badge}
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,12 +3,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { findLast } from "@opencode-ai/util/array"
|
|
||||||
|
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||||
|
|
||||||
interface SessionContextUsageProps {
|
interface SessionContextUsageProps {
|
||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
@@ -34,26 +33,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||||
|
const context = createMemo(() => metrics().context)
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
return usd().format(metrics().totalCost)
|
||||||
return usd().format(total)
|
|
||||||
})
|
|
||||||
|
|
||||||
const context = createMemo(() => {
|
|
||||||
const locale = language.locale()
|
|
||||||
const last = findLast(messages(), (x) => {
|
|
||||||
if (x.role !== "assistant") return false
|
|
||||||
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
|
||||||
return total > 0
|
|
||||||
}) as AssistantMessage
|
|
||||||
if (!last) return
|
|
||||||
const total =
|
|
||||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
|
||||||
const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
|
|
||||||
return {
|
|
||||||
tokens: total.toLocaleString(locale),
|
|
||||||
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
@@ -67,7 +50,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const circle = () => (
|
const circle = () => (
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
|
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
{(ctx) => (
|
{(ctx) => (
|
||||||
<>
|
<>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-text-invert-strong">{ctx().tokens}</span>
|
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
|
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
|
|
||||||
|
const assistant = (
|
||||||
|
id: string,
|
||||||
|
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
|
||||||
|
cost: number,
|
||||||
|
providerID = "openai",
|
||||||
|
modelID = "gpt-4.1",
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
cost,
|
||||||
|
tokens: {
|
||||||
|
input: tokens.input,
|
||||||
|
output: tokens.output,
|
||||||
|
reasoning: tokens.reasoning,
|
||||||
|
cache: {
|
||||||
|
read: tokens.read,
|
||||||
|
write: tokens.write,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
time: { created: 1 },
|
||||||
|
} as unknown as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = (id: string) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
cost: 0,
|
||||||
|
time: { created: 1 },
|
||||||
|
} as unknown as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getSessionContextMetrics", () => {
|
||||||
|
test("computes totals and usage from latest assistant with tokens", () => {
|
||||||
|
const messages = [
|
||||||
|
user("u1"),
|
||||||
|
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||||
|
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
|
||||||
|
]
|
||||||
|
const providers = [
|
||||||
|
{
|
||||||
|
id: "openai",
|
||||||
|
name: "OpenAI",
|
||||||
|
models: {
|
||||||
|
"gpt-4.1": {
|
||||||
|
name: "GPT-4.1",
|
||||||
|
limit: { context: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const metrics = getSessionContextMetrics(messages, providers)
|
||||||
|
|
||||||
|
expect(metrics.totalCost).toBe(1.75)
|
||||||
|
expect(metrics.context?.message.id).toBe("a2")
|
||||||
|
expect(metrics.context?.total).toBe(500)
|
||||||
|
expect(metrics.context?.usage).toBe(50)
|
||||||
|
expect(metrics.context?.providerLabel).toBe("OpenAI")
|
||||||
|
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
||||||
|
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
||||||
|
const providers = [{ id: "p-1", models: {} }]
|
||||||
|
|
||||||
|
const metrics = getSessionContextMetrics(messages, providers)
|
||||||
|
|
||||||
|
expect(metrics.context?.providerLabel).toBe("p-1")
|
||||||
|
expect(metrics.context?.modelLabel).toBe("m-1")
|
||||||
|
expect(metrics.context?.limit).toBeUndefined()
|
||||||
|
expect(metrics.context?.usage).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("memoizes by message and provider array identity", () => {
|
||||||
|
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||||
|
const providers = [{ id: "openai", models: {} }]
|
||||||
|
|
||||||
|
const one = getSessionContextMetrics(messages, providers)
|
||||||
|
const two = getSessionContextMetrics(messages, providers)
|
||||||
|
const three = getSessionContextMetrics([...messages], providers)
|
||||||
|
|
||||||
|
expect(two).toBe(one)
|
||||||
|
expect(three).not.toBe(one)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
type Provider = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
models: Record<string, Model | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Model = {
|
||||||
|
name?: string
|
||||||
|
limit: {
|
||||||
|
context: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
message: AssistantMessage
|
||||||
|
provider?: Provider
|
||||||
|
model?: Model
|
||||||
|
providerLabel: string
|
||||||
|
modelLabel: string
|
||||||
|
limit: number | undefined
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
reasoning: number
|
||||||
|
cacheRead: number
|
||||||
|
cacheWrite: number
|
||||||
|
total: number
|
||||||
|
usage: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metrics = {
|
||||||
|
totalCost: number
|
||||||
|
context: Context | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new WeakMap<Message[], WeakMap<Provider[], Metrics>>()
|
||||||
|
|
||||||
|
const tokenTotal = (msg: AssistantMessage) => {
|
||||||
|
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastAssistantWithTokens = (messages: Message[]) => {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i]
|
||||||
|
if (msg.role !== "assistant") continue
|
||||||
|
if (tokenTotal(msg) <= 0) continue
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (messages: Message[], providers: Provider[]): Metrics => {
|
||||||
|
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
||||||
|
const message = lastAssistantWithTokens(messages)
|
||||||
|
if (!message) return { totalCost, context: undefined }
|
||||||
|
|
||||||
|
const provider = providers.find((item) => item.id === message.providerID)
|
||||||
|
const model = provider?.models[message.modelID]
|
||||||
|
const limit = model?.limit.context
|
||||||
|
const total = tokenTotal(message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCost,
|
||||||
|
context: {
|
||||||
|
message,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
providerLabel: provider?.name ?? message.providerID,
|
||||||
|
modelLabel: model?.name ?? message.modelID,
|
||||||
|
limit,
|
||||||
|
input: message.tokens.input,
|
||||||
|
output: message.tokens.output,
|
||||||
|
reasoning: message.tokens.reasoning,
|
||||||
|
cacheRead: message.tokens.cache.read,
|
||||||
|
cacheWrite: message.tokens.cache.write,
|
||||||
|
total,
|
||||||
|
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
|
||||||
|
const byProvider = cache.get(messages)
|
||||||
|
if (byProvider) {
|
||||||
|
const hit = byProvider.get(providers)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = build(messages, providers)
|
||||||
|
const next = byProvider ?? new WeakMap<Provider[], Metrics>()
|
||||||
|
next.set(providers, value)
|
||||||
|
if (!byProvider) cache.set(messages, next)
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -11,8 +11,9 @@ import { Accordion } from "@opencode-ai/ui/accordion"
|
|||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { Code } from "@opencode-ai/ui/code"
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||||
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
|
|
||||||
interface SessionContextTabProps {
|
interface SessionContextTabProps {
|
||||||
messages: () => Message[]
|
messages: () => Message[]
|
||||||
@@ -34,44 +35,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const ctx = createMemo(() => {
|
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
||||||
const last = findLast(props.messages(), (x) => {
|
const ctx = createMemo(() => metrics().context)
|
||||||
if (x.role !== "assistant") return false
|
|
||||||
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
|
||||||
return total > 0
|
|
||||||
}) as AssistantMessage
|
|
||||||
if (!last) return
|
|
||||||
|
|
||||||
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
|
|
||||||
const model = provider?.models[last.modelID]
|
|
||||||
const limit = model?.limit.context
|
|
||||||
|
|
||||||
const input = last.tokens.input
|
|
||||||
const output = last.tokens.output
|
|
||||||
const reasoning = last.tokens.reasoning
|
|
||||||
const cacheRead = last.tokens.cache.read
|
|
||||||
const cacheWrite = last.tokens.cache.write
|
|
||||||
const total = input + output + reasoning + cacheRead + cacheWrite
|
|
||||||
const usage = limit ? Math.round((total / limit) * 100) : null
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: last,
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
limit,
|
|
||||||
input,
|
|
||||||
output,
|
|
||||||
reasoning,
|
|
||||||
cacheRead,
|
|
||||||
cacheWrite,
|
|
||||||
total,
|
|
||||||
usage,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
return usd().format(metrics().totalCost)
|
||||||
return usd().format(total)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const counts = createMemo(() => {
|
const counts = createMemo(() => {
|
||||||
@@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
const providerLabel = createMemo(() => {
|
const providerLabel = createMemo(() => {
|
||||||
const c = ctx()
|
const c = ctx()
|
||||||
if (!c) return "—"
|
if (!c) return "—"
|
||||||
return c.provider?.name ?? c.message.providerID
|
return c.providerLabel
|
||||||
})
|
})
|
||||||
|
|
||||||
const modelLabel = createMemo(() => {
|
const modelLabel = createMemo(() => {
|
||||||
const c = ctx()
|
const c = ctx()
|
||||||
if (!c) return "—"
|
if (!c) return "—"
|
||||||
if (c.model?.name) return c.model.name
|
return c.modelLabel
|
||||||
return c.message.modelID
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const breakdown = createMemo(
|
const breakdown = createMemo(
|
||||||
|
|||||||
@@ -67,9 +67,39 @@ export function SessionHeader() {
|
|||||||
"xcode",
|
"xcode",
|
||||||
"android-studio",
|
"android-studio",
|
||||||
"powershell",
|
"powershell",
|
||||||
|
"sublime-text",
|
||||||
] as const
|
] as const
|
||||||
type OpenApp = (typeof OPEN_APPS)[number]
|
type OpenApp = (typeof OPEN_APPS)[number]
|
||||||
|
|
||||||
|
const MAC_APPS = [
|
||||||
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
||||||
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
||||||
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
||||||
|
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
||||||
|
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
||||||
|
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||||
|
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||||
|
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||||
|
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||||
|
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
||||||
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const WINDOWS_APPS = [
|
||||||
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||||
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||||
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||||
|
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
||||||
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const LINUX_APPS = [
|
||||||
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||||
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||||
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||||
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
|
] as const
|
||||||
|
|
||||||
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
|
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
|
||||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||||
if (typeof navigator !== "object") return "unknown"
|
if (typeof navigator !== "object") return "unknown"
|
||||||
@@ -80,38 +110,44 @@ export function SessionHeader() {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (platform.platform !== "desktop") return
|
||||||
|
if (!platform.checkAppExists) return
|
||||||
|
|
||||||
|
const list = os()
|
||||||
|
const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : []
|
||||||
|
if (apps.length === 0) return
|
||||||
|
|
||||||
|
void Promise.all(
|
||||||
|
apps.map((app) =>
|
||||||
|
Promise.resolve(platform.checkAppExists?.(app.openWith)).then((value) => {
|
||||||
|
const ok = Boolean(value)
|
||||||
|
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
||||||
|
return [app.id, ok] as const
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).then((entries) => {
|
||||||
|
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
if (os() === "macos") {
|
if (os() === "macos") {
|
||||||
return [
|
return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
|
||||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
|
||||||
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
|
||||||
{ id: "finder", label: "Finder", icon: "finder" },
|
|
||||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
|
||||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
|
||||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
|
||||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
|
||||||
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
|
||||||
] as const
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (os() === "windows") {
|
if (os() === "windows") {
|
||||||
return [
|
return [
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
{ id: "finder", label: "File Explorer", icon: "file-explorer" },
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
...WINDOWS_APPS.filter((app) => exists[app.id]),
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
|
||||||
{ id: "finder", label: "File Explorer", icon: "finder" },
|
|
||||||
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
|
||||||
] as const
|
] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
|
||||||
{ id: "finder", label: "File Manager", icon: "finder" },
|
{ id: "finder", label: "File Manager", icon: "finder" },
|
||||||
|
...LINUX_APPS.filter((app) => exists[app.id]),
|
||||||
] as const
|
] as const
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -268,6 +304,7 @@ export function SessionHeader() {
|
|||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Show when={projectDirectory()}>
|
<Show when={projectDirectory()}>
|
||||||
|
<div class="hidden xl:flex items-center">
|
||||||
<Show
|
<Show
|
||||||
when={canOpen()}
|
when={canOpen()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -278,7 +315,9 @@ export function SessionHeader() {
|
|||||||
aria-label={language.t("session.header.open.copyPath")}
|
aria-label={language.t("session.header.open.copyPath")}
|
||||||
>
|
>
|
||||||
<Icon name="copy" size="small" class="text-icon-base" />
|
<Icon name="copy" size="small" class="text-icon-base" />
|
||||||
<span class="text-12-regular text-text-strong">{language.t("session.header.open.copyPath")}</span>
|
<span class="text-12-regular text-text-strong">
|
||||||
|
{language.t("session.header.open.copyPath")}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -336,6 +375,7 @@ export function SessionHeader() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<StatusPopover />
|
<StatusPopover />
|
||||||
<Show when={showShare()}>
|
<Show when={showShare()}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs"
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
import { normalizeServerUrl, useServer } from "@/context/server"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { DialogSelectServer } from "./dialog-select-server"
|
import { DialogSelectServer } from "./dialog-select-server"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { ServerRow } from "@/components/server/server-row"
|
||||||
type ServerStatus = { healthy: boolean; version?: string }
|
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
|
||||||
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
|
|
||||||
const sdk = createOpencodeClient({
|
|
||||||
baseUrl: url,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
return sdk.global
|
|
||||||
.health()
|
|
||||||
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
|
||||||
.catch(() => ({ healthy: false }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -42,10 +27,11 @@ export function StatusPopover() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<string, ServerStatus | undefined>,
|
status: {} as Record<string, ServerHealth | undefined>,
|
||||||
loading: null as string | null,
|
loading: null as string | null,
|
||||||
defaultServerUrl: undefined as string | undefined,
|
defaultServerUrl: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
const current = server.url
|
const current = server.url
|
||||||
@@ -60,7 +46,7 @@ export function StatusPopover() {
|
|||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = server.url
|
const active = server.url
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
const rank = (value?: ServerStatus) => {
|
const rank = (value?: ServerHealth) => {
|
||||||
if (value?.healthy === true) return 0
|
if (value?.healthy === true) return 0
|
||||||
if (value?.healthy === false) return 2
|
if (value?.healthy === false) return 2
|
||||||
return 1
|
return 1
|
||||||
@@ -75,10 +61,10 @@ export function StatusPopover() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function refreshHealth() {
|
async function refreshHealth() {
|
||||||
const results: Record<string, ServerStatus> = {}
|
const results: Record<string, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
servers().map(async (url) => {
|
servers().map(async (url) => {
|
||||||
results[url] = await checkHealth(url, platform)
|
results[url] = await checkServerHealth(url, fetcher)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
@@ -213,41 +199,12 @@ export function StatusPopover() {
|
|||||||
const isDefault = () => url === store.defaultServerUrl
|
const isDefault = () => url === store.defaultServerUrl
|
||||||
const status = () => store.status[url]
|
const status = () => store.status[url]
|
||||||
const isBlocked = () => status()?.healthy === false
|
const isBlocked = () => status()?.healthy === false
|
||||||
const [truncated, setTruncated] = createSignal(false)
|
|
||||||
let nameRef: HTMLSpanElement | undefined
|
|
||||||
let versionRef: HTMLSpanElement | undefined
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const check = () => {
|
|
||||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
|
||||||
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
|
||||||
setTruncated(nameTruncated || versionTruncated)
|
|
||||||
}
|
|
||||||
check()
|
|
||||||
window.addEventListener("resize", check)
|
|
||||||
onCleanup(() => window.removeEventListener("resize", check))
|
|
||||||
})
|
|
||||||
|
|
||||||
const tooltipValue = () => {
|
|
||||||
const name = serverDisplayName(url)
|
|
||||||
const version = status()?.version
|
|
||||||
return (
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<span>{name}</span>
|
|
||||||
<Show when={version}>
|
|
||||||
<span class="text-text-invert-base">{version}</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-50": isBlocked(),
|
|
||||||
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||||
"cursor-not-allowed": isBlocked(),
|
"cursor-not-allowed": isBlocked(),
|
||||||
}}
|
}}
|
||||||
@@ -258,33 +215,27 @@ export function StatusPopover() {
|
|||||||
navigate("/")
|
navigate("/")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<ServerRow
|
||||||
classList={{
|
url={url}
|
||||||
"size-1.5 rounded-full shrink-0": true,
|
status={status()}
|
||||||
"bg-icon-success-base": status()?.healthy === true,
|
dimmed={isBlocked()}
|
||||||
"bg-icon-critical-base": status()?.healthy === false,
|
class="flex items-center gap-2 w-full min-w-0"
|
||||||
"bg-border-weak-base": status() === undefined,
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
}}
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
/>
|
badge={
|
||||||
<span ref={nameRef} class="text-14-regular text-text-base truncate">
|
|
||||||
{serverDisplayName(url)}
|
|
||||||
</span>
|
|
||||||
<Show when={status()?.version}>
|
|
||||||
<span ref={versionRef} class="text-12-regular text-text-weak truncate">
|
|
||||||
{status()?.version}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isDefault()}>
|
<Show when={isDefault()}>
|
||||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||||
{language.t("common.default")}
|
{language.t("common.default")}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<Show when={isActive()}>
|
<Show when={isActive()}>
|
||||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
|
</ServerRow>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LocalPTY } from "@/context/terminal"
|
|||||||
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
|
|
||||||
export interface TerminalProps extends ComponentProps<"div"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
@@ -111,17 +112,13 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const colors = getTerminalColors()
|
const colors = getTerminalColors()
|
||||||
setTerminalColors(colors)
|
setTerminalColors(colors)
|
||||||
if (!term) return
|
if (!term) return
|
||||||
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
|
setOptionIfSupported(term, "theme", colors)
|
||||||
if (!setOption) return
|
|
||||||
setOption("theme", colors)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const font = monoFontFamily(settings.appearance.font())
|
const font = monoFontFamily(settings.appearance.font())
|
||||||
if (!term) return
|
if (!term) return
|
||||||
const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
|
setOptionIfSupported(term, "fontFamily", font)
|
||||||
if (!setOption) return
|
|
||||||
setOption("fontFamily", font)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const focusTerminal = () => {
|
const focusTerminal = () => {
|
||||||
@@ -146,12 +143,12 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const t = term
|
const t = term
|
||||||
if (!t) return
|
if (!t) return
|
||||||
|
|
||||||
const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink
|
const text = getHoveredLinkText(t)
|
||||||
if (!link?.text) return
|
if (!text) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
platform.openLink(link.text)
|
platform.openLink(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -250,7 +247,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const fit = new mod.FitAddon()
|
const fit = new mod.FitAddon()
|
||||||
const serializer = new SerializeAddon()
|
const serializer = new SerializeAddon()
|
||||||
cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
|
cleanups.push(() => disposeIfDisposable(fit))
|
||||||
t.loadAddon(serializer)
|
t.loadAddon(serializer)
|
||||||
t.loadAddon(fit)
|
t.loadAddon(fit)
|
||||||
fitAddon = fit
|
fitAddon = fit
|
||||||
@@ -290,6 +287,27 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
handleResize = () => fit.fit()
|
handleResize = () => fit.fit()
|
||||||
window.addEventListener("resize", handleResize)
|
window.addEventListener("resize", handleResize)
|
||||||
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
cleanups.push(() => window.removeEventListener("resize", handleResize))
|
||||||
|
const limit = 16_384
|
||||||
|
const min = 32
|
||||||
|
const windowMs = 750
|
||||||
|
const seed = tail.length > limit ? tail.slice(-limit) : tail
|
||||||
|
let sync = seed.length >= min
|
||||||
|
let syncUntil = 0
|
||||||
|
const stopSync = () => {
|
||||||
|
sync = false
|
||||||
|
syncUntil = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlap = (data: string) => {
|
||||||
|
if (!seed) return 0
|
||||||
|
const max = Math.min(seed.length, data.length)
|
||||||
|
if (max < min) return 0
|
||||||
|
for (let i = max; i >= min; i--) {
|
||||||
|
if (seed.slice(-i) === data.slice(0, i)) return i
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
const onResize = t.onResize(async (size) => {
|
const onResize = t.onResize(async (size) => {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
await sdk.client.pty
|
await sdk.client.pty
|
||||||
@@ -303,38 +321,27 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
|
cleanups.push(() => disposeIfDisposable(onResize))
|
||||||
const onData = t.onData((data) => {
|
const onData = t.onData((data) => {
|
||||||
|
if (data) stopSync()
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(data)
|
socket.send(data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
|
cleanups.push(() => disposeIfDisposable(onData))
|
||||||
const onKey = t.onKey((key) => {
|
const onKey = t.onKey((key) => {
|
||||||
if (key.key == "Enter") {
|
if (key.key == "Enter") {
|
||||||
props.onSubmit?.()
|
props.onSubmit?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
|
cleanups.push(() => disposeIfDisposable(onKey))
|
||||||
// t.onScroll((ydisp) => {
|
// t.onScroll((ydisp) => {
|
||||||
// console.log("Scroll position:", ydisp)
|
// console.log("Scroll position:", ydisp)
|
||||||
// })
|
// })
|
||||||
|
|
||||||
const limit = 16_384
|
|
||||||
const seed = tail
|
|
||||||
let sync = !!seed
|
|
||||||
|
|
||||||
const overlap = (data: string) => {
|
|
||||||
if (!seed) return 0
|
|
||||||
const max = Math.min(seed.length, data.length)
|
|
||||||
for (let i = max; i > 0; i--) {
|
|
||||||
if (seed.slice(-i) === data.slice(0, i)) return i
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
local.onConnect?.()
|
local.onConnect?.()
|
||||||
|
if (sync) syncUntil = Date.now() + windowMs
|
||||||
sdk.client.pty
|
sdk.client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: local.pty.id,
|
ptyID: local.pty.id,
|
||||||
@@ -349,18 +356,23 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
if (disposed) return
|
||||||
const data = typeof event.data === "string" ? event.data : ""
|
const data = typeof event.data === "string" ? event.data : ""
|
||||||
if (!data) return
|
if (!data) return
|
||||||
|
|
||||||
const next = (() => {
|
const next = (() => {
|
||||||
if (!sync) return data
|
if (!sync) return data
|
||||||
|
if (syncUntil && Date.now() > syncUntil) {
|
||||||
|
stopSync()
|
||||||
|
return data
|
||||||
|
}
|
||||||
const n = overlap(data)
|
const n = overlap(data)
|
||||||
if (!n) {
|
if (!n) {
|
||||||
sync = false
|
stopSync()
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
const trimmed = data.slice(n)
|
const trimmed = data.slice(n)
|
||||||
if (trimmed) sync = false
|
if (trimmed) stopSync()
|
||||||
return trimmed
|
return trimmed
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
63
packages/app/src/components/titlebar-history.test.ts
Normal file
63
packages/app/src/components/titlebar-history.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history"
|
||||||
|
|
||||||
|
function history(): TitlebarHistory {
|
||||||
|
return { stack: [], index: 0, action: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("titlebar history", () => {
|
||||||
|
test("append and trim keeps max bounded", () => {
|
||||||
|
let state = history()
|
||||||
|
state = applyPath(state, "/", 3)
|
||||||
|
state = applyPath(state, "/a", 3)
|
||||||
|
state = applyPath(state, "/b", 3)
|
||||||
|
state = applyPath(state, "/c", 3)
|
||||||
|
|
||||||
|
expect(state.stack).toEqual(["/a", "/b", "/c"])
|
||||||
|
expect(state.stack.length).toBe(3)
|
||||||
|
expect(state.index).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("back and forward indexes stay correct after trimming", () => {
|
||||||
|
let state = history()
|
||||||
|
state = applyPath(state, "/", 3)
|
||||||
|
state = applyPath(state, "/a", 3)
|
||||||
|
state = applyPath(state, "/b", 3)
|
||||||
|
state = applyPath(state, "/c", 3)
|
||||||
|
|
||||||
|
expect(state.stack).toEqual(["/a", "/b", "/c"])
|
||||||
|
expect(state.index).toBe(2)
|
||||||
|
|
||||||
|
const back = backPath(state)
|
||||||
|
expect(back?.to).toBe("/b")
|
||||||
|
expect(back?.state.index).toBe(1)
|
||||||
|
|
||||||
|
const afterBack = applyPath(back!.state, back!.to, 3)
|
||||||
|
expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
|
||||||
|
expect(afterBack.index).toBe(1)
|
||||||
|
|
||||||
|
const forward = forwardPath(afterBack)
|
||||||
|
expect(forward?.to).toBe("/c")
|
||||||
|
expect(forward?.state.index).toBe(2)
|
||||||
|
|
||||||
|
const afterForward = applyPath(forward!.state, forward!.to, 3)
|
||||||
|
expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
|
||||||
|
expect(afterForward.index).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("action-driven navigation does not push duplicate history entries", () => {
|
||||||
|
const state: TitlebarHistory = {
|
||||||
|
stack: ["/", "/a", "/b"],
|
||||||
|
index: 2,
|
||||||
|
action: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const back = backPath(state)
|
||||||
|
expect(back?.to).toBe("/a")
|
||||||
|
|
||||||
|
const next = applyPath(back!.state, back!.to, 10)
|
||||||
|
expect(next.stack).toEqual(["/", "/a", "/b"])
|
||||||
|
expect(next.index).toBe(1)
|
||||||
|
expect(next.action).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
57
packages/app/src/components/titlebar-history.ts
Normal file
57
packages/app/src/components/titlebar-history.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export const MAX_TITLEBAR_HISTORY = 100
|
||||||
|
|
||||||
|
export type TitlebarAction = "back" | "forward" | undefined
|
||||||
|
|
||||||
|
export type TitlebarHistory = {
|
||||||
|
stack: string[]
|
||||||
|
index: number
|
||||||
|
action: TitlebarAction
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||||
|
if (!state.stack.length) {
|
||||||
|
const stack = current === "/" ? ["/"] : ["/", current]
|
||||||
|
return { stack, index: stack.length - 1, action: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = state.stack[state.index]
|
||||||
|
if (current === active) {
|
||||||
|
if (!state.action) return state
|
||||||
|
return { ...state, action: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.action) return { ...state, action: undefined }
|
||||||
|
|
||||||
|
return pushPath(state, current, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||||
|
const stack = state.stack.slice(0, state.index + 1).concat(path)
|
||||||
|
const next = trimHistory(stack, stack.length - 1, max)
|
||||||
|
return { ...state, ...next, action: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
|
||||||
|
if (stack.length <= max) return { stack, index }
|
||||||
|
const cut = stack.length - max
|
||||||
|
return {
|
||||||
|
stack: stack.slice(cut),
|
||||||
|
index: Math.max(0, index - cut),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backPath(state: TitlebarHistory) {
|
||||||
|
if (state.index <= 0) return
|
||||||
|
const index = state.index - 1
|
||||||
|
const to = state.stack[index]
|
||||||
|
if (!to) return
|
||||||
|
return { state: { ...state, index, action: "back" as const }, to }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forwardPath(state: TitlebarHistory) {
|
||||||
|
if (state.index >= state.stack.length - 1) return
|
||||||
|
const index = state.index + 1
|
||||||
|
const to = state.stack[index]
|
||||||
|
if (!to) return
|
||||||
|
return { state: { ...state, index, action: "forward" as const }, to }
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useLayout } 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"
|
||||||
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
|
|
||||||
export function Titlebar() {
|
export function Titlebar() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -39,25 +40,9 @@ export function Titlebar() {
|
|||||||
const current = path()
|
const current = path()
|
||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (!history.stack.length) {
|
const next = applyPath(history, current)
|
||||||
const stack = current === "/" ? ["/"] : ["/", current]
|
if (next === history) return
|
||||||
setHistory({ stack, index: stack.length - 1 })
|
setHistory(next)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = history.stack[history.index]
|
|
||||||
if (current === active) {
|
|
||||||
if (history.action) setHistory("action", undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (history.action) {
|
|
||||||
setHistory("action", undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = history.stack.slice(0, history.index + 1).concat(current)
|
|
||||||
setHistory({ stack: next, index: next.length - 1 })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,29 +50,47 @@ export function Titlebar() {
|
|||||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (!canBack()) return
|
const next = backPath(history)
|
||||||
const index = history.index - 1
|
if (!next) return
|
||||||
const to = history.stack[index]
|
setHistory(next.state)
|
||||||
if (!to) return
|
navigate(next.to)
|
||||||
setHistory({ index, action: "back" })
|
|
||||||
navigate(to)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const forward = () => {
|
const forward = () => {
|
||||||
if (!canForward()) return
|
const next = forwardPath(history)
|
||||||
const index = history.index + 1
|
if (!next) return
|
||||||
const to = history.stack[index]
|
setHistory(next.state)
|
||||||
if (!to) return
|
navigate(next.to)
|
||||||
setHistory({ index, action: "forward" })
|
|
||||||
navigate(to)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
command.register(() => [
|
||||||
|
{
|
||||||
|
id: "common.goBack",
|
||||||
|
title: language.t("common.goBack"),
|
||||||
|
category: language.t("command.category.view"),
|
||||||
|
onSelect: back,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "common.goForward",
|
||||||
|
title: language.t("common.goForward"),
|
||||||
|
category: language.t("command.category.view"),
|
||||||
|
onSelect: forward,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const getWin = () => {
|
const getWin = () => {
|
||||||
if (platform.platform !== "desktop") return
|
if (platform.platform !== "desktop") return
|
||||||
|
|
||||||
const tauri = (
|
const tauri = (
|
||||||
window as unknown as {
|
window as unknown as {
|
||||||
__TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } }
|
__TAURI__?: {
|
||||||
|
window?: {
|
||||||
|
getCurrentWindow?: () => {
|
||||||
|
startDragging?: () => Promise<void>
|
||||||
|
toggleMaximize?: () => Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
).__TAURI__
|
).__TAURI__
|
||||||
if (!tauri?.window?.getCurrentWindow) return
|
if (!tauri?.window?.getCurrentWindow) return
|
||||||
@@ -133,17 +136,30 @@ export function Titlebar() {
|
|||||||
void win.startDragging().catch(() => undefined)
|
void win.startDragging().catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maximize = (e: MouseEvent) => {
|
||||||
|
if (platform.platform !== "desktop") return
|
||||||
|
if (interactive(e.target)) return
|
||||||
|
if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
|
||||||
|
|
||||||
|
const win = getWin()
|
||||||
|
if (!win?.toggleMaximize) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
void win.toggleMaximize().catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||||
style={{ "min-height": minHeight() }}
|
style={{ "min-height": minHeight() }}
|
||||||
|
onMouseDown={drag}
|
||||||
|
onDblClick={maximize}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex items-center min-w-0": true,
|
"flex items-center min-w-0": true,
|
||||||
"pl-2": !mac(),
|
"pl-2": !mac(),
|
||||||
}}
|
}}
|
||||||
onMouseDown={drag}
|
|
||||||
>
|
>
|
||||||
<Show when={mac()}>
|
<Show when={mac()}>
|
||||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
||||||
|
|||||||
43
packages/app/src/context/command-keybind.test.ts
Normal file
43
packages/app/src/context/command-keybind.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { formatKeybind, matchKeybind, parseKeybind } from "./command"
|
||||||
|
|
||||||
|
describe("command keybind helpers", () => {
|
||||||
|
test("parseKeybind handles aliases and multiple combos", () => {
|
||||||
|
const keybinds = parseKeybind("control+option+k, mod+shift+comma")
|
||||||
|
|
||||||
|
expect(keybinds).toHaveLength(2)
|
||||||
|
expect(keybinds[0]).toEqual({
|
||||||
|
key: "k",
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
alt: true,
|
||||||
|
})
|
||||||
|
expect(keybinds[1]?.shift).toBe(true)
|
||||||
|
expect(keybinds[1]?.key).toBe("comma")
|
||||||
|
expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseKeybind treats none and empty as disabled", () => {
|
||||||
|
expect(parseKeybind("none")).toEqual([])
|
||||||
|
expect(parseKeybind("")).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matchKeybind normalizes punctuation keys", () => {
|
||||||
|
const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
|
||||||
|
|
||||||
|
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
|
||||||
|
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
|
||||||
|
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
|
||||||
|
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("formatKeybind returns human readable output", () => {
|
||||||
|
const display = formatKeybind("ctrl+alt+arrowup")
|
||||||
|
|
||||||
|
expect(display).toContain("↑")
|
||||||
|
expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
|
||||||
|
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||||
|
expect(formatKeybind("none")).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
25
packages/app/src/context/command.test.ts
Normal file
25
packages/app/src/context/command.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { upsertCommandRegistration } from "./command"
|
||||||
|
|
||||||
|
describe("upsertCommandRegistration", () => {
|
||||||
|
test("replaces keyed registrations", () => {
|
||||||
|
const one = () => [{ id: "one", title: "One" }]
|
||||||
|
const two = () => [{ id: "two", title: "Two" }]
|
||||||
|
|
||||||
|
const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
|
||||||
|
|
||||||
|
expect(next).toHaveLength(1)
|
||||||
|
expect(next[0]?.options).toBe(two)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps unkeyed registrations additive", () => {
|
||||||
|
const one = () => [{ id: "one", title: "One" }]
|
||||||
|
const two = () => [{ id: "two", title: "Two" }]
|
||||||
|
|
||||||
|
const next = upsertCommandRegistration([{ options: one }], { options: two })
|
||||||
|
|
||||||
|
expect(next).toHaveLength(2)
|
||||||
|
expect(next[0]?.options).toBe(two)
|
||||||
|
expect(next[1]?.options).toBe(one)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -64,6 +64,16 @@ export type CommandCatalogItem = {
|
|||||||
slash?: string
|
slash?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CommandRegistration = {
|
||||||
|
key?: string
|
||||||
|
options: Accessor<CommandOption[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
|
||||||
|
if (entry.key === undefined) return [entry, ...registrations]
|
||||||
|
return [entry, ...registrations.filter((x) => x.key !== entry.key)]
|
||||||
|
}
|
||||||
|
|
||||||
export function parseKeybind(config: string): Keybind[] {
|
export function parseKeybind(config: string): Keybind[] {
|
||||||
if (!config || config === "none") return []
|
if (!config || config === "none") return []
|
||||||
|
|
||||||
@@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
registrations: [] as Accessor<CommandOption[]>[],
|
registrations: [] as CommandRegistration[],
|
||||||
suspendCount: 0,
|
suspendCount: 0,
|
||||||
})
|
})
|
||||||
|
const warnedDuplicates = new Set<string>()
|
||||||
|
|
||||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||||
Persist.global("command.catalog.v1"),
|
Persist.global("command.catalog.v1"),
|
||||||
@@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
const all: CommandOption[] = []
|
const all: CommandOption[] = []
|
||||||
|
|
||||||
for (const reg of store.registrations) {
|
for (const reg of store.registrations) {
|
||||||
for (const opt of reg()) {
|
for (const opt of reg.options()) {
|
||||||
if (seen.has(opt.id)) continue
|
if (seen.has(opt.id)) {
|
||||||
|
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||||
|
warnedDuplicates.add(opt.id)
|
||||||
|
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
seen.add(opt.id)
|
seen.add(opt.id)
|
||||||
all.push(opt)
|
all.push(opt)
|
||||||
}
|
}
|
||||||
@@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
function register(cb: () => CommandOption[]): void
|
||||||
register(cb: () => CommandOption[]) {
|
function register(key: string, cb: () => CommandOption[]): void
|
||||||
const results = createMemo(cb)
|
function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
|
||||||
setStore("registrations", (arr) => [results, ...arr])
|
const id = typeof key === "string" ? key : undefined
|
||||||
|
const next = typeof key === "function" ? key : cb
|
||||||
|
if (!next) return
|
||||||
|
const options = createMemo(next)
|
||||||
|
const entry: CommandRegistration = {
|
||||||
|
key: id,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
setStore("registrations", (arr) => arr.filter((x) => x !== results))
|
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
register,
|
||||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||||
run(id, source)
|
run(id, source)
|
||||||
},
|
},
|
||||||
|
|||||||
111
packages/app/src/context/comments.test.ts
Normal file
111
packages/app/src/context/comments.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import type { LineComment } from "./comments"
|
||||||
|
|
||||||
|
let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mock.module("@solidjs/router", () => ({
|
||||||
|
useParams: () => ({}),
|
||||||
|
}))
|
||||||
|
mock.module("@opencode-ai/ui/context", () => ({
|
||||||
|
createSimpleContext: () => ({
|
||||||
|
use: () => undefined,
|
||||||
|
provider: () => undefined,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
const mod = await import("./comments")
|
||||||
|
createCommentSessionForTest = mod.createCommentSessionForTest
|
||||||
|
})
|
||||||
|
|
||||||
|
function line(file: string, id: string, time: number): LineComment {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
comment: id,
|
||||||
|
time,
|
||||||
|
selection: { start: 1, end: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("comments session indexing", () => {
|
||||||
|
test("keeps file list behavior and aggregate chronological order", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const comments = createCommentSessionForTest({
|
||||||
|
"a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)],
|
||||||
|
"b.ts": [line("b.ts", "b-mid", now + 10_000)],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"])
|
||||||
|
expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"])
|
||||||
|
|
||||||
|
const next = comments.add({
|
||||||
|
file: "b.ts",
|
||||||
|
comment: "next",
|
||||||
|
selection: { start: 2, end: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(comments.list("b.ts").at(-1)?.id).toBe(next.id)
|
||||||
|
expect(comments.all().map((item) => item.time)).toEqual(
|
||||||
|
comments
|
||||||
|
.all()
|
||||||
|
.map((item) => item.time)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a - b),
|
||||||
|
)
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remove updates file and aggregate indexes consistently", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const comments = createCommentSessionForTest({
|
||||||
|
"a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)],
|
||||||
|
"b.ts": [line("b.ts", "shared", 30)],
|
||||||
|
})
|
||||||
|
|
||||||
|
comments.setFocus({ file: "a.ts", id: "shared" })
|
||||||
|
comments.setActive({ file: "a.ts", id: "shared" })
|
||||||
|
comments.remove("a.ts", "shared")
|
||||||
|
|
||||||
|
expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"])
|
||||||
|
expect(
|
||||||
|
comments
|
||||||
|
.all()
|
||||||
|
.filter((item) => item.id === "shared")
|
||||||
|
.map((item) => item.file),
|
||||||
|
).toEqual(["b.ts"])
|
||||||
|
expect(comments.focus()).toBeNull()
|
||||||
|
expect(comments.active()).toEqual({ file: "a.ts", id: "shared" })
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clear resets file and aggregate indexes plus focus state", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const comments = createCommentSessionForTest({
|
||||||
|
"a.ts": [line("a.ts", "a1", 10)],
|
||||||
|
})
|
||||||
|
|
||||||
|
const next = comments.add({
|
||||||
|
file: "b.ts",
|
||||||
|
comment: "next",
|
||||||
|
selection: { start: 2, end: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
comments.setActive({ file: "b.ts", id: next.id })
|
||||||
|
comments.clear()
|
||||||
|
|
||||||
|
expect(comments.list("a.ts")).toEqual([])
|
||||||
|
expect(comments.list("b.ts")).toEqual([])
|
||||||
|
expect(comments.all()).toEqual([])
|
||||||
|
expect(comments.focus()).toBeNull()
|
||||||
|
expect(comments.active()).toBeNull()
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
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 { createScopedCache } from "@/utils/scoped-cache"
|
||||||
import type { SelectedLineRange } from "@/context/file"
|
import type { SelectedLineRange } from "@/context/file"
|
||||||
|
|
||||||
export type LineComment = {
|
export type LineComment = {
|
||||||
@@ -18,28 +19,28 @@ type CommentFocus = { file: string; id: string }
|
|||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_COMMENT_SESSIONS = 20
|
const MAX_COMMENT_SESSIONS = 20
|
||||||
|
|
||||||
type CommentSession = ReturnType<typeof createCommentSession>
|
type CommentStore = {
|
||||||
|
comments: Record<string, LineComment[]>
|
||||||
type CommentCacheEntry = {
|
|
||||||
value: CommentSession
|
|
||||||
dispose: VoidFunction
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCommentSession(dir: string, id: string | undefined) {
|
function aggregate(comments: Record<string, LineComment[]>) {
|
||||||
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
return Object.keys(comments)
|
||||||
|
.flatMap((file) => comments[file] ?? [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.time - b.time)
|
||||||
|
}
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
function insert(items: LineComment[], next: LineComment) {
|
||||||
Persist.scoped(dir, id, "comments", [legacy]),
|
const index = items.findIndex((item) => item.time > next.time)
|
||||||
createStore<{
|
if (index < 0) return [...items, next]
|
||||||
comments: Record<string, LineComment[]>
|
return [...items.slice(0, index), next, ...items.slice(index)]
|
||||||
}>({
|
}
|
||||||
comments: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
focus: null as CommentFocus | null,
|
focus: null as CommentFocus | null,
|
||||||
active: null as CommentFocus | null,
|
active: null as CommentFocus | null,
|
||||||
|
all: aggregate(store.comments),
|
||||||
})
|
})
|
||||||
|
|
||||||
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||||
@@ -59,6 +60,7 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("comments", input.file, (items) => [...(items ?? []), next])
|
setStore("comments", input.file, (items) => [...(items ?? []), next])
|
||||||
|
setState("all", (items) => insert(items, next))
|
||||||
setFocus({ file: input.file, id: next.id })
|
setFocus({ file: input.file, id: next.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,37 +68,72 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remove = (file: string, id: string) => {
|
const remove = (file: string, id: string) => {
|
||||||
setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id))
|
batch(() => {
|
||||||
|
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
|
||||||
|
setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
|
||||||
setFocus((current) => (current?.id === id ? null : current))
|
setFocus((current) => (current?.id === id ? null : current))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("comments", {})
|
setStore("comments", reconcile({}))
|
||||||
|
setState("all", [])
|
||||||
setFocus(null)
|
setFocus(null)
|
||||||
setActive(null)
|
setActive(null)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = createMemo(() => {
|
return {
|
||||||
const files = Object.keys(store.comments)
|
list,
|
||||||
const items = files.flatMap((file) => store.comments[file] ?? [])
|
all: () => state.all,
|
||||||
return items.slice().sort((a, b) => a.time - b.time)
|
add,
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
focus: () => state.focus,
|
||||||
|
setFocus,
|
||||||
|
clearFocus: () => setFocus(null),
|
||||||
|
active: () => state.active,
|
||||||
|
setActive,
|
||||||
|
clearActive: () => setActive(null),
|
||||||
|
reindex: () => setState("all", aggregate(store.comments)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommentSessionForTest(comments: Record<string, LineComment[]> = {}) {
|
||||||
|
const [store, setStore] = createStore<CommentStore>({ comments })
|
||||||
|
return createCommentSessionState(store, setStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommentSession(dir: string, id: string | undefined) {
|
||||||
|
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "comments", [legacy]),
|
||||||
|
createStore<CommentStore>({
|
||||||
|
comments: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const session = createCommentSessionState(store, setStore)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
session.reindex()
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
list,
|
list: session.list,
|
||||||
all,
|
all: session.all,
|
||||||
add,
|
add: session.add,
|
||||||
remove,
|
remove: session.remove,
|
||||||
clear,
|
clear: session.clear,
|
||||||
focus: createMemo(() => state.focus),
|
focus: session.focus,
|
||||||
setFocus,
|
setFocus: session.setFocus,
|
||||||
clearFocus: () => setFocus(null),
|
clearFocus: session.clearFocus,
|
||||||
active: createMemo(() => state.active),
|
active: session.active,
|
||||||
setActive,
|
setActive: session.setActive,
|
||||||
clearActive: () => setActive(null),
|
clearActive: session.clearActive,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,44 +142,27 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const cache = new Map<string, CommentCacheEntry>()
|
const cache = createScopedCache(
|
||||||
|
(key) => {
|
||||||
const disposeAll = () => {
|
const split = key.lastIndexOf("\n")
|
||||||
for (const entry of cache.values()) {
|
const dir = split >= 0 ? key.slice(0, split) : key
|
||||||
entry.dispose()
|
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
|
||||||
}
|
return createRoot((dispose) => ({
|
||||||
cache.clear()
|
value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(disposeAll)
|
|
||||||
|
|
||||||
const prune = () => {
|
|
||||||
while (cache.size > MAX_COMMENT_SESSIONS) {
|
|
||||||
const first = cache.keys().next().value
|
|
||||||
if (!first) return
|
|
||||||
const entry = cache.get(first)
|
|
||||||
entry?.dispose()
|
|
||||||
cache.delete(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = (dir: string, id: string | undefined) => {
|
|
||||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
|
||||||
const existing = cache.get(key)
|
|
||||||
if (existing) {
|
|
||||||
cache.delete(key)
|
|
||||||
cache.set(key, existing)
|
|
||||||
return existing.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = createRoot((dispose) => ({
|
|
||||||
value: createCommentSession(dir, id),
|
|
||||||
dispose,
|
dispose,
|
||||||
}))
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxEntries: MAX_COMMENT_SESSIONS,
|
||||||
|
dispose: (entry) => entry.dispose(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
cache.set(key, entry)
|
onCleanup(() => cache.clear())
|
||||||
prune()
|
|
||||||
return entry.value
|
const load = (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
|
||||||
|
return cache.get(key).value
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(params.dir!, params.id))
|
const session = createMemo(() => load(params.dir!, params.id))
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
evictContentLru,
|
||||||
|
getFileContentBytesTotal,
|
||||||
|
getFileContentEntryCount,
|
||||||
|
removeFileContentBytes,
|
||||||
|
resetFileContentLru,
|
||||||
|
setFileContentBytes,
|
||||||
|
touchFileContent,
|
||||||
|
} from "./file/content-cache"
|
||||||
|
|
||||||
|
describe("file content eviction accounting", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
resetFileContentLru()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates byte totals incrementally for set, overwrite, remove, and reset", () => {
|
||||||
|
setFileContentBytes("a", 10)
|
||||||
|
setFileContentBytes("b", 15)
|
||||||
|
expect(getFileContentBytesTotal()).toBe(25)
|
||||||
|
expect(getFileContentEntryCount()).toBe(2)
|
||||||
|
|
||||||
|
setFileContentBytes("a", 5)
|
||||||
|
expect(getFileContentBytesTotal()).toBe(20)
|
||||||
|
expect(getFileContentEntryCount()).toBe(2)
|
||||||
|
|
||||||
|
touchFileContent("a")
|
||||||
|
expect(getFileContentBytesTotal()).toBe(20)
|
||||||
|
|
||||||
|
removeFileContentBytes("b")
|
||||||
|
expect(getFileContentBytesTotal()).toBe(5)
|
||||||
|
expect(getFileContentEntryCount()).toBe(1)
|
||||||
|
|
||||||
|
resetFileContentLru()
|
||||||
|
expect(getFileContentBytesTotal()).toBe(0)
|
||||||
|
expect(getFileContentEntryCount()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("evicts by entry cap using LRU order", () => {
|
||||||
|
for (const i of Array.from({ length: 41 }, (_, n) => n)) {
|
||||||
|
setFileContentBytes(`f-${i}`, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const evicted: string[] = []
|
||||||
|
evictContentLru(undefined, (path) => evicted.push(path))
|
||||||
|
|
||||||
|
expect(evicted).toEqual(["f-0"])
|
||||||
|
expect(getFileContentEntryCount()).toBe(40)
|
||||||
|
expect(getFileContentBytesTotal()).toBe(40)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("evicts by byte cap while preserving protected entries", () => {
|
||||||
|
const chunk = 8 * 1024 * 1024
|
||||||
|
setFileContentBytes("a", chunk)
|
||||||
|
setFileContentBytes("b", chunk)
|
||||||
|
setFileContentBytes("c", chunk)
|
||||||
|
|
||||||
|
const evicted: string[] = []
|
||||||
|
evictContentLru(new Set(["a"]), (path) => evicted.push(path))
|
||||||
|
|
||||||
|
expect(evicted).toEqual(["b"])
|
||||||
|
expect(getFileContentEntryCount()).toBe(2)
|
||||||
|
expect(getFileContentBytesTotal()).toBe(chunk * 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,269 +1,45 @@
|
|||||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { createPathHelpers } from "./file/path"
|
||||||
|
import {
|
||||||
|
approxBytes,
|
||||||
|
evictContentLru,
|
||||||
|
getFileContentBytesTotal,
|
||||||
|
getFileContentEntryCount,
|
||||||
|
hasFileContent,
|
||||||
|
removeFileContentBytes,
|
||||||
|
resetFileContentLru,
|
||||||
|
setFileContentBytes,
|
||||||
|
touchFileContent,
|
||||||
|
} from "./file/content-cache"
|
||||||
|
import { createFileViewCache } from "./file/view-cache"
|
||||||
|
import { createFileTreeStore } from "./file/tree-store"
|
||||||
|
import { invalidateFromWatcher } from "./file/watcher"
|
||||||
|
import {
|
||||||
|
selectionFromLines,
|
||||||
|
type FileState,
|
||||||
|
type FileSelection,
|
||||||
|
type FileViewState,
|
||||||
|
type SelectedLineRange,
|
||||||
|
} from "./file/types"
|
||||||
|
|
||||||
export type FileSelection = {
|
export type { FileSelection, SelectedLineRange, FileViewState, FileState }
|
||||||
startLine: number
|
export { selectionFromLines }
|
||||||
startChar: number
|
export {
|
||||||
endLine: number
|
evictContentLru,
|
||||||
endChar: number
|
getFileContentBytesTotal,
|
||||||
}
|
getFileContentEntryCount,
|
||||||
|
removeFileContentBytes,
|
||||||
export type SelectedLineRange = {
|
resetFileContentLru,
|
||||||
start: number
|
setFileContentBytes,
|
||||||
end: number
|
touchFileContent,
|
||||||
side?: "additions" | "deletions"
|
|
||||||
endSide?: "additions" | "deletions"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileViewState = {
|
|
||||||
scrollTop?: number
|
|
||||||
scrollLeft?: number
|
|
||||||
selectedLines?: SelectedLineRange | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileState = {
|
|
||||||
path: string
|
|
||||||
name: string
|
|
||||||
loaded?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
error?: string
|
|
||||||
content?: FileContent
|
|
||||||
}
|
|
||||||
|
|
||||||
type DirectoryState = {
|
|
||||||
expanded: boolean
|
|
||||||
loaded?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
error?: string
|
|
||||||
children?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripFileProtocol(input: string) {
|
|
||||||
if (!input.startsWith("file://")) return input
|
|
||||||
return input.slice("file://".length)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripQueryAndHash(input: string) {
|
|
||||||
const hashIndex = input.indexOf("#")
|
|
||||||
const queryIndex = input.indexOf("?")
|
|
||||||
|
|
||||||
if (hashIndex !== -1 && queryIndex !== -1) {
|
|
||||||
return input.slice(0, Math.min(hashIndex, queryIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hashIndex !== -1) return input.slice(0, hashIndex)
|
|
||||||
if (queryIndex !== -1) return input.slice(0, queryIndex)
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
function unquoteGitPath(input: string) {
|
|
||||||
if (!input.startsWith('"')) return input
|
|
||||||
if (!input.endsWith('"')) return input
|
|
||||||
const body = input.slice(1, -1)
|
|
||||||
const bytes: number[] = []
|
|
||||||
|
|
||||||
for (let i = 0; i < body.length; i++) {
|
|
||||||
const char = body[i]!
|
|
||||||
if (char !== "\\") {
|
|
||||||
bytes.push(char.charCodeAt(0))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = body[i + 1]
|
|
||||||
if (!next) {
|
|
||||||
bytes.push("\\".charCodeAt(0))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next >= "0" && next <= "7") {
|
|
||||||
const chunk = body.slice(i + 1, i + 4)
|
|
||||||
const match = chunk.match(/^[0-7]{1,3}/)
|
|
||||||
if (!match) {
|
|
||||||
bytes.push(next.charCodeAt(0))
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bytes.push(parseInt(match[0], 8))
|
|
||||||
i += match[0].length
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const escaped =
|
|
||||||
next === "n"
|
|
||||||
? "\n"
|
|
||||||
: next === "r"
|
|
||||||
? "\r"
|
|
||||||
: next === "t"
|
|
||||||
? "\t"
|
|
||||||
: next === "b"
|
|
||||||
? "\b"
|
|
||||||
: next === "f"
|
|
||||||
? "\f"
|
|
||||||
: next === "v"
|
|
||||||
? "\v"
|
|
||||||
: next === "\\" || next === '"'
|
|
||||||
? next
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
bytes.push((escaped ?? next).charCodeAt(0))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TextDecoder().decode(new Uint8Array(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
|
||||||
const startLine = Math.min(range.start, range.end)
|
|
||||||
const endLine = Math.max(range.start, range.end)
|
|
||||||
return {
|
|
||||||
startLine,
|
|
||||||
endLine,
|
|
||||||
startChar: 0,
|
|
||||||
endChar: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
|
||||||
if (range.start <= range.end) return range
|
|
||||||
|
|
||||||
const startSide = range.side
|
|
||||||
const endSide = range.endSide ?? startSide
|
|
||||||
|
|
||||||
return {
|
|
||||||
...range,
|
|
||||||
start: range.end,
|
|
||||||
end: range.start,
|
|
||||||
side: endSide,
|
|
||||||
endSide: startSide !== endSide ? startSide : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
|
||||||
const MAX_FILE_VIEW_SESSIONS = 20
|
|
||||||
const MAX_VIEW_FILES = 500
|
|
||||||
|
|
||||||
const MAX_FILE_CONTENT_ENTRIES = 40
|
|
||||||
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
|
||||||
|
|
||||||
const contentLru = new Map<string, number>()
|
|
||||||
|
|
||||||
function approxBytes(content: FileContent) {
|
|
||||||
const patchBytes =
|
|
||||||
content.patch?.hunks.reduce((total, hunk) => {
|
|
||||||
return total + hunk.lines.reduce((sum, line) => sum + line.length, 0)
|
|
||||||
}, 0) ?? 0
|
|
||||||
|
|
||||||
return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
|
|
||||||
}
|
|
||||||
|
|
||||||
function touchContent(path: string, bytes?: number) {
|
|
||||||
const prev = contentLru.get(path)
|
|
||||||
if (prev === undefined && bytes === undefined) return
|
|
||||||
const value = bytes ?? prev ?? 0
|
|
||||||
contentLru.delete(path)
|
|
||||||
contentLru.set(path, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ViewSession = ReturnType<typeof createViewSession>
|
|
||||||
|
|
||||||
type ViewCacheEntry = {
|
|
||||||
value: ViewSession
|
|
||||||
dispose: VoidFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
function createViewSession(dir: string, id: string | undefined) {
|
|
||||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
|
||||||
|
|
||||||
const [view, setView, _, ready] = persisted(
|
|
||||||
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
|
||||||
createStore<{
|
|
||||||
file: Record<string, FileViewState>
|
|
||||||
}>({
|
|
||||||
file: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const meta = { pruned: false }
|
|
||||||
|
|
||||||
const pruneView = (keep?: string) => {
|
|
||||||
const keys = Object.keys(view.file)
|
|
||||||
if (keys.length <= MAX_VIEW_FILES) return
|
|
||||||
|
|
||||||
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
|
||||||
if (drop.length === 0) return
|
|
||||||
|
|
||||||
setView(
|
|
||||||
produce((draft) => {
|
|
||||||
for (const key of drop) {
|
|
||||||
delete draft.file[key]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
if (meta.pruned) return
|
|
||||||
meta.pruned = true
|
|
||||||
pruneView()
|
|
||||||
})
|
|
||||||
|
|
||||||
const scrollTop = (path: string) => view.file[path]?.scrollTop
|
|
||||||
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
|
|
||||||
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
|
||||||
|
|
||||||
const setScrollTop = (path: string, top: number) => {
|
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.scrollTop === top) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollTop: top,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setScrollLeft = (path: string, left: number) => {
|
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.scrollLeft === left) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
scrollLeft: left,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
|
|
||||||
const next = range ? normalizeSelectedLines(range) : null
|
|
||||||
setView("file", path, (current) => {
|
|
||||||
if (current?.selectedLines === next) return current
|
|
||||||
return {
|
|
||||||
...(current ?? {}),
|
|
||||||
selectedLines: next,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
pruneView(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready,
|
|
||||||
scrollTop,
|
|
||||||
scrollLeft,
|
|
||||||
selectedLines,
|
|
||||||
setScrollTop,
|
|
||||||
setScrollLeft,
|
|
||||||
setSelectedLines,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
@@ -271,170 +47,75 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
useSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const scope = createMemo(() => sdk.directory)
|
const scope = createMemo(() => sdk.directory)
|
||||||
|
const path = createPathHelpers(scope)
|
||||||
function normalize(input: string) {
|
|
||||||
const root = scope()
|
|
||||||
const prefix = root.endsWith("/") ? root : root + "/"
|
|
||||||
|
|
||||||
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
|
|
||||||
|
|
||||||
if (path.startsWith(prefix)) {
|
|
||||||
path = path.slice(prefix.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.startsWith(root)) {
|
|
||||||
path = path.slice(root.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.startsWith("./")) {
|
|
||||||
path = path.slice(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.startsWith("/")) {
|
|
||||||
path = path.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
function tab(input: string) {
|
|
||||||
const path = normalize(input)
|
|
||||||
return `file://${path}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathFromTab(tabValue: string) {
|
|
||||||
if (!tabValue.startsWith("file://")) return
|
|
||||||
return normalize(tabValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const treeInflight = new Map<string, Promise<void>>()
|
|
||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false") =>
|
|
||||||
sdk.client.find.files({ query, dirs }).then(
|
|
||||||
(x) => (x.data ?? []).map(normalize),
|
|
||||||
() => [],
|
|
||||||
)
|
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
file: Record<string, FileState>
|
file: Record<string, FileState>
|
||||||
}>({
|
}>({
|
||||||
file: {},
|
file: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const [tree, setTree] = createStore<{
|
const tree = createFileTreeStore({
|
||||||
node: Record<string, FileNode>
|
scope,
|
||||||
dir: Record<string, DirectoryState>
|
normalizeDir: path.normalizeDir,
|
||||||
}>({
|
list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
|
||||||
node: {},
|
onError: (message) => {
|
||||||
dir: { "": { expanded: true } },
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("toast.file.listFailed.title"),
|
||||||
|
description: message,
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const evictContent = (keep?: Set<string>) => {
|
const evictContent = (keep?: Set<string>) => {
|
||||||
const protectedSet = keep ?? new Set<string>()
|
evictContentLru(keep, (target) => {
|
||||||
const total = () => {
|
if (!store.file[target]) return
|
||||||
return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) {
|
|
||||||
const path = contentLru.keys().next().value
|
|
||||||
if (!path) return
|
|
||||||
|
|
||||||
if (protectedSet.has(path)) {
|
|
||||||
touchContent(path)
|
|
||||||
if (contentLru.size <= protectedSet.size) return
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
contentLru.delete(path)
|
|
||||||
if (!store.file[path]) continue
|
|
||||||
setStore(
|
setStore(
|
||||||
"file",
|
"file",
|
||||||
path,
|
target,
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.content = undefined
|
draft.content = undefined
|
||||||
draft.loaded = false
|
draft.loaded = false
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
scope()
|
scope()
|
||||||
inflight.clear()
|
inflight.clear()
|
||||||
treeInflight.clear()
|
resetFileContentLru()
|
||||||
contentLru.clear()
|
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("file", reconcile({}))
|
setStore("file", reconcile({}))
|
||||||
setTree("node", reconcile({}))
|
tree.reset()
|
||||||
setTree("dir", reconcile({}))
|
|
||||||
setTree("dir", "", { expanded: true })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewCache = new Map<string, ViewCacheEntry>()
|
const viewCache = createFileViewCache()
|
||||||
|
const view = createMemo(() => viewCache.load(scope(), params.id))
|
||||||
|
|
||||||
const disposeViews = () => {
|
const ensure = (file: string) => {
|
||||||
for (const entry of viewCache.values()) {
|
if (!file) return
|
||||||
entry.dispose()
|
if (store.file[file]) return
|
||||||
}
|
setStore("file", file, { path: file, name: getFilename(file) })
|
||||||
viewCache.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pruneViews = () => {
|
const load = (input: string, options?: { force?: boolean }) => {
|
||||||
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
|
const file = path.normalize(input)
|
||||||
const first = viewCache.keys().next().value
|
if (!file) return Promise.resolve()
|
||||||
if (!first) return
|
|
||||||
const entry = viewCache.get(first)
|
|
||||||
entry?.dispose()
|
|
||||||
viewCache.delete(first)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadView = (dir: string, id: string | undefined) => {
|
|
||||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
|
||||||
const existing = viewCache.get(key)
|
|
||||||
if (existing) {
|
|
||||||
viewCache.delete(key)
|
|
||||||
viewCache.set(key, existing)
|
|
||||||
return existing.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = createRoot((dispose) => ({
|
|
||||||
value: createViewSession(dir, id),
|
|
||||||
dispose,
|
|
||||||
}))
|
|
||||||
|
|
||||||
viewCache.set(key, entry)
|
|
||||||
pruneViews()
|
|
||||||
return entry.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const view = createMemo(() => loadView(scope(), params.id))
|
|
||||||
|
|
||||||
function ensure(path: string) {
|
|
||||||
if (!path) return
|
|
||||||
if (store.file[path]) return
|
|
||||||
setStore("file", path, { path, name: getFilename(path) })
|
|
||||||
}
|
|
||||||
|
|
||||||
function load(input: string, options?: { force?: boolean }) {
|
|
||||||
const path = normalize(input)
|
|
||||||
if (!path) return Promise.resolve()
|
|
||||||
|
|
||||||
const directory = scope()
|
const directory = scope()
|
||||||
const key = `${directory}\n${path}`
|
const key = `${directory}\n${file}`
|
||||||
const client = sdk.client
|
ensure(file)
|
||||||
|
|
||||||
ensure(path)
|
const current = store.file[file]
|
||||||
|
|
||||||
const current = store.file[path]
|
|
||||||
if (!options?.force && current?.loaded) return Promise.resolve()
|
if (!options?.force && current?.loaded) return Promise.resolve()
|
||||||
|
|
||||||
const pending = inflight.get(key)
|
const pending = inflight.get(key)
|
||||||
@@ -442,21 +123,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
|
|
||||||
setStore(
|
setStore(
|
||||||
"file",
|
"file",
|
||||||
path,
|
file,
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.loading = true
|
draft.loading = true
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const promise = client.file
|
const promise = sdk.client.file
|
||||||
.read({ path })
|
.read({ path: file })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (scope() !== directory) return
|
if (scope() !== directory) return
|
||||||
const content = x.data
|
const content = x.data
|
||||||
setStore(
|
setStore(
|
||||||
"file",
|
"file",
|
||||||
path,
|
file,
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.loaded = true
|
draft.loaded = true
|
||||||
draft.loading = false
|
draft.loading = false
|
||||||
@@ -465,14 +146,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!content) return
|
if (!content) return
|
||||||
touchContent(path, approxBytes(content))
|
touchFileContent(file, approxBytes(content))
|
||||||
evictContent(new Set([path]))
|
evictContent(new Set([file]))
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (scope() !== directory) return
|
if (scope() !== directory) return
|
||||||
setStore(
|
setStore(
|
||||||
"file",
|
"file",
|
||||||
path,
|
file,
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.loading = false
|
draft.loading = false
|
||||||
draft.error = e.message
|
draft.error = e.message
|
||||||
@@ -492,225 +173,79 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDir(input: string) {
|
const search = (query: string, dirs: "true" | "false") =>
|
||||||
return normalize(input).replace(/\/+$/, "")
|
sdk.client.find.files({ query, dirs }).then(
|
||||||
}
|
(x) => (x.data ?? []).map(path.normalize),
|
||||||
|
() => [],
|
||||||
function ensureDir(path: string) {
|
|
||||||
if (tree.dir[path]) return
|
|
||||||
setTree("dir", path, { expanded: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
function listDir(input: string, options?: { force?: boolean }) {
|
|
||||||
const dir = normalizeDir(input)
|
|
||||||
ensureDir(dir)
|
|
||||||
|
|
||||||
const current = tree.dir[dir]
|
|
||||||
if (!options?.force && current?.loaded) return Promise.resolve()
|
|
||||||
|
|
||||||
const pending = treeInflight.get(dir)
|
|
||||||
if (pending) return pending
|
|
||||||
|
|
||||||
setTree(
|
|
||||||
"dir",
|
|
||||||
dir,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.loading = true
|
|
||||||
draft.error = undefined
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const directory = scope()
|
|
||||||
|
|
||||||
const promise = sdk.client.file
|
|
||||||
.list({ path: dir })
|
|
||||||
.then((x) => {
|
|
||||||
if (scope() !== directory) return
|
|
||||||
const nodes = x.data ?? []
|
|
||||||
const prevChildren = tree.dir[dir]?.children ?? []
|
|
||||||
const nextChildren = nodes.map((node) => node.path)
|
|
||||||
const nextSet = new Set(nextChildren)
|
|
||||||
|
|
||||||
setTree(
|
|
||||||
"node",
|
|
||||||
produce((draft) => {
|
|
||||||
const removedDirs: string[] = []
|
|
||||||
|
|
||||||
for (const child of prevChildren) {
|
|
||||||
if (nextSet.has(child)) continue
|
|
||||||
const existing = draft[child]
|
|
||||||
if (existing?.type === "directory") removedDirs.push(child)
|
|
||||||
delete draft[child]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removedDirs.length > 0) {
|
|
||||||
const keys = Object.keys(draft)
|
|
||||||
for (const key of keys) {
|
|
||||||
for (const removed of removedDirs) {
|
|
||||||
if (!key.startsWith(removed + "/")) continue
|
|
||||||
delete draft[key]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
draft[node.path] = node
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
setTree(
|
|
||||||
"dir",
|
|
||||||
dir,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.loaded = true
|
|
||||||
draft.loading = false
|
|
||||||
draft.children = nextChildren
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (scope() !== directory) return
|
|
||||||
setTree(
|
|
||||||
"dir",
|
|
||||||
dir,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.loading = false
|
|
||||||
draft.error = e.message
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("toast.file.listFailed.title"),
|
|
||||||
description: e.message,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
treeInflight.delete(dir)
|
|
||||||
})
|
|
||||||
|
|
||||||
treeInflight.set(dir, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandDir(input: string) {
|
|
||||||
const dir = normalizeDir(input)
|
|
||||||
ensureDir(dir)
|
|
||||||
setTree("dir", dir, "expanded", true)
|
|
||||||
void listDir(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseDir(input: string) {
|
|
||||||
const dir = normalizeDir(input)
|
|
||||||
ensureDir(dir)
|
|
||||||
setTree("dir", dir, "expanded", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dirState(input: string) {
|
|
||||||
const dir = normalizeDir(input)
|
|
||||||
return tree.dir[dir]
|
|
||||||
}
|
|
||||||
|
|
||||||
function children(input: string) {
|
|
||||||
const dir = normalizeDir(input)
|
|
||||||
const ids = tree.dir[dir]?.children
|
|
||||||
if (!ids) return []
|
|
||||||
const out: FileNode[] = []
|
|
||||||
for (const id of ids) {
|
|
||||||
const node = tree.node[id]
|
|
||||||
if (node) out.push(node)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const stop = sdk.event.listen((e) => {
|
const stop = sdk.event.listen((e) => {
|
||||||
const event = e.details
|
invalidateFromWatcher(e.details, {
|
||||||
if (event.type !== "file.watcher.updated") return
|
normalize: path.normalize,
|
||||||
const path = normalize(event.properties.file)
|
hasFile: (file) => Boolean(store.file[file]),
|
||||||
if (!path) return
|
loadFile: (file) => {
|
||||||
if (path.startsWith(".git/")) return
|
void load(file, { force: true })
|
||||||
|
},
|
||||||
if (store.file[path]) {
|
node: tree.node,
|
||||||
load(path, { force: true })
|
isDirLoaded: tree.isLoaded,
|
||||||
}
|
refreshDir: (dir) => {
|
||||||
|
void tree.listDir(dir, { force: true })
|
||||||
const kind = event.properties.event
|
},
|
||||||
if (kind === "change") {
|
})
|
||||||
const dir = (() => {
|
|
||||||
if (path === "") return ""
|
|
||||||
const node = tree.node[path]
|
|
||||||
if (node?.type !== "directory") return
|
|
||||||
return path
|
|
||||||
})()
|
|
||||||
if (dir === undefined) return
|
|
||||||
if (!tree.dir[dir]?.loaded) return
|
|
||||||
listDir(dir, { force: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (kind !== "add" && kind !== "unlink") return
|
|
||||||
|
|
||||||
const parent = path.split("/").slice(0, -1).join("/")
|
|
||||||
if (!tree.dir[parent]?.loaded) return
|
|
||||||
|
|
||||||
listDir(parent, { force: true })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const get = (input: string) => {
|
const get = (input: string) => {
|
||||||
const path = normalize(input)
|
const file = path.normalize(input)
|
||||||
const file = store.file[path]
|
const state = store.file[file]
|
||||||
const content = file?.content
|
const content = state?.content
|
||||||
if (!content) return file
|
if (!content) return state
|
||||||
if (contentLru.has(path)) {
|
if (hasFileContent(file)) {
|
||||||
touchContent(path)
|
touchFileContent(file)
|
||||||
return file
|
return state
|
||||||
}
|
}
|
||||||
touchContent(path, approxBytes(content))
|
touchFileContent(file, approxBytes(content))
|
||||||
return file
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollTop = (input: string) => view().scrollTop(normalize(input))
|
const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
|
||||||
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
|
const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
|
||||||
const selectedLines = (input: string) => view().selectedLines(normalize(input))
|
const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
|
||||||
|
|
||||||
const setScrollTop = (input: string, top: number) => {
|
const setScrollTop = (input: string, top: number) => {
|
||||||
const path = normalize(input)
|
view().setScrollTop(path.normalize(input), top)
|
||||||
view().setScrollTop(path, top)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setScrollLeft = (input: string, left: number) => {
|
const setScrollLeft = (input: string, left: number) => {
|
||||||
const path = normalize(input)
|
view().setScrollLeft(path.normalize(input), left)
|
||||||
view().setScrollLeft(path, left)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
||||||
const path = normalize(input)
|
view().setSelectedLines(path.normalize(input), range)
|
||||||
view().setSelectedLines(path, range)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stop()
|
stop()
|
||||||
disposeViews()
|
viewCache.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => view().ready(),
|
ready: () => view().ready(),
|
||||||
normalize,
|
normalize: path.normalize,
|
||||||
tab,
|
tab: path.tab,
|
||||||
pathFromTab,
|
pathFromTab: path.pathFromTab,
|
||||||
tree: {
|
tree: {
|
||||||
list: listDir,
|
list: tree.listDir,
|
||||||
refresh: (input: string) => listDir(input, { force: true }),
|
refresh: (input: string) => tree.listDir(input, { force: true }),
|
||||||
state: dirState,
|
state: tree.dirState,
|
||||||
children,
|
children: tree.children,
|
||||||
expand: expandDir,
|
expand: tree.expandDir,
|
||||||
collapse: collapseDir,
|
collapse: tree.collapseDir,
|
||||||
toggle(input: string) {
|
toggle(input: string) {
|
||||||
if (dirState(input)?.expanded) {
|
if (tree.dirState(input)?.expanded) {
|
||||||
collapseDir(input)
|
tree.collapseDir(input)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expandDir(input)
|
tree.expandDir(input)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
get,
|
get,
|
||||||
|
|||||||
88
packages/app/src/context/file/content-cache.ts
Normal file
88
packages/app/src/context/file/content-cache.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
const MAX_FILE_CONTENT_ENTRIES = 40
|
||||||
|
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
const lru = new Map<string, number>()
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
export function approxBytes(content: FileContent) {
|
||||||
|
const patchBytes =
|
||||||
|
content.patch?.hunks.reduce((sum, hunk) => {
|
||||||
|
return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0)
|
||||||
|
}, 0) ?? 0
|
||||||
|
|
||||||
|
return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBytes(path: string, nextBytes: number) {
|
||||||
|
const prev = lru.get(path)
|
||||||
|
if (prev !== undefined) total -= prev
|
||||||
|
lru.delete(path)
|
||||||
|
lru.set(path, nextBytes)
|
||||||
|
total += nextBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(path: string, bytes?: number) {
|
||||||
|
const prev = lru.get(path)
|
||||||
|
if (prev === undefined && bytes === undefined) return
|
||||||
|
setBytes(path, bytes ?? prev ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(path: string) {
|
||||||
|
const prev = lru.get(path)
|
||||||
|
if (prev === undefined) return
|
||||||
|
lru.delete(path)
|
||||||
|
total -= prev
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
lru.clear()
|
||||||
|
total = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
|
||||||
|
const set = keep ?? new Set<string>()
|
||||||
|
|
||||||
|
while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) {
|
||||||
|
const path = lru.keys().next().value
|
||||||
|
if (!path) return
|
||||||
|
|
||||||
|
if (set.has(path)) {
|
||||||
|
touch(path)
|
||||||
|
if (lru.size <= set.size) return
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(path)
|
||||||
|
evict(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFileContentLru() {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFileContentBytes(path: string, bytes: number) {
|
||||||
|
setBytes(path, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFileContentBytes(path: string) {
|
||||||
|
remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchFileContent(path: string, bytes?: number) {
|
||||||
|
touch(path, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileContentBytesTotal() {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileContentEntryCount() {
|
||||||
|
return lru.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFileContent(path: string) {
|
||||||
|
return lru.has(path)
|
||||||
|
}
|
||||||
27
packages/app/src/context/file/path.test.ts
Normal file
27
packages/app/src/context/file/path.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path"
|
||||||
|
|
||||||
|
describe("file path helpers", () => {
|
||||||
|
test("normalizes file inputs against workspace root", () => {
|
||||||
|
const path = createPathHelpers(() => "/repo")
|
||||||
|
expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
|
||||||
|
expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
|
||||||
|
expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
|
||||||
|
expect(path.normalizeDir("src/components///")).toBe("src/components")
|
||||||
|
expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
|
||||||
|
expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
|
||||||
|
expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps query/hash stripping behavior stable", () => {
|
||||||
|
expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
|
||||||
|
expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
|
||||||
|
expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unquotes git escaped octal path strings", () => {
|
||||||
|
expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
|
||||||
|
expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
|
||||||
|
expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
|
||||||
|
})
|
||||||
|
})
|
||||||
134
packages/app/src/context/file/path.ts
Normal file
134
packages/app/src/context/file/path.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
export function stripFileProtocol(input: string) {
|
||||||
|
if (!input.startsWith("file://")) return input
|
||||||
|
return input.slice("file://".length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripQueryAndHash(input: string) {
|
||||||
|
const hashIndex = input.indexOf("#")
|
||||||
|
const queryIndex = input.indexOf("?")
|
||||||
|
|
||||||
|
if (hashIndex !== -1 && queryIndex !== -1) {
|
||||||
|
return input.slice(0, Math.min(hashIndex, queryIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashIndex !== -1) return input.slice(0, hashIndex)
|
||||||
|
if (queryIndex !== -1) return input.slice(0, queryIndex)
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unquoteGitPath(input: string) {
|
||||||
|
if (!input.startsWith('"')) return input
|
||||||
|
if (!input.endsWith('"')) return input
|
||||||
|
const body = input.slice(1, -1)
|
||||||
|
const bytes: number[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const char = body[i]!
|
||||||
|
if (char !== "\\") {
|
||||||
|
bytes.push(char.charCodeAt(0))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = body[i + 1]
|
||||||
|
if (!next) {
|
||||||
|
bytes.push("\\".charCodeAt(0))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next >= "0" && next <= "7") {
|
||||||
|
const chunk = body.slice(i + 1, i + 4)
|
||||||
|
const match = chunk.match(/^[0-7]{1,3}/)
|
||||||
|
if (!match) {
|
||||||
|
bytes.push(next.charCodeAt(0))
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bytes.push(parseInt(match[0], 8))
|
||||||
|
i += match[0].length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped =
|
||||||
|
next === "n"
|
||||||
|
? "\n"
|
||||||
|
: next === "r"
|
||||||
|
? "\r"
|
||||||
|
: next === "t"
|
||||||
|
? "\t"
|
||||||
|
: next === "b"
|
||||||
|
? "\b"
|
||||||
|
: next === "f"
|
||||||
|
? "\f"
|
||||||
|
: next === "v"
|
||||||
|
? "\v"
|
||||||
|
: next === "\\" || next === '"'
|
||||||
|
? next
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
bytes.push((escaped ?? next).charCodeAt(0))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextDecoder().decode(new Uint8Array(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeFilePath(input: string) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(input)
|
||||||
|
} catch {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeFilePath(filepath: string): string {
|
||||||
|
return filepath
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPathHelpers(scope: () => string) {
|
||||||
|
const normalize = (input: string) => {
|
||||||
|
const root = scope()
|
||||||
|
const prefix = root.endsWith("/") ? root : root + "/"
|
||||||
|
|
||||||
|
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
|
||||||
|
|
||||||
|
if (path.startsWith(prefix)) {
|
||||||
|
path = path.slice(prefix.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith(root)) {
|
||||||
|
path = path.slice(root.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("./")) {
|
||||||
|
path = path.slice(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/")) {
|
||||||
|
path = path.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = (input: string) => {
|
||||||
|
const path = normalize(input)
|
||||||
|
return `file://${encodeFilePath(path)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathFromTab = (tabValue: string) => {
|
||||||
|
if (!tabValue.startsWith("file://")) return
|
||||||
|
return normalize(tabValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalize,
|
||||||
|
tab,
|
||||||
|
pathFromTab,
|
||||||
|
normalizeDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
170
packages/app/src/context/file/tree-store.ts
Normal file
170
packages/app/src/context/file/tree-store.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
type DirectoryState = {
|
||||||
|
expanded: boolean
|
||||||
|
loaded?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
error?: string
|
||||||
|
children?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TreeStoreOptions = {
|
||||||
|
scope: () => string
|
||||||
|
normalizeDir: (input: string) => string
|
||||||
|
list: (input: string) => Promise<FileNode[]>
|
||||||
|
onError: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileTreeStore(options: TreeStoreOptions) {
|
||||||
|
const [tree, setTree] = createStore<{
|
||||||
|
node: Record<string, FileNode>
|
||||||
|
dir: Record<string, DirectoryState>
|
||||||
|
}>({
|
||||||
|
node: {},
|
||||||
|
dir: { "": { expanded: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const inflight = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
inflight.clear()
|
||||||
|
setTree("node", reconcile({}))
|
||||||
|
setTree("dir", reconcile({}))
|
||||||
|
setTree("dir", "", { expanded: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureDir = (path: string) => {
|
||||||
|
if (tree.dir[path]) return
|
||||||
|
setTree("dir", path, { expanded: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const listDir = (input: string, opts?: { force?: boolean }) => {
|
||||||
|
const dir = options.normalizeDir(input)
|
||||||
|
ensureDir(dir)
|
||||||
|
|
||||||
|
const current = tree.dir[dir]
|
||||||
|
if (!opts?.force && current?.loaded) return Promise.resolve()
|
||||||
|
|
||||||
|
const pending = inflight.get(dir)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
setTree(
|
||||||
|
"dir",
|
||||||
|
dir,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loading = true
|
||||||
|
draft.error = undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const directory = options.scope()
|
||||||
|
|
||||||
|
const promise = options
|
||||||
|
.list(dir)
|
||||||
|
.then((nodes) => {
|
||||||
|
if (options.scope() !== directory) return
|
||||||
|
const prevChildren = tree.dir[dir]?.children ?? []
|
||||||
|
const nextChildren = nodes.map((node) => node.path)
|
||||||
|
const nextSet = new Set(nextChildren)
|
||||||
|
|
||||||
|
setTree(
|
||||||
|
"node",
|
||||||
|
produce((draft) => {
|
||||||
|
const removedDirs: string[] = []
|
||||||
|
|
||||||
|
for (const child of prevChildren) {
|
||||||
|
if (nextSet.has(child)) continue
|
||||||
|
const existing = draft[child]
|
||||||
|
if (existing?.type === "directory") removedDirs.push(child)
|
||||||
|
delete draft[child]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedDirs.length > 0) {
|
||||||
|
const keys = Object.keys(draft)
|
||||||
|
for (const key of keys) {
|
||||||
|
for (const removed of removedDirs) {
|
||||||
|
if (!key.startsWith(removed + "/")) continue
|
||||||
|
delete draft[key]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
draft[node.path] = node
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
setTree(
|
||||||
|
"dir",
|
||||||
|
dir,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loaded = true
|
||||||
|
draft.loading = false
|
||||||
|
draft.children = nextChildren
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (options.scope() !== directory) return
|
||||||
|
setTree(
|
||||||
|
"dir",
|
||||||
|
dir,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loading = false
|
||||||
|
draft.error = e.message
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
options.onError(e.message)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
inflight.set(dir, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandDir = (input: string) => {
|
||||||
|
const dir = options.normalizeDir(input)
|
||||||
|
ensureDir(dir)
|
||||||
|
setTree("dir", dir, "expanded", true)
|
||||||
|
void listDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapseDir = (input: string) => {
|
||||||
|
const dir = options.normalizeDir(input)
|
||||||
|
ensureDir(dir)
|
||||||
|
setTree("dir", dir, "expanded", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirState = (input: string) => {
|
||||||
|
const dir = options.normalizeDir(input)
|
||||||
|
return tree.dir[dir]
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = (input: string) => {
|
||||||
|
const dir = options.normalizeDir(input)
|
||||||
|
const ids = tree.dir[dir]?.children
|
||||||
|
if (!ids) return []
|
||||||
|
const out: FileNode[] = []
|
||||||
|
for (const id of ids) {
|
||||||
|
const node = tree.node[id]
|
||||||
|
if (node) out.push(node)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
listDir,
|
||||||
|
expandDir,
|
||||||
|
collapseDir,
|
||||||
|
dirState,
|
||||||
|
children,
|
||||||
|
node: (path: string) => tree.node[path],
|
||||||
|
isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded),
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/app/src/context/file/types.ts
Normal file
41
packages/app/src/context/file/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
export type FileSelection = {
|
||||||
|
startLine: number
|
||||||
|
startChar: number
|
||||||
|
endLine: number
|
||||||
|
endChar: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectedLineRange = {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
side?: "additions" | "deletions"
|
||||||
|
endSide?: "additions" | "deletions"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileViewState = {
|
||||||
|
scrollTop?: number
|
||||||
|
scrollLeft?: number
|
||||||
|
selectedLines?: SelectedLineRange | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileState = {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
loaded?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
error?: string
|
||||||
|
content?: FileContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
||||||
|
const startLine = Math.min(range.start, range.end)
|
||||||
|
const endLine = Math.max(range.start, range.end)
|
||||||
|
return {
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
startChar: 0,
|
||||||
|
endChar: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
136
packages/app/src/context/file/view-cache.ts
Normal file
136
packages/app/src/context/file/view-cache.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { createEffect, createRoot } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { createScopedCache } from "@/utils/scoped-cache"
|
||||||
|
import type { FileViewState, SelectedLineRange } from "./types"
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
|
const MAX_FILE_VIEW_SESSIONS = 20
|
||||||
|
const MAX_VIEW_FILES = 500
|
||||||
|
|
||||||
|
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||||
|
if (range.start <= range.end) return range
|
||||||
|
|
||||||
|
const startSide = range.side
|
||||||
|
const endSide = range.endSide ?? startSide
|
||||||
|
|
||||||
|
return {
|
||||||
|
...range,
|
||||||
|
start: range.end,
|
||||||
|
end: range.start,
|
||||||
|
side: endSide,
|
||||||
|
endSide: startSide !== endSide ? startSide : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createViewSession(dir: string, id: string | undefined) {
|
||||||
|
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
|
const [view, setView, _, ready] = persisted(
|
||||||
|
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
||||||
|
createStore<{
|
||||||
|
file: Record<string, FileViewState>
|
||||||
|
}>({
|
||||||
|
file: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const meta = { pruned: false }
|
||||||
|
|
||||||
|
const pruneView = (keep?: string) => {
|
||||||
|
const keys = Object.keys(view.file)
|
||||||
|
if (keys.length <= MAX_VIEW_FILES) return
|
||||||
|
|
||||||
|
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
|
||||||
|
if (drop.length === 0) return
|
||||||
|
|
||||||
|
setView(
|
||||||
|
produce((draft) => {
|
||||||
|
for (const key of drop) {
|
||||||
|
delete draft.file[key]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (meta.pruned) return
|
||||||
|
meta.pruned = true
|
||||||
|
pruneView()
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollTop = (path: string) => view.file[path]?.scrollTop
|
||||||
|
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
|
||||||
|
const selectedLines = (path: string) => view.file[path]?.selectedLines
|
||||||
|
|
||||||
|
const setScrollTop = (path: string, top: number) => {
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollTop === top) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollTop: top,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setScrollLeft = (path: string, left: number) => {
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.scrollLeft === left) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
scrollLeft: left,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
|
||||||
|
const next = range ? normalizeSelectedLines(range) : null
|
||||||
|
setView("file", path, (current) => {
|
||||||
|
if (current?.selectedLines === next) return current
|
||||||
|
return {
|
||||||
|
...(current ?? {}),
|
||||||
|
selectedLines: next,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pruneView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
scrollTop,
|
||||||
|
scrollLeft,
|
||||||
|
selectedLines,
|
||||||
|
setScrollTop,
|
||||||
|
setScrollLeft,
|
||||||
|
setSelectedLines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileViewCache() {
|
||||||
|
const cache = createScopedCache(
|
||||||
|
(key) => {
|
||||||
|
const split = key.lastIndexOf("\n")
|
||||||
|
const dir = split >= 0 ? key.slice(0, split) : key
|
||||||
|
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
|
||||||
|
return createRoot((dispose) => ({
|
||||||
|
value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
|
||||||
|
dispose,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxEntries: MAX_FILE_VIEW_SESSIONS,
|
||||||
|
dispose: (entry) => entry.dispose(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
load: (dir: string, id: string | undefined) => {
|
||||||
|
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
|
||||||
|
return cache.get(key).value
|
||||||
|
},
|
||||||
|
clear: () => cache.clear(),
|
||||||
|
}
|
||||||
|
}
|
||||||
118
packages/app/src/context/file/watcher.test.ts
Normal file
118
packages/app/src/context/file/watcher.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { invalidateFromWatcher } from "./watcher"
|
||||||
|
|
||||||
|
describe("file watcher invalidation", () => {
|
||||||
|
test("reloads open files and refreshes loaded parent on add", () => {
|
||||||
|
const loads: string[] = []
|
||||||
|
const refresh: string[] = []
|
||||||
|
invalidateFromWatcher(
|
||||||
|
{
|
||||||
|
type: "file.watcher.updated",
|
||||||
|
properties: {
|
||||||
|
file: "src/new.ts",
|
||||||
|
event: "add",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normalize: (input) => input,
|
||||||
|
hasFile: (path) => path === "src/new.ts",
|
||||||
|
loadFile: (path) => loads.push(path),
|
||||||
|
node: () => undefined,
|
||||||
|
isDirLoaded: (path) => path === "src",
|
||||||
|
refreshDir: (path) => refresh.push(path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(loads).toEqual(["src/new.ts"])
|
||||||
|
expect(refresh).toEqual(["src"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refreshes only changed loaded directory nodes", () => {
|
||||||
|
const refresh: string[] = []
|
||||||
|
|
||||||
|
invalidateFromWatcher(
|
||||||
|
{
|
||||||
|
type: "file.watcher.updated",
|
||||||
|
properties: {
|
||||||
|
file: "src",
|
||||||
|
event: "change",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normalize: (input) => input,
|
||||||
|
hasFile: () => false,
|
||||||
|
loadFile: () => {},
|
||||||
|
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
|
||||||
|
isDirLoaded: (path) => path === "src",
|
||||||
|
refreshDir: (path) => refresh.push(path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
invalidateFromWatcher(
|
||||||
|
{
|
||||||
|
type: "file.watcher.updated",
|
||||||
|
properties: {
|
||||||
|
file: "src/file.ts",
|
||||||
|
event: "change",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normalize: (input) => input,
|
||||||
|
hasFile: () => false,
|
||||||
|
loadFile: () => {},
|
||||||
|
node: () => ({
|
||||||
|
path: "src/file.ts",
|
||||||
|
type: "file",
|
||||||
|
name: "file.ts",
|
||||||
|
absolute: "/repo/src/file.ts",
|
||||||
|
ignored: false,
|
||||||
|
}),
|
||||||
|
isDirLoaded: () => true,
|
||||||
|
refreshDir: (path) => refresh.push(path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(refresh).toEqual(["src"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores invalid or git watcher updates", () => {
|
||||||
|
const refresh: string[] = []
|
||||||
|
|
||||||
|
invalidateFromWatcher(
|
||||||
|
{
|
||||||
|
type: "file.watcher.updated",
|
||||||
|
properties: {
|
||||||
|
file: ".git/index.lock",
|
||||||
|
event: "change",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normalize: (input) => input,
|
||||||
|
hasFile: () => true,
|
||||||
|
loadFile: () => {
|
||||||
|
throw new Error("should not load")
|
||||||
|
},
|
||||||
|
node: () => undefined,
|
||||||
|
isDirLoaded: () => true,
|
||||||
|
refreshDir: (path) => refresh.push(path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
invalidateFromWatcher(
|
||||||
|
{
|
||||||
|
type: "project.updated",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normalize: (input) => input,
|
||||||
|
hasFile: () => false,
|
||||||
|
loadFile: () => {},
|
||||||
|
node: () => undefined,
|
||||||
|
isDirLoaded: () => true,
|
||||||
|
refreshDir: (path) => refresh.push(path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(refresh).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
52
packages/app/src/context/file/watcher.ts
Normal file
52
packages/app/src/context/file/watcher.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
type WatcherEvent = {
|
||||||
|
type: string
|
||||||
|
properties: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatcherOps = {
|
||||||
|
normalize: (input: string) => string
|
||||||
|
hasFile: (path: string) => boolean
|
||||||
|
loadFile: (path: string) => void
|
||||||
|
node: (path: string) => FileNode | undefined
|
||||||
|
isDirLoaded: (path: string) => boolean
|
||||||
|
refreshDir: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
|
||||||
|
if (event.type !== "file.watcher.updated") return
|
||||||
|
const props =
|
||||||
|
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
|
||||||
|
const rawPath = typeof props?.file === "string" ? props.file : undefined
|
||||||
|
const kind = typeof props?.event === "string" ? props.event : undefined
|
||||||
|
if (!rawPath) return
|
||||||
|
if (!kind) return
|
||||||
|
|
||||||
|
const path = ops.normalize(rawPath)
|
||||||
|
if (!path) return
|
||||||
|
if (path.startsWith(".git/")) return
|
||||||
|
|
||||||
|
if (ops.hasFile(path)) {
|
||||||
|
ops.loadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "change") {
|
||||||
|
const dir = (() => {
|
||||||
|
if (path === "") return ""
|
||||||
|
const node = ops.node(path)
|
||||||
|
if (node?.type !== "directory") return
|
||||||
|
return path
|
||||||
|
})()
|
||||||
|
if (dir === undefined) return
|
||||||
|
if (!ops.isDirLoaded(dir)) return
|
||||||
|
ops.refreshDir(dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (kind !== "add" && kind !== "unlink") return
|
||||||
|
|
||||||
|
const parent = path.split("/").slice(0, -1).join("/")
|
||||||
|
if (!ops.isDirLoaded(parent)) return
|
||||||
|
|
||||||
|
ops.refreshDir(parent)
|
||||||
|
}
|
||||||
136
packages/app/src/context/global-sync.test.ts
Normal file
136
packages/app/src/context/global-sync.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
canDisposeDirectory,
|
||||||
|
estimateRootSessionTotal,
|
||||||
|
loadRootSessionsWithFallback,
|
||||||
|
pickDirectoriesToEvict,
|
||||||
|
} from "./global-sync"
|
||||||
|
|
||||||
|
describe("pickDirectoriesToEvict", () => {
|
||||||
|
test("keeps pinned stores and evicts idle stores", () => {
|
||||||
|
const now = 5_000
|
||||||
|
const picks = pickDirectoriesToEvict({
|
||||||
|
stores: ["a", "b", "c", "d"],
|
||||||
|
state: new Map([
|
||||||
|
["a", { lastAccessAt: 1_000 }],
|
||||||
|
["b", { lastAccessAt: 4_900 }],
|
||||||
|
["c", { lastAccessAt: 4_800 }],
|
||||||
|
["d", { lastAccessAt: 3_000 }],
|
||||||
|
]),
|
||||||
|
pins: new Set(["a"]),
|
||||||
|
max: 2,
|
||||||
|
ttl: 1_500,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(picks).toEqual(["d", "c"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("loadRootSessionsWithFallback", () => {
|
||||||
|
test("uses limited roots query when supported", async () => {
|
||||||
|
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||||
|
let fallback = 0
|
||||||
|
|
||||||
|
const result = await loadRootSessionsWithFallback({
|
||||||
|
directory: "dir",
|
||||||
|
limit: 10,
|
||||||
|
list: async (query) => {
|
||||||
|
calls.push(query)
|
||||||
|
return { data: [] }
|
||||||
|
},
|
||||||
|
onFallback: () => {
|
||||||
|
fallback += 1
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data).toEqual([])
|
||||||
|
expect(result.limited).toBe(true)
|
||||||
|
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
||||||
|
expect(fallback).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to full roots query on limited-query failure", async () => {
|
||||||
|
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||||
|
let fallback = 0
|
||||||
|
|
||||||
|
const result = await loadRootSessionsWithFallback({
|
||||||
|
directory: "dir",
|
||||||
|
limit: 25,
|
||||||
|
list: async (query) => {
|
||||||
|
calls.push(query)
|
||||||
|
if (query.limit) throw new Error("unsupported")
|
||||||
|
return { data: [] }
|
||||||
|
},
|
||||||
|
onFallback: () => {
|
||||||
|
fallback += 1
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data).toEqual([])
|
||||||
|
expect(result.limited).toBe(false)
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ directory: "dir", roots: true, limit: 25 },
|
||||||
|
{ directory: "dir", roots: true },
|
||||||
|
])
|
||||||
|
expect(fallback).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("estimateRootSessionTotal", () => {
|
||||||
|
test("keeps exact total for full fetches", () => {
|
||||||
|
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("marks has-more for full-limit limited fetches", () => {
|
||||||
|
expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps exact total when limited fetch is under limit", () => {
|
||||||
|
expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("canDisposeDirectory", () => {
|
||||||
|
test("rejects pinned or inflight directories", () => {
|
||||||
|
expect(
|
||||||
|
canDisposeDirectory({
|
||||||
|
directory: "dir",
|
||||||
|
hasStore: true,
|
||||||
|
pinned: true,
|
||||||
|
booting: false,
|
||||||
|
loadingSessions: false,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
canDisposeDirectory({
|
||||||
|
directory: "dir",
|
||||||
|
hasStore: true,
|
||||||
|
pinned: false,
|
||||||
|
booting: true,
|
||||||
|
loadingSessions: false,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
canDisposeDirectory({
|
||||||
|
directory: "dir",
|
||||||
|
hasStore: true,
|
||||||
|
pinned: false,
|
||||||
|
booting: false,
|
||||||
|
loadingSessions: true,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts idle unpinned directory store", () => {
|
||||||
|
expect(
|
||||||
|
canDisposeDirectory({
|
||||||
|
directory: "dir",
|
||||||
|
hasStore: true,
|
||||||
|
pinned: false,
|
||||||
|
booting: false,
|
||||||
|
loadingSessions: false,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
195
packages/app/src/context/global-sync/bootstrap.ts
Normal file
195
packages/app/src/context/global-sync/bootstrap.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
type Config,
|
||||||
|
type Path,
|
||||||
|
type PermissionRequest,
|
||||||
|
type Project,
|
||||||
|
type ProviderAuthResponse,
|
||||||
|
type ProviderListResponse,
|
||||||
|
type QuestionRequest,
|
||||||
|
createOpencodeClient,
|
||||||
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { batch } from "solid-js"
|
||||||
|
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { cmp, normalizeProviderList } from "./utils"
|
||||||
|
import type { State, VcsCache } from "./types"
|
||||||
|
|
||||||
|
type GlobalStore = {
|
||||||
|
ready: boolean
|
||||||
|
path: Path
|
||||||
|
project: Project[]
|
||||||
|
provider: ProviderListResponse
|
||||||
|
provider_auth: ProviderAuthResponse
|
||||||
|
config: Config
|
||||||
|
reload: undefined | "pending" | "complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapGlobal(input: {
|
||||||
|
globalSDK: ReturnType<typeof createOpencodeClient>
|
||||||
|
connectErrorTitle: string
|
||||||
|
connectErrorDescription: string
|
||||||
|
requestFailedTitle: string
|
||||||
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
|
}) {
|
||||||
|
const health = await input.globalSDK.global
|
||||||
|
.health()
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!health?.healthy) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.connectErrorTitle,
|
||||||
|
description: input.connectErrorDescription,
|
||||||
|
})
|
||||||
|
input.setGlobalStore("ready", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
retry(() =>
|
||||||
|
input.globalSDK.path.get().then((x) => {
|
||||||
|
input.setGlobalStore("path", x.data!)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
retry(() =>
|
||||||
|
input.globalSDK.global.config.get().then((x) => {
|
||||||
|
input.setGlobalStore("config", x.data!)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
retry(() =>
|
||||||
|
input.globalSDK.project.list().then((x) => {
|
||||||
|
const projects = (x.data ?? [])
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
input.setGlobalStore("project", projects)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
retry(() =>
|
||||||
|
input.globalSDK.provider.list().then((x) => {
|
||||||
|
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
retry(() =>
|
||||||
|
input.globalSDK.provider.auth().then((x) => {
|
||||||
|
input.setGlobalStore("provider_auth", x.data ?? {})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(tasks)
|
||||||
|
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||||
|
if (errors.length) {
|
||||||
|
const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
|
||||||
|
const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.requestFailedTitle,
|
||||||
|
description: message + more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
input.setGlobalStore("ready", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
|
||||||
|
return input.reduce<Record<string, T[]>>((acc, item) => {
|
||||||
|
if (!item?.id || !item.sessionID) return acc
|
||||||
|
const list = acc[item.sessionID]
|
||||||
|
if (list) list.push(item)
|
||||||
|
if (!list) acc[item.sessionID] = [item]
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapDirectory(input: {
|
||||||
|
directory: string
|
||||||
|
sdk: ReturnType<typeof createOpencodeClient>
|
||||||
|
store: Store<State>
|
||||||
|
setStore: SetStoreFunction<State>
|
||||||
|
vcsCache: VcsCache
|
||||||
|
loadSessions: (directory: string) => Promise<void> | void
|
||||||
|
}) {
|
||||||
|
input.setStore("status", "loading")
|
||||||
|
|
||||||
|
const blockingRequests = {
|
||||||
|
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
||||||
|
provider: () =>
|
||||||
|
input.sdk.provider.list().then((x) => {
|
||||||
|
input.setStore("provider", normalizeProviderList(x.data!))
|
||||||
|
}),
|
||||||
|
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
|
||||||
|
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to bootstrap instance", err)
|
||||||
|
const project = getFilename(input.directory)
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: `Failed to reload ${project}`, description: message })
|
||||||
|
input.setStore("status", "partial")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.store.status !== "complete") input.setStore("status", "partial")
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
|
||||||
|
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
|
||||||
|
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
|
||||||
|
input.loadSessions(input.directory),
|
||||||
|
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
|
||||||
|
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
|
||||||
|
input.sdk.vcs.get().then((x) => {
|
||||||
|
const next = x.data ?? input.store.vcs
|
||||||
|
input.setStore("vcs", next)
|
||||||
|
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||||
|
}),
|
||||||
|
input.sdk.permission.list().then((x) => {
|
||||||
|
const grouped = groupBySession(
|
||||||
|
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||||
|
)
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(input.store.permission)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
input.setStore("permission", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
|
input.setStore(
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
input.sdk.question.list().then((x) => {
|
||||||
|
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(input.store.question)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
input.setStore("question", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||||
|
input.setStore(
|
||||||
|
"question",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
]).then(() => {
|
||||||
|
input.setStore("status", "complete")
|
||||||
|
})
|
||||||
|
}
|
||||||
263
packages/app/src/context/global-sync/child-store.ts
Normal file
263
packages/app/src/context/global-sync/child-store.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||||
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import {
|
||||||
|
DIR_IDLE_TTL_MS,
|
||||||
|
MAX_DIR_STORES,
|
||||||
|
type ChildOptions,
|
||||||
|
type DirState,
|
||||||
|
type IconCache,
|
||||||
|
type MetaCache,
|
||||||
|
type ProjectMeta,
|
||||||
|
type State,
|
||||||
|
type VcsCache,
|
||||||
|
} from "./types"
|
||||||
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||||
|
|
||||||
|
export function createChildStoreManager(input: {
|
||||||
|
owner: Owner
|
||||||
|
markStats: (activeDirectoryStores: number) => void
|
||||||
|
incrementEvictions: () => void
|
||||||
|
isBooting: (directory: string) => boolean
|
||||||
|
isLoadingSessions: (directory: string) => boolean
|
||||||
|
onBootstrap: (directory: string) => void
|
||||||
|
onDispose: (directory: string) => void
|
||||||
|
}) {
|
||||||
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
|
const vcsCache = new Map<string, VcsCache>()
|
||||||
|
const metaCache = new Map<string, MetaCache>()
|
||||||
|
const iconCache = new Map<string, IconCache>()
|
||||||
|
const lifecycle = new Map<string, DirState>()
|
||||||
|
const pins = new Map<string, number>()
|
||||||
|
const ownerPins = new WeakMap<object, Set<string>>()
|
||||||
|
const disposers = new Map<string, () => void>()
|
||||||
|
|
||||||
|
const mark = (directory: string) => {
|
||||||
|
if (!directory) return
|
||||||
|
lifecycle.set(directory, { lastAccessAt: Date.now() })
|
||||||
|
runEviction()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin = (directory: string) => {
|
||||||
|
if (!directory) return
|
||||||
|
pins.set(directory, (pins.get(directory) ?? 0) + 1)
|
||||||
|
mark(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unpin = (directory: string) => {
|
||||||
|
if (!directory) return
|
||||||
|
const next = (pins.get(directory) ?? 0) - 1
|
||||||
|
if (next > 0) {
|
||||||
|
pins.set(directory, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pins.delete(directory)
|
||||||
|
runEviction()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
|
||||||
|
|
||||||
|
const pinForOwner = (directory: string) => {
|
||||||
|
const current = getOwner()
|
||||||
|
if (!current) return
|
||||||
|
if (current === input.owner) return
|
||||||
|
const key = current as object
|
||||||
|
const set = ownerPins.get(key)
|
||||||
|
if (set?.has(directory)) return
|
||||||
|
if (set) set.add(directory)
|
||||||
|
if (!set) ownerPins.set(key, new Set([directory]))
|
||||||
|
pin(directory)
|
||||||
|
onCleanup(() => {
|
||||||
|
const set = ownerPins.get(key)
|
||||||
|
if (set) {
|
||||||
|
set.delete(directory)
|
||||||
|
if (set.size === 0) ownerPins.delete(key)
|
||||||
|
}
|
||||||
|
unpin(directory)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeDirectory(directory: string) {
|
||||||
|
if (
|
||||||
|
!canDisposeDirectory({
|
||||||
|
directory,
|
||||||
|
hasStore: !!children[directory],
|
||||||
|
pinned: pinned(directory),
|
||||||
|
booting: input.isBooting(directory),
|
||||||
|
loadingSessions: input.isLoadingSessions(directory),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
vcsCache.delete(directory)
|
||||||
|
metaCache.delete(directory)
|
||||||
|
iconCache.delete(directory)
|
||||||
|
lifecycle.delete(directory)
|
||||||
|
const dispose = disposers.get(directory)
|
||||||
|
if (dispose) {
|
||||||
|
dispose()
|
||||||
|
disposers.delete(directory)
|
||||||
|
}
|
||||||
|
delete children[directory]
|
||||||
|
input.onDispose(directory)
|
||||||
|
input.markStats(Object.keys(children).length)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function runEviction() {
|
||||||
|
const stores = Object.keys(children)
|
||||||
|
if (stores.length === 0) return
|
||||||
|
const list = pickDirectoriesToEvict({
|
||||||
|
stores,
|
||||||
|
state: lifecycle,
|
||||||
|
pins: new Set(stores.filter(pinned)),
|
||||||
|
max: MAX_DIR_STORES,
|
||||||
|
ttl: DIR_IDLE_TTL_MS,
|
||||||
|
now: Date.now(),
|
||||||
|
})
|
||||||
|
if (list.length === 0) return
|
||||||
|
for (const directory of list) {
|
||||||
|
if (!disposeDirectory(directory)) continue
|
||||||
|
input.incrementEvictions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureChild(directory: string) {
|
||||||
|
if (!directory) console.error("No directory provided")
|
||||||
|
if (!children[directory]) {
|
||||||
|
const vcs = runWithOwner(input.owner, () =>
|
||||||
|
persisted(
|
||||||
|
Persist.workspace(directory, "vcs", ["vcs.v1"]),
|
||||||
|
createStore({ value: undefined as VcsInfo | undefined }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||||
|
const vcsStore = vcs[0]
|
||||||
|
const vcsReady = vcs[3]
|
||||||
|
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
|
||||||
|
|
||||||
|
const meta = runWithOwner(input.owner, () =>
|
||||||
|
persisted(
|
||||||
|
Persist.workspace(directory, "project", ["project.v1"]),
|
||||||
|
createStore({ value: undefined as ProjectMeta | undefined }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!meta) throw new Error("Failed to create persisted project metadata")
|
||||||
|
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
|
||||||
|
|
||||||
|
const icon = runWithOwner(input.owner, () =>
|
||||||
|
persisted(
|
||||||
|
Persist.workspace(directory, "icon", ["icon.v1"]),
|
||||||
|
createStore({ value: undefined as string | undefined }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!icon) throw new Error("Failed to create persisted project icon")
|
||||||
|
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
||||||
|
|
||||||
|
const init = () =>
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const child = createStore<State>({
|
||||||
|
project: "",
|
||||||
|
projectMeta: meta[0].value,
|
||||||
|
icon: icon[0].value,
|
||||||
|
provider: { all: [], connected: [], default: {} },
|
||||||
|
config: {},
|
||||||
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
|
status: "loading" as const,
|
||||||
|
agent: [],
|
||||||
|
command: [],
|
||||||
|
session: [],
|
||||||
|
sessionTotal: 0,
|
||||||
|
session_status: {},
|
||||||
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
|
permission: {},
|
||||||
|
question: {},
|
||||||
|
mcp: {},
|
||||||
|
lsp: [],
|
||||||
|
vcs: vcsStore.value,
|
||||||
|
limit: 5,
|
||||||
|
message: {},
|
||||||
|
part: {},
|
||||||
|
})
|
||||||
|
children[directory] = child
|
||||||
|
disposers.set(directory, dispose)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!vcsReady()) return
|
||||||
|
const cached = vcsStore.value
|
||||||
|
if (!cached?.branch) return
|
||||||
|
child[1]("vcs", (value) => value ?? cached)
|
||||||
|
})
|
||||||
|
createEffect(() => {
|
||||||
|
child[1]("projectMeta", meta[0].value)
|
||||||
|
})
|
||||||
|
createEffect(() => {
|
||||||
|
child[1]("icon", icon[0].value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
runWithOwner(input.owner, init)
|
||||||
|
input.markStats(Object.keys(children).length)
|
||||||
|
}
|
||||||
|
mark(directory)
|
||||||
|
const childStore = children[directory]
|
||||||
|
if (!childStore) throw new Error("Failed to create store")
|
||||||
|
return childStore
|
||||||
|
}
|
||||||
|
|
||||||
|
function child(directory: string, options: ChildOptions = {}) {
|
||||||
|
const childStore = ensureChild(directory)
|
||||||
|
pinForOwner(directory)
|
||||||
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
|
input.onBootstrap(directory)
|
||||||
|
}
|
||||||
|
return childStore
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||||
|
const [store, setStore] = ensureChild(directory)
|
||||||
|
const cached = metaCache.get(directory)
|
||||||
|
if (!cached) return
|
||||||
|
const previous = store.projectMeta ?? {}
|
||||||
|
const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
|
||||||
|
const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
|
||||||
|
const next = {
|
||||||
|
...previous,
|
||||||
|
...patch,
|
||||||
|
icon,
|
||||||
|
commands,
|
||||||
|
}
|
||||||
|
cached.setStore("value", next)
|
||||||
|
setStore("projectMeta", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectIcon(directory: string, value: string | undefined) {
|
||||||
|
const [store, setStore] = ensureChild(directory)
|
||||||
|
const cached = iconCache.get(directory)
|
||||||
|
if (!cached) return
|
||||||
|
if (store.icon === value) return
|
||||||
|
cached.setStore("value", value)
|
||||||
|
setStore("icon", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
children,
|
||||||
|
ensureChild,
|
||||||
|
child,
|
||||||
|
projectMeta,
|
||||||
|
projectIcon,
|
||||||
|
mark,
|
||||||
|
pin,
|
||||||
|
unpin,
|
||||||
|
pinned,
|
||||||
|
disposeDirectory,
|
||||||
|
runEviction,
|
||||||
|
vcsCache,
|
||||||
|
metaCache,
|
||||||
|
iconCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
201
packages/app/src/context/global-sync/event-reducer.test.ts
Normal file
201
packages/app/src/context/global-sync/event-reducer.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { State } from "./types"
|
||||||
|
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||||
|
|
||||||
|
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||||
|
({
|
||||||
|
id: input.id,
|
||||||
|
parentID: input.parentID,
|
||||||
|
time: {
|
||||||
|
created: 1,
|
||||||
|
updated: 1,
|
||||||
|
archived: input.archived,
|
||||||
|
},
|
||||||
|
}) as Session
|
||||||
|
|
||||||
|
const userMessage = (id: string, sessionID: string) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "assistant",
|
||||||
|
model: { providerID: "openai", modelID: "gpt" },
|
||||||
|
}) as Message
|
||||||
|
|
||||||
|
const textPart = (id: string, sessionID: string, messageID: string) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text: id,
|
||||||
|
}) as Part
|
||||||
|
|
||||||
|
const baseState = (input: Partial<State> = {}) =>
|
||||||
|
({
|
||||||
|
status: "complete",
|
||||||
|
agent: [],
|
||||||
|
command: [],
|
||||||
|
project: "",
|
||||||
|
projectMeta: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
provider: {} as State["provider"],
|
||||||
|
config: {} as State["config"],
|
||||||
|
path: { directory: "/tmp" } as State["path"],
|
||||||
|
session: [],
|
||||||
|
sessionTotal: 0,
|
||||||
|
session_status: {},
|
||||||
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
|
permission: {},
|
||||||
|
question: {},
|
||||||
|
mcp: {},
|
||||||
|
lsp: [],
|
||||||
|
vcs: undefined,
|
||||||
|
limit: 10,
|
||||||
|
message: {},
|
||||||
|
part: {},
|
||||||
|
...input,
|
||||||
|
}) as State
|
||||||
|
|
||||||
|
describe("applyGlobalEvent", () => {
|
||||||
|
test("upserts project.updated in sorted position", () => {
|
||||||
|
const project = [{ id: "a" }, { id: "c" }] as Project[]
|
||||||
|
let refreshCount = 0
|
||||||
|
applyGlobalEvent({
|
||||||
|
event: { type: "project.updated", properties: { id: "b" } },
|
||||||
|
project,
|
||||||
|
refresh: () => {
|
||||||
|
refreshCount += 1
|
||||||
|
},
|
||||||
|
setGlobalProject(next) {
|
||||||
|
if (typeof next === "function") next(project)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
|
||||||
|
expect(refreshCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles global.disposed by triggering refresh", () => {
|
||||||
|
let refreshCount = 0
|
||||||
|
applyGlobalEvent({
|
||||||
|
event: { type: "global.disposed" },
|
||||||
|
project: [],
|
||||||
|
refresh: () => {
|
||||||
|
refreshCount += 1
|
||||||
|
},
|
||||||
|
setGlobalProject() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(refreshCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyDirectoryEvent", () => {
|
||||||
|
test("inserts root sessions in sorted order and updates sessionTotal", () => {
|
||||||
|
const [store, setStore] = createStore(
|
||||||
|
baseState({
|
||||||
|
session: [rootSession({ id: "b" })],
|
||||||
|
sessionTotal: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
|
||||||
|
expect(store.sessionTotal).toBe(2)
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.sessionTotal).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleans session caches when archived", () => {
|
||||||
|
const message = userMessage("msg_1", "ses_1")
|
||||||
|
const [store, setStore] = createStore(
|
||||||
|
baseState({
|
||||||
|
session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
|
||||||
|
sessionTotal: 2,
|
||||||
|
message: { ses_1: [message] },
|
||||||
|
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
|
||||||
|
session_diff: { ses_1: [] },
|
||||||
|
todo: { ses_1: [] },
|
||||||
|
permission: { ses_1: [] },
|
||||||
|
question: { ses_1: [] },
|
||||||
|
session_status: { ses_1: { type: "busy" } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
|
||||||
|
expect(store.sessionTotal).toBe(1)
|
||||||
|
expect(store.message.ses_1).toBeUndefined()
|
||||||
|
expect(store.part[message.id]).toBeUndefined()
|
||||||
|
expect(store.session_diff.ses_1).toBeUndefined()
|
||||||
|
expect(store.todo.ses_1).toBeUndefined()
|
||||||
|
expect(store.permission.ses_1).toBeUndefined()
|
||||||
|
expect(store.question.ses_1).toBeUndefined()
|
||||||
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||||
|
const [store, setStore] = createStore(baseState())
|
||||||
|
const pushes: string[] = []
|
||||||
|
let lspLoads = 0
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "server.instance.disposed" },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push(directory) {
|
||||||
|
pushes.push(directory)
|
||||||
|
},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {
|
||||||
|
lspLoads += 1
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "lsp.updated" },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push(directory) {
|
||||||
|
pushes.push(directory)
|
||||||
|
},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {
|
||||||
|
lspLoads += 1
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(pushes).toEqual(["/tmp"])
|
||||||
|
expect(lspLoads).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
337
packages/app/src/context/global-sync/event-reducer.ts
Normal file
337
packages/app/src/context/global-sync/event-reducer.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
|
import type {
|
||||||
|
FileDiff,
|
||||||
|
Message,
|
||||||
|
Part,
|
||||||
|
PermissionRequest,
|
||||||
|
Project,
|
||||||
|
QuestionRequest,
|
||||||
|
Session,
|
||||||
|
SessionStatus,
|
||||||
|
Todo,
|
||||||
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { State, VcsCache } from "./types"
|
||||||
|
import { trimSessions } from "./session-trim"
|
||||||
|
|
||||||
|
export function applyGlobalEvent(input: {
|
||||||
|
event: { type: string; properties?: unknown }
|
||||||
|
project: Project[]
|
||||||
|
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
|
||||||
|
refresh: () => void
|
||||||
|
}) {
|
||||||
|
if (input.event.type === "global.disposed") {
|
||||||
|
input.refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.event.type !== "project.updated") return
|
||||||
|
const properties = input.event.properties as Project
|
||||||
|
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
||||||
|
if (result.found) {
|
||||||
|
input.setGlobalProject((draft) => {
|
||||||
|
draft[result.index] = { ...draft[result.index], ...properties }
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.setGlobalProject((draft) => {
|
||||||
|
draft.splice(result.index, 0, properties)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
|
||||||
|
if (!sessionID) return
|
||||||
|
const hasAny =
|
||||||
|
store.message[sessionID] !== undefined ||
|
||||||
|
store.session_diff[sessionID] !== undefined ||
|
||||||
|
store.todo[sessionID] !== undefined ||
|
||||||
|
store.permission[sessionID] !== undefined ||
|
||||||
|
store.question[sessionID] !== undefined ||
|
||||||
|
store.session_status[sessionID] !== undefined
|
||||||
|
if (!hasAny) return
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
const messages = draft.message[sessionID]
|
||||||
|
if (messages) {
|
||||||
|
for (const message of messages) {
|
||||||
|
const id = message?.id
|
||||||
|
if (!id) continue
|
||||||
|
delete draft.part[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete draft.message[sessionID]
|
||||||
|
delete draft.session_diff[sessionID]
|
||||||
|
delete draft.todo[sessionID]
|
||||||
|
delete draft.permission[sessionID]
|
||||||
|
delete draft.question[sessionID]
|
||||||
|
delete draft.session_status[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDirectoryEvent(input: {
|
||||||
|
event: { type: string; properties?: unknown }
|
||||||
|
store: Store<State>
|
||||||
|
setStore: SetStoreFunction<State>
|
||||||
|
push: (directory: string) => void
|
||||||
|
directory: string
|
||||||
|
loadLsp: () => void
|
||||||
|
vcsCache?: VcsCache
|
||||||
|
}) {
|
||||||
|
const event = input.event
|
||||||
|
switch (event.type) {
|
||||||
|
case "server.instance.disposed": {
|
||||||
|
input.push(input.directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "session.created": {
|
||||||
|
const info = (event.properties as { info: Session }).info
|
||||||
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore("session", result.index, reconcile(info))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const next = input.store.session.slice()
|
||||||
|
next.splice(result.index, 0, info)
|
||||||
|
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||||
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
|
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.updated": {
|
||||||
|
const info = (event.properties as { info: Session }).info
|
||||||
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
|
if (info.time.archived) {
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cleanupSessionCaches(input.store, input.setStore, info.id)
|
||||||
|
if (info.parentID) break
|
||||||
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore("session", result.index, reconcile(info))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const next = input.store.session.slice()
|
||||||
|
next.splice(result.index, 0, info)
|
||||||
|
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||||
|
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.deleted": {
|
||||||
|
const info = (event.properties as { info: Session }).info
|
||||||
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cleanupSessionCaches(input.store, input.setStore, info.id)
|
||||||
|
if (info.parentID) break
|
||||||
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.diff": {
|
||||||
|
const props = event.properties as { sessionID: string; diff: FileDiff[] }
|
||||||
|
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "todo.updated": {
|
||||||
|
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||||
|
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.status": {
|
||||||
|
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||||
|
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.updated": {
|
||||||
|
const info = (event.properties as { info: Message }).info
|
||||||
|
const messages = input.store.message[info.sessionID]
|
||||||
|
if (!messages) {
|
||||||
|
input.setStore("message", info.sessionID, [info])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const result = Binary.search(messages, info.id, (m) => m.id)
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore("message", info.sessionID, result.index, reconcile(info))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
input.setStore(
|
||||||
|
"message",
|
||||||
|
info.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.removed": {
|
||||||
|
const props = event.properties as { sessionID: string; messageID: string }
|
||||||
|
input.setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
const messages = draft.message[props.sessionID]
|
||||||
|
if (messages) {
|
||||||
|
const result = Binary.search(messages, props.messageID, (m) => m.id)
|
||||||
|
if (result.found) messages.splice(result.index, 1)
|
||||||
|
}
|
||||||
|
delete draft.part[props.messageID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.part.updated": {
|
||||||
|
const part = (event.properties as { part: Part }).part
|
||||||
|
const parts = input.store.part[part.messageID]
|
||||||
|
if (!parts) {
|
||||||
|
input.setStore("part", part.messageID, [part])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const result = Binary.search(parts, part.id, (p) => p.id)
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore("part", part.messageID, result.index, reconcile(part))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
input.setStore(
|
||||||
|
"part",
|
||||||
|
part.messageID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, part)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.part.removed": {
|
||||||
|
const props = event.properties as { messageID: string; partID: string }
|
||||||
|
const parts = input.store.part[props.messageID]
|
||||||
|
if (!parts) break
|
||||||
|
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
const list = draft.part[props.messageID]
|
||||||
|
if (!list) return
|
||||||
|
const next = Binary.search(list, props.partID, (p) => p.id)
|
||||||
|
if (!next.found) return
|
||||||
|
list.splice(next.index, 1)
|
||||||
|
if (list.length === 0) delete draft.part[props.messageID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.part.delta": {
|
||||||
|
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
||||||
|
const parts = input.store.part[props.messageID]
|
||||||
|
if (!parts) break
|
||||||
|
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||||
|
if (!result.found) break
|
||||||
|
input.setStore(
|
||||||
|
"part",
|
||||||
|
props.messageID,
|
||||||
|
produce((draft) => {
|
||||||
|
const part = draft[result.index]
|
||||||
|
const field = props.field as keyof typeof part
|
||||||
|
const existing = part[field] as string | undefined
|
||||||
|
;(part[field] as string) = (existing ?? "") + props.delta
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "vcs.branch.updated": {
|
||||||
|
const props = event.properties as { branch: string }
|
||||||
|
const next = { branch: props.branch }
|
||||||
|
input.setStore("vcs", next)
|
||||||
|
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "permission.asked": {
|
||||||
|
const permission = event.properties as PermissionRequest
|
||||||
|
const permissions = input.store.permission[permission.sessionID]
|
||||||
|
if (!permissions) {
|
||||||
|
input.setStore("permission", permission.sessionID, [permission])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const result = Binary.search(permissions, permission.id, (p) => p.id)
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
input.setStore(
|
||||||
|
"permission",
|
||||||
|
permission.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, permission)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "permission.replied": {
|
||||||
|
const props = event.properties as { sessionID: string; requestID: string }
|
||||||
|
const permissions = input.store.permission[props.sessionID]
|
||||||
|
if (!permissions) break
|
||||||
|
const result = Binary.search(permissions, props.requestID, (p) => p.id)
|
||||||
|
if (!result.found) break
|
||||||
|
input.setStore(
|
||||||
|
"permission",
|
||||||
|
props.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "question.asked": {
|
||||||
|
const question = event.properties as QuestionRequest
|
||||||
|
const questions = input.store.question[question.sessionID]
|
||||||
|
if (!questions) {
|
||||||
|
input.setStore("question", question.sessionID, [question])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const result = Binary.search(questions, question.id, (q) => q.id)
|
||||||
|
if (result.found) {
|
||||||
|
input.setStore("question", question.sessionID, result.index, reconcile(question))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
input.setStore(
|
||||||
|
"question",
|
||||||
|
question.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, question)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "question.replied":
|
||||||
|
case "question.rejected": {
|
||||||
|
const props = event.properties as { sessionID: string; requestID: string }
|
||||||
|
const questions = input.store.question[props.sessionID]
|
||||||
|
if (!questions) break
|
||||||
|
const result = Binary.search(questions, props.requestID, (q) => q.id)
|
||||||
|
if (!result.found) break
|
||||||
|
input.setStore(
|
||||||
|
"question",
|
||||||
|
props.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "lsp.updated": {
|
||||||
|
input.loadLsp()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/app/src/context/global-sync/eviction.ts
Normal file
28
packages/app/src/context/global-sync/eviction.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { DisposeCheck, EvictPlan } from "./types"
|
||||||
|
|
||||||
|
export function pickDirectoriesToEvict(input: EvictPlan) {
|
||||||
|
const overflow = Math.max(0, input.stores.length - input.max)
|
||||||
|
let pendingOverflow = overflow
|
||||||
|
const sorted = input.stores
|
||||||
|
.filter((dir) => !input.pins.has(dir))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
|
||||||
|
const output: string[] = []
|
||||||
|
for (const dir of sorted) {
|
||||||
|
const last = input.state.get(dir)?.lastAccessAt ?? 0
|
||||||
|
const idle = input.now - last >= input.ttl
|
||||||
|
if (!idle && pendingOverflow <= 0) continue
|
||||||
|
output.push(dir)
|
||||||
|
if (pendingOverflow > 0) pendingOverflow -= 1
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canDisposeDirectory(input: DisposeCheck) {
|
||||||
|
if (!input.directory) return false
|
||||||
|
if (!input.hasStore) return false
|
||||||
|
if (input.pinned) return false
|
||||||
|
if (input.booting) return false
|
||||||
|
if (input.loadingSessions) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
83
packages/app/src/context/global-sync/queue.ts
Normal file
83
packages/app/src/context/global-sync/queue.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
type QueueInput = {
|
||||||
|
paused: () => boolean
|
||||||
|
bootstrap: () => Promise<void>
|
||||||
|
bootstrapInstance: (directory: string) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRefreshQueue(input: QueueInput) {
|
||||||
|
const queued = new Set<string>()
|
||||||
|
let root = false
|
||||||
|
let running = false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
const take = (count: number) => {
|
||||||
|
if (queued.size === 0) return [] as string[]
|
||||||
|
const items: string[] = []
|
||||||
|
for (const item of queued) {
|
||||||
|
queued.delete(item)
|
||||||
|
items.push(item)
|
||||||
|
if (items.length >= count) break
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (timer) return
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = undefined
|
||||||
|
void drain()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const push = (directory: string) => {
|
||||||
|
if (!directory) return
|
||||||
|
queued.add(directory)
|
||||||
|
if (input.paused()) return
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
root = true
|
||||||
|
if (input.paused()) return
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain() {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
if (input.paused()) return
|
||||||
|
if (root) {
|
||||||
|
root = false
|
||||||
|
await input.bootstrap()
|
||||||
|
await tick()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const dirs = take(2)
|
||||||
|
if (dirs.length === 0) return
|
||||||
|
await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
|
||||||
|
await tick()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
if (input.paused()) return
|
||||||
|
if (root || queued.size) schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
push,
|
||||||
|
refresh,
|
||||||
|
clear(directory: string) {
|
||||||
|
queued.delete(directory)
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (!timer) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/app/src/context/global-sync/session-load.ts
Normal file
26
packages/app/src/context/global-sync/session-load.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { RootLoadArgs } from "./types"
|
||||||
|
|
||||||
|
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
||||||
|
try {
|
||||||
|
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||||
|
return {
|
||||||
|
data: result.data,
|
||||||
|
limit: input.limit,
|
||||||
|
limited: true,
|
||||||
|
} as const
|
||||||
|
} catch {
|
||||||
|
input.onFallback()
|
||||||
|
const result = await input.list({ directory: input.directory, roots: true })
|
||||||
|
return {
|
||||||
|
data: result.data,
|
||||||
|
limit: input.limit,
|
||||||
|
limited: false,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
||||||
|
if (!input.limited) return input.count
|
||||||
|
if (input.count < input.limit) return input.count
|
||||||
|
return input.count + 1
|
||||||
|
}
|
||||||
59
packages/app/src/context/global-sync/session-trim.test.ts
Normal file
59
packages/app/src/context/global-sync/session-trim.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { trimSessions } from "./session-trim"
|
||||||
|
|
||||||
|
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
||||||
|
({
|
||||||
|
id: input.id,
|
||||||
|
parentID: input.parentID,
|
||||||
|
time: {
|
||||||
|
created: input.created,
|
||||||
|
updated: input.updated,
|
||||||
|
archived: input.archived,
|
||||||
|
},
|
||||||
|
}) as Session
|
||||||
|
|
||||||
|
describe("trimSessions", () => {
|
||||||
|
test("keeps base roots and recent roots beyond the limit", () => {
|
||||||
|
const now = 1_000_000
|
||||||
|
const list = [
|
||||||
|
session({ id: "a", created: now - 100_000 }),
|
||||||
|
session({ id: "b", created: now - 90_000 }),
|
||||||
|
session({ id: "c", created: now - 80_000 }),
|
||||||
|
session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
|
||||||
|
session({ id: "e", created: now - 60_000, archived: now - 10 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = trimSessions(list, { limit: 2, permission: {}, now })
|
||||||
|
expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps children when root is kept, permission exists, or child is recent", () => {
|
||||||
|
const now = 1_000_000
|
||||||
|
const list = [
|
||||||
|
session({ id: "root-1", created: now - 1000 }),
|
||||||
|
session({ id: "root-2", created: now - 2000 }),
|
||||||
|
session({ id: "z-root", created: now - 30_000_000 }),
|
||||||
|
session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
|
||||||
|
session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
|
||||||
|
session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
|
||||||
|
session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = trimSessions(list, {
|
||||||
|
limit: 2,
|
||||||
|
permission: {
|
||||||
|
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.map((x) => x.id)).toEqual([
|
||||||
|
"child-kept-by-permission",
|
||||||
|
"child-kept-by-recency",
|
||||||
|
"child-kept-by-root",
|
||||||
|
"root-1",
|
||||||
|
"root-2",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
56
packages/app/src/context/global-sync/session-trim.ts
Normal file
56
packages/app/src/context/global-sync/session-trim.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { cmp } from "./utils"
|
||||||
|
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||||
|
|
||||||
|
export function sessionUpdatedAt(session: Session) {
|
||||||
|
return session.time.updated ?? session.time.created
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareSessionRecent(a: Session, b: Session) {
|
||||||
|
const aUpdated = sessionUpdatedAt(a)
|
||||||
|
const bUpdated = sessionUpdatedAt(b)
|
||||||
|
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
||||||
|
return cmp(a.id, b.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
|
||||||
|
if (limit <= 0) return [] as Session[]
|
||||||
|
const selected: Session[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!session?.id) continue
|
||||||
|
if (seen.has(session.id)) continue
|
||||||
|
seen.add(session.id)
|
||||||
|
if (sessionUpdatedAt(session) <= cutoff) continue
|
||||||
|
const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
|
||||||
|
if (index === -1) selected.push(session)
|
||||||
|
if (index !== -1) selected.splice(index, 0, session)
|
||||||
|
if (selected.length > limit) selected.pop()
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimSessions(
|
||||||
|
input: Session[],
|
||||||
|
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
|
||||||
|
) {
|
||||||
|
const limit = Math.max(0, options.limit)
|
||||||
|
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
|
||||||
|
const all = input
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
|
.filter((s) => !s.time?.archived)
|
||||||
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
const roots = all.filter((s) => !s.parentID)
|
||||||
|
const children = all.filter((s) => !!s.parentID)
|
||||||
|
const base = roots.slice(0, limit)
|
||||||
|
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
|
||||||
|
const keepRoots = [...base, ...recent]
|
||||||
|
const keepRootIds = new Set(keepRoots.map((s) => s.id))
|
||||||
|
const keepChildren = children.filter((s) => {
|
||||||
|
if (s.parentID && keepRootIds.has(s.parentID)) return true
|
||||||
|
const perms = options.permission[s.id] ?? []
|
||||||
|
if (perms.length > 0) return true
|
||||||
|
return sessionUpdatedAt(s) > cutoff
|
||||||
|
})
|
||||||
|
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}
|
||||||
134
packages/app/src/context/global-sync/types.ts
Normal file
134
packages/app/src/context/global-sync/types.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import type {
|
||||||
|
Agent,
|
||||||
|
Command,
|
||||||
|
Config,
|
||||||
|
FileDiff,
|
||||||
|
LspStatus,
|
||||||
|
McpStatus,
|
||||||
|
Message,
|
||||||
|
Part,
|
||||||
|
Path,
|
||||||
|
PermissionRequest,
|
||||||
|
Project,
|
||||||
|
ProviderListResponse,
|
||||||
|
QuestionRequest,
|
||||||
|
Session,
|
||||||
|
SessionStatus,
|
||||||
|
Todo,
|
||||||
|
VcsInfo,
|
||||||
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { Accessor } from "solid-js"
|
||||||
|
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||||
|
|
||||||
|
export type ProjectMeta = {
|
||||||
|
name?: string
|
||||||
|
icon?: {
|
||||||
|
override?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
commands?: {
|
||||||
|
start?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type State = {
|
||||||
|
status: "loading" | "partial" | "complete"
|
||||||
|
agent: Agent[]
|
||||||
|
command: Command[]
|
||||||
|
project: string
|
||||||
|
projectMeta: ProjectMeta | undefined
|
||||||
|
icon: string | undefined
|
||||||
|
provider: ProviderListResponse
|
||||||
|
config: Config
|
||||||
|
path: Path
|
||||||
|
session: Session[]
|
||||||
|
sessionTotal: number
|
||||||
|
session_status: {
|
||||||
|
[sessionID: string]: SessionStatus
|
||||||
|
}
|
||||||
|
session_diff: {
|
||||||
|
[sessionID: string]: FileDiff[]
|
||||||
|
}
|
||||||
|
todo: {
|
||||||
|
[sessionID: string]: Todo[]
|
||||||
|
}
|
||||||
|
permission: {
|
||||||
|
[sessionID: string]: PermissionRequest[]
|
||||||
|
}
|
||||||
|
question: {
|
||||||
|
[sessionID: string]: QuestionRequest[]
|
||||||
|
}
|
||||||
|
mcp: {
|
||||||
|
[name: string]: McpStatus
|
||||||
|
}
|
||||||
|
lsp: LspStatus[]
|
||||||
|
vcs: VcsInfo | undefined
|
||||||
|
limit: number
|
||||||
|
message: {
|
||||||
|
[sessionID: string]: Message[]
|
||||||
|
}
|
||||||
|
part: {
|
||||||
|
[messageID: string]: Part[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VcsCache = {
|
||||||
|
store: Store<{ value: VcsInfo | undefined }>
|
||||||
|
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
|
||||||
|
ready: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetaCache = {
|
||||||
|
store: Store<{ value: ProjectMeta | undefined }>
|
||||||
|
setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
|
||||||
|
ready: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IconCache = {
|
||||||
|
store: Store<{ value: string | undefined }>
|
||||||
|
setStore: SetStoreFunction<{ value: string | undefined }>
|
||||||
|
ready: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChildOptions = {
|
||||||
|
bootstrap?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirState = {
|
||||||
|
lastAccessAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvictPlan = {
|
||||||
|
stores: string[]
|
||||||
|
state: Map<string, DirState>
|
||||||
|
pins: Set<string>
|
||||||
|
max: number
|
||||||
|
ttl: number
|
||||||
|
now: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisposeCheck = {
|
||||||
|
directory: string
|
||||||
|
hasStore: boolean
|
||||||
|
pinned: boolean
|
||||||
|
booting: boolean
|
||||||
|
loadingSessions: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RootLoadArgs = {
|
||||||
|
directory: string
|
||||||
|
limit: number
|
||||||
|
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||||
|
onFallback: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RootLoadResult = {
|
||||||
|
data?: Session[]
|
||||||
|
limit: number
|
||||||
|
limited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_DIR_STORES = 30
|
||||||
|
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||||
|
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
||||||
|
export const SESSION_RECENT_LIMIT = 50
|
||||||
25
packages/app/src/context/global-sync/utils.ts
Normal file
25
packages/app/src/context/global-sync/utils.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
|
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
all: input.all.map((provider) => ({
|
||||||
|
...provider,
|
||||||
|
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeProject(project: Project) {
|
||||||
|
if (!project.icon?.url && !project.icon?.override) return project
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
icon: {
|
||||||
|
...project.icon,
|
||||||
|
url: undefined,
|
||||||
|
override: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,26 @@ const LOCALES: readonly Locale[] = [
|
|||||||
"th",
|
"th",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||||
|
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||||
|
zh,
|
||||||
|
zht,
|
||||||
|
ko,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fr,
|
||||||
|
da,
|
||||||
|
ja,
|
||||||
|
pl,
|
||||||
|
ru,
|
||||||
|
ar,
|
||||||
|
no,
|
||||||
|
br,
|
||||||
|
th,
|
||||||
|
bs,
|
||||||
|
}
|
||||||
|
void PARITY_CHECK
|
||||||
|
|
||||||
function detectLocale(): Locale {
|
function detectLocale(): Locale {
|
||||||
if (typeof navigator !== "object") return "en"
|
if (typeof navigator !== "object") return "en"
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,36 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createRoot } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
|
|
||||||
import { createScrollPersistence } from "./layout-scroll"
|
import { createScrollPersistence } from "./layout-scroll"
|
||||||
|
|
||||||
describe("createScrollPersistence", () => {
|
describe("createScrollPersistence", () => {
|
||||||
test.skip("debounces persisted scroll writes", async () => {
|
test("debounces persisted scroll writes", async () => {
|
||||||
const key = "layout-scroll.test"
|
const snapshot = {
|
||||||
const data = new Map<string, string>()
|
session: {
|
||||||
const writes: string[] = []
|
review: { x: 0, y: 0 },
|
||||||
const stats = { flushes: 0 }
|
|
||||||
|
|
||||||
const storage = {
|
|
||||||
getItem: (k: string) => data.get(k) ?? null,
|
|
||||||
setItem: (k: string, v: string) => {
|
|
||||||
data.set(k, v)
|
|
||||||
if (k === key) writes.push(v)
|
|
||||||
},
|
},
|
||||||
removeItem: (k: string) => {
|
} as Record<string, Record<string, { x: number; y: number }>>
|
||||||
data.delete(k)
|
const writes: Array<Record<string, { x: number; y: number }>> = []
|
||||||
},
|
|
||||||
} as SyncStorage
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
createRoot((dispose) => {
|
|
||||||
const [raw, setRaw] = createStore({
|
|
||||||
sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
|
|
||||||
|
|
||||||
const scroll = createScrollPersistence({
|
const scroll = createScrollPersistence({
|
||||||
debounceMs: 30,
|
debounceMs: 10,
|
||||||
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
|
getSnapshot: (sessionKey) => snapshot[sessionKey],
|
||||||
onFlush: (sessionKey, next) => {
|
onFlush: (sessionKey, next) => {
|
||||||
stats.flushes += 1
|
snapshot[sessionKey] = next
|
||||||
|
writes.push(next)
|
||||||
const current = store.sessionView[sessionKey]
|
|
||||||
if (!current) {
|
|
||||||
setStore("sessionView", sessionKey, { scroll: next })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = async () => {
|
for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
|
||||||
await new Promise((r) => setTimeout(r, 0))
|
|
||||||
writes.length = 0
|
|
||||||
|
|
||||||
for (const i of Array.from({ length: 100 }, (_, n) => n)) {
|
|
||||||
scroll.setScroll("session", "review", { x: 0, y: i })
|
scroll.setScroll("session", "review", { x: 0, y: i })
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 120))
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||||
|
|
||||||
expect(stats.flushes).toBeGreaterThanOrEqual(1)
|
expect(writes).toHaveLength(1)
|
||||||
expect(writes.length).toBeGreaterThanOrEqual(1)
|
expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
|
||||||
expect(writes.length).toBeLessThanOrEqual(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
void run()
|
scroll.setScroll("session", "review", { x: 0, y: 30 })
|
||||||
.then(resolve)
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||||
.catch(reject)
|
|
||||||
.finally(() => {
|
expect(writes).toHaveLength(1)
|
||||||
scroll.dispose()
|
scroll.dispose()
|
||||||
dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
69
packages/app/src/context/layout.test.ts
Normal file
69
packages/app/src/context/layout.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createRoot, createSignal } from "solid-js"
|
||||||
|
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
|
||||||
|
|
||||||
|
describe("layout session-key helpers", () => {
|
||||||
|
test("couples touch and scroll seed in order", () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const result = ensureSessionKey(
|
||||||
|
"dir/a",
|
||||||
|
(key) => calls.push(`touch:${key}`),
|
||||||
|
(key) => calls.push(`seed:${key}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe("dir/a")
|
||||||
|
expect(calls).toEqual(["touch:dir/a", "seed:dir/a"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads dynamic accessor keys lazily", () => {
|
||||||
|
const seen: string[] = []
|
||||||
|
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [key, setKey] = createSignal("dir/one")
|
||||||
|
const read = createSessionKeyReader(key, (value) => seen.push(value))
|
||||||
|
|
||||||
|
expect(read()).toBe("dir/one")
|
||||||
|
setKey("dir/two")
|
||||||
|
expect(read()).toBe("dir/two")
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(seen).toEqual(["dir/one", "dir/two"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pruneSessionKeys", () => {
|
||||||
|
test("keeps active key and drops lowest-used keys", () => {
|
||||||
|
const drop = pruneSessionKeys({
|
||||||
|
keep: "k4",
|
||||||
|
max: 3,
|
||||||
|
used: new Map([
|
||||||
|
["k1", 1],
|
||||||
|
["k2", 2],
|
||||||
|
["k3", 3],
|
||||||
|
["k4", 4],
|
||||||
|
]),
|
||||||
|
view: ["k1", "k2", "k4"],
|
||||||
|
tabs: ["k1", "k3", "k4"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(drop).toEqual(["k1"])
|
||||||
|
expect(drop.includes("k4")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not prune without keep key", () => {
|
||||||
|
const drop = pruneSessionKeys({
|
||||||
|
keep: undefined,
|
||||||
|
max: 1,
|
||||||
|
used: new Map([
|
||||||
|
["k1", 1],
|
||||||
|
["k2", 2],
|
||||||
|
]),
|
||||||
|
view: ["k1"],
|
||||||
|
tabs: ["k2"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(drop).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
@@ -47,6 +47,43 @@ 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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -172,20 +209,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prune(keep?: string) {
|
function prune(keep?: string) {
|
||||||
if (!keep) return
|
const drop = pruneSessionKeys({
|
||||||
|
keep,
|
||||||
const keys = new Set<string>()
|
max: MAX_SESSION_KEYS,
|
||||||
for (const key of Object.keys(store.sessionView)) keys.add(key)
|
used,
|
||||||
for (const key of Object.keys(store.sessionTabs)) keys.add(key)
|
view: Object.keys(store.sessionView),
|
||||||
if (keys.size <= MAX_SESSION_KEYS) return
|
tabs: Object.keys(store.sessionTabs),
|
||||||
|
})
|
||||||
const score = (key: string) => {
|
|
||||||
if (key === keep) return Number.MAX_SAFE_INTEGER
|
|
||||||
return used.get(key) ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
|
|
||||||
const drop = ordered.slice(MAX_SESSION_KEYS)
|
|
||||||
if (drop.length === 0) return
|
if (drop.length === 0) return
|
||||||
|
|
||||||
setStore(
|
setStore(
|
||||||
@@ -233,6 +263,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
if (meta.pruned) return
|
if (meta.pruned) return
|
||||||
@@ -616,22 +648,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
view(sessionKey: string | Accessor<string>) {
|
view(sessionKey: string | Accessor<string>) {
|
||||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||||
|
|
||||||
touch(key())
|
|
||||||
scroll.seed(key())
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
key,
|
|
||||||
(value) => {
|
|
||||||
touch(value)
|
|
||||||
scroll.seed(value)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
|
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
|
||||||
@@ -711,20 +728,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
tabs(sessionKey: string | Accessor<string>) {
|
tabs(sessionKey: string | Accessor<string>) {
|
||||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||||
|
|
||||||
touch(key())
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
key,
|
|
||||||
(value) => {
|
|
||||||
touch(value)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs,
|
||||||
|
|||||||
66
packages/app/src/context/notification-index.ts
Normal file
66
packages/app/src/context/notification-index.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
type NotificationIndexItem = {
|
||||||
|
directory?: string
|
||||||
|
session?: string
|
||||||
|
viewed: boolean
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
|
||||||
|
const sessionAll = new Map<string, T[]>()
|
||||||
|
const sessionUnseen = new Map<string, T[]>()
|
||||||
|
const sessionUnseenCount = new Map<string, number>()
|
||||||
|
const sessionUnseenHasError = new Map<string, boolean>()
|
||||||
|
const projectAll = new Map<string, T[]>()
|
||||||
|
const projectUnseen = new Map<string, T[]>()
|
||||||
|
const projectUnseenCount = new Map<string, number>()
|
||||||
|
const projectUnseenHasError = new Map<string, boolean>()
|
||||||
|
|
||||||
|
for (const notification of list) {
|
||||||
|
const session = notification.session
|
||||||
|
if (session) {
|
||||||
|
const all = sessionAll.get(session)
|
||||||
|
if (all) all.push(notification)
|
||||||
|
else sessionAll.set(session, [notification])
|
||||||
|
|
||||||
|
if (!notification.viewed) {
|
||||||
|
const unseen = sessionUnseen.get(session)
|
||||||
|
if (unseen) unseen.push(notification)
|
||||||
|
else sessionUnseen.set(session, [notification])
|
||||||
|
|
||||||
|
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
|
||||||
|
if (notification.type === "error") sessionUnseenHasError.set(session, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = notification.directory
|
||||||
|
if (directory) {
|
||||||
|
const all = projectAll.get(directory)
|
||||||
|
if (all) all.push(notification)
|
||||||
|
else projectAll.set(directory, [notification])
|
||||||
|
|
||||||
|
if (!notification.viewed) {
|
||||||
|
const unseen = projectUnseen.get(directory)
|
||||||
|
if (unseen) unseen.push(notification)
|
||||||
|
else projectUnseen.set(directory, [notification])
|
||||||
|
|
||||||
|
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
|
||||||
|
if (notification.type === "error") projectUnseenHasError.set(directory, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
all: sessionAll,
|
||||||
|
unseen: sessionUnseen,
|
||||||
|
unseenCount: sessionUnseenCount,
|
||||||
|
unseenHasError: sessionUnseenHasError,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
all: projectAll,
|
||||||
|
unseen: projectUnseen,
|
||||||
|
unseenCount: projectUnseenCount,
|
||||||
|
unseenHasError: projectUnseenHasError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/app/src/context/notification.test.ts
Normal file
73
packages/app/src/context/notification.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { buildNotificationIndex } from "./notification-index"
|
||||||
|
|
||||||
|
type Notification = {
|
||||||
|
type: "turn-complete" | "error"
|
||||||
|
session: string
|
||||||
|
directory: string
|
||||||
|
viewed: boolean
|
||||||
|
time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const turn = (session: string, directory: string, viewed = false): Notification => ({
|
||||||
|
type: "turn-complete",
|
||||||
|
session,
|
||||||
|
directory,
|
||||||
|
viewed,
|
||||||
|
time: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = (session: string, directory: string, viewed = false): Notification => ({
|
||||||
|
type: "error",
|
||||||
|
session,
|
||||||
|
directory,
|
||||||
|
viewed,
|
||||||
|
time: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildNotificationIndex", () => {
|
||||||
|
test("builds unseen counts and unseen error flags", () => {
|
||||||
|
const list = [
|
||||||
|
turn("s1", "d1", false),
|
||||||
|
error("s1", "d1", false),
|
||||||
|
turn("s1", "d1", true),
|
||||||
|
turn("s2", "d1", false),
|
||||||
|
error("s3", "d2", true),
|
||||||
|
]
|
||||||
|
|
||||||
|
const index = buildNotificationIndex(list)
|
||||||
|
|
||||||
|
expect(index.session.all.get("s1")?.length).toBe(3)
|
||||||
|
expect(index.session.unseen.get("s1")?.length).toBe(2)
|
||||||
|
expect(index.session.unseenCount.get("s1")).toBe(2)
|
||||||
|
expect(index.session.unseenHasError.get("s1")).toBe(true)
|
||||||
|
|
||||||
|
expect(index.session.unseenCount.get("s2")).toBe(1)
|
||||||
|
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
|
||||||
|
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
|
||||||
|
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
|
||||||
|
|
||||||
|
expect(index.project.unseenCount.get("d1")).toBe(3)
|
||||||
|
expect(index.project.unseenHasError.get("d1")).toBe(true)
|
||||||
|
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
|
||||||
|
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates selectors after viewed transitions", () => {
|
||||||
|
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
|
||||||
|
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
|
||||||
|
|
||||||
|
const before = buildNotificationIndex(list)
|
||||||
|
const after = buildNotificationIndex(next)
|
||||||
|
|
||||||
|
expect(before.session.unseenCount.get("s1")).toBe(2)
|
||||||
|
expect(before.session.unseenHasError.get("s1")).toBe(true)
|
||||||
|
expect(before.project.unseenCount.get("d1")).toBe(3)
|
||||||
|
expect(before.project.unseenHasError.get("d1")).toBe(true)
|
||||||
|
|
||||||
|
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
|
||||||
|
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
|
||||||
|
expect(after.project.unseenCount.get("d1")).toBe(1)
|
||||||
|
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,6 +13,7 @@ import { decode64 } from "@/utils/base64"
|
|||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSound, soundSrc } from "@/utils/sound"
|
import { playSound, soundSrc } from "@/utils/sound"
|
||||||
|
import { buildNotificationIndex } from "./notification-index"
|
||||||
|
|
||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -81,49 +82,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
setStore("list", (list) => pruneNotifications([...list, notification]))
|
setStore("list", (list) => pruneNotifications([...list, notification]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = createMemo(() => {
|
const index = createMemo(() => buildNotificationIndex(store.list))
|
||||||
const sessionAll = new Map<string, Notification[]>()
|
|
||||||
const sessionUnseen = new Map<string, Notification[]>()
|
|
||||||
const projectAll = new Map<string, Notification[]>()
|
|
||||||
const projectUnseen = new Map<string, Notification[]>()
|
|
||||||
|
|
||||||
for (const notification of store.list) {
|
|
||||||
const session = notification.session
|
|
||||||
if (session) {
|
|
||||||
const list = sessionAll.get(session)
|
|
||||||
if (list) list.push(notification)
|
|
||||||
else sessionAll.set(session, [notification])
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = sessionUnseen.get(session)
|
|
||||||
if (unseen) unseen.push(notification)
|
|
||||||
else sessionUnseen.set(session, [notification])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = notification.directory
|
|
||||||
if (directory) {
|
|
||||||
const list = projectAll.get(directory)
|
|
||||||
if (list) list.push(notification)
|
|
||||||
else projectAll.set(directory, [notification])
|
|
||||||
if (!notification.viewed) {
|
|
||||||
const unseen = projectUnseen.get(directory)
|
|
||||||
if (unseen) unseen.push(notification)
|
|
||||||
else projectUnseen.set(directory, [notification])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
session: {
|
|
||||||
all: sessionAll,
|
|
||||||
unseen: sessionUnseen,
|
|
||||||
},
|
|
||||||
project: {
|
|
||||||
all: projectAll,
|
|
||||||
unseen: projectUnseen,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
@@ -208,6 +167,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
unseen(session: string) {
|
unseen(session: string) {
|
||||||
return index().session.unseen.get(session) ?? empty
|
return index().session.unseen.get(session) ?? empty
|
||||||
},
|
},
|
||||||
|
unseenCount(session: string) {
|
||||||
|
return index().session.unseenCount.get(session) ?? 0
|
||||||
|
},
|
||||||
|
unseenHasError(session: string) {
|
||||||
|
return index().session.unseenHasError.get(session) ?? false
|
||||||
|
},
|
||||||
markViewed(session: string) {
|
markViewed(session: string) {
|
||||||
setStore("list", (n) => n.session === session, "viewed", true)
|
setStore("list", (n) => n.session === session, "viewed", true)
|
||||||
},
|
},
|
||||||
@@ -219,6 +184,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
unseen(directory: string) {
|
unseen(directory: string) {
|
||||||
return index().project.unseen.get(directory) ?? empty
|
return index().project.unseen.get(directory) ?? empty
|
||||||
},
|
},
|
||||||
|
unseenCount(directory: string) {
|
||||||
|
return index().project.unseenCount.get(directory) ?? 0
|
||||||
|
},
|
||||||
|
unseenHasError(directory: string) {
|
||||||
|
return index().project.unseenHasError.get(directory) ?? false
|
||||||
|
},
|
||||||
markViewed(directory: string) {
|
markViewed(directory: string) {
|
||||||
setStore("list", (n) => n.directory === directory, "viewed", true)
|
setStore("list", (n) => n.directory === directory, "viewed", true)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export type Platform = {
|
|||||||
|
|
||||||
/** Webview zoom level (desktop only) */
|
/** Webview zoom level (desktop only) */
|
||||||
webviewZoom?: Accessor<number>
|
webviewZoom?: Accessor<number>
|
||||||
|
|
||||||
|
/** Check if an editor app exists (desktop only) */
|
||||||
|
checkAppExists?(appName: string): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { checkServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
|
|
||||||
@@ -94,18 +94,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!state.active)
|
const isReady = createMemo(() => ready() && !!state.active)
|
||||||
|
|
||||||
const check = (url: string) => {
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
|
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
|
||||||
const sdk = createOpencodeClient({
|
|
||||||
baseUrl: url,
|
|
||||||
fetch: platform.fetch,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
return sdk.global
|
|
||||||
.health()
|
|
||||||
.then((x) => x.data?.healthy === true)
|
|
||||||
.catch(() => false)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const url = state.active
|
const url = state.active
|
||||||
|
|||||||
56
packages/app/src/context/sync-optimistic.test.ts
Normal file
56
packages/app/src/context/sync-optimistic.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
|
||||||
|
|
||||||
|
const userMessage = (id: string, sessionID: string): Message => ({
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "assistant",
|
||||||
|
model: { providerID: "openai", modelID: "gpt" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const textPart = (id: string, sessionID: string, messageID: string): Part => ({
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sync optimistic reducers", () => {
|
||||||
|
test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
|
||||||
|
const sessionID = "ses_1"
|
||||||
|
const draft = {
|
||||||
|
message: { [sessionID]: [userMessage("msg_2", sessionID)] },
|
||||||
|
part: {} as Record<string, Part[] | undefined>,
|
||||||
|
}
|
||||||
|
|
||||||
|
applyOptimisticAdd(draft, {
|
||||||
|
sessionID,
|
||||||
|
message: userMessage("msg_1", sessionID),
|
||||||
|
parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
|
||||||
|
expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applyOptimisticRemove removes message and part entries", () => {
|
||||||
|
const sessionID = "ses_1"
|
||||||
|
const draft = {
|
||||||
|
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
|
||||||
|
part: {
|
||||||
|
msg_1: [textPart("prt_1", sessionID, "msg_1")],
|
||||||
|
msg_2: [textPart("prt_2", sessionID, "msg_2")],
|
||||||
|
} as Record<string, Part[] | undefined>,
|
||||||
|
}
|
||||||
|
|
||||||
|
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
|
||||||
|
|
||||||
|
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
|
||||||
|
expect(draft.part.msg_1).toBeUndefined()
|
||||||
|
expect(draft.part.msg_2).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,43 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
|||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
|
type OptimisticStore = {
|
||||||
|
message: Record<string, Message[] | undefined>
|
||||||
|
part: Record<string, Part[] | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
type OptimisticAddInput = {
|
||||||
|
sessionID: string
|
||||||
|
message: Message
|
||||||
|
parts: Part[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type OptimisticRemoveInput = {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||||
|
const messages = draft.message[input.sessionID]
|
||||||
|
if (!messages) {
|
||||||
|
draft.message[input.sessionID] = [input.message]
|
||||||
|
}
|
||||||
|
if (messages) {
|
||||||
|
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||||
|
messages.splice(result.index, 0, input.message)
|
||||||
|
}
|
||||||
|
draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||||
|
const messages = draft.message[input.sessionID]
|
||||||
|
if (messages) {
|
||||||
|
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||||
|
if (result.found) messages.splice(result.index, 1)
|
||||||
|
}
|
||||||
|
delete draft.part[input.messageID]
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -21,6 +58,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
type Setter = Child[1]
|
type Setter = Child[1]
|
||||||
|
|
||||||
const current = createMemo(() => globalSync.child(sdk.directory))
|
const current = createMemo(() => globalSync.child(sdk.directory))
|
||||||
|
const target = (directory?: string) => {
|
||||||
|
if (!directory || directory === sdk.directory) return current()
|
||||||
|
return globalSync.child(directory)
|
||||||
|
}
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const chunk = 400
|
const chunk = 400
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
@@ -107,6 +148,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
get: getSession,
|
get: getSession,
|
||||||
|
optimistic: {
|
||||||
|
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||||
|
const [, setStore] = target(input.directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
applyOptimisticAdd(draft as OptimisticStore, input)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||||
|
const [, setStore] = target(input.directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
applyOptimisticRemove(draft as OptimisticStore, input)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
addOptimisticMessage(input: {
|
addOptimisticMessage(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
@@ -122,16 +181,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
}
|
}
|
||||||
current()[1](
|
const [, setStore] = target()
|
||||||
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[input.sessionID]
|
applyOptimisticAdd(draft as OptimisticStore, {
|
||||||
if (!messages) {
|
sessionID: input.sessionID,
|
||||||
draft.message[input.sessionID] = [message]
|
message,
|
||||||
} else {
|
parts: input.parts,
|
||||||
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
})
|
||||||
messages.splice(result.index, 0, message)
|
|
||||||
}
|
|
||||||
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
38
packages/app/src/context/terminal.test.ts
Normal file
38
packages/app/src/context/terminal.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||||
|
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mock.module("@solidjs/router", () => ({
|
||||||
|
useParams: () => ({}),
|
||||||
|
}))
|
||||||
|
mock.module("@opencode-ai/ui/context", () => ({
|
||||||
|
createSimpleContext: () => ({
|
||||||
|
use: () => undefined,
|
||||||
|
provider: () => undefined,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
const mod = await import("./terminal")
|
||||||
|
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||||
|
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getWorkspaceTerminalCacheKey", () => {
|
||||||
|
test("uses workspace-only directory cache key", () => {
|
||||||
|
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getLegacyTerminalStorageKeys", () => {
|
||||||
|
test("keeps workspace storage path when no legacy session id", () => {
|
||||||
|
expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes legacy session path before workspace path", () => {
|
||||||
|
expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([
|
||||||
|
"/repo/terminal/session-123.v1",
|
||||||
|
"/repo/terminal.v1",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,15 +19,24 @@ export type LocalPTY = {
|
|||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_TERMINAL_SESSIONS = 20
|
const MAX_TERMINAL_SESSIONS = 20
|
||||||
|
|
||||||
type TerminalSession = ReturnType<typeof createTerminalSession>
|
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||||
|
return `${dir}:${WORKSPACE_KEY}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
|
||||||
|
if (!legacySessionID) return [`${dir}/terminal.v1`]
|
||||||
|
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
|
||||||
|
|
||||||
type TerminalCacheEntry = {
|
type TerminalCacheEntry = {
|
||||||
value: TerminalSession
|
value: TerminalSession
|
||||||
dispose: VoidFunction
|
dispose: VoidFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) {
|
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||||
const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`]
|
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||||
|
|
||||||
const numberFromTitle = (title: string) => {
|
const numberFromTitle = (title: string) => {
|
||||||
const match = title.match(/^Terminal (\d+)$/)
|
const match = title.match(/^Terminal (\d+)$/)
|
||||||
@@ -235,8 +244,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const load = (dir: string, session?: string) => {
|
const loadWorkspace = (dir: string, legacySessionID?: string) => {
|
||||||
const key = `${dir}:${WORKSPACE_KEY}`
|
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
|
||||||
|
const key = getWorkspaceTerminalCacheKey(dir)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
@@ -245,7 +255,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entry = createRoot((dispose) => ({
|
const entry = createRoot((dispose) => ({
|
||||||
value: createTerminalSession(sdk, dir, session),
|
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
|
||||||
dispose,
|
dispose,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -254,7 +264,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
return entry.value
|
return entry.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = createMemo(() => load(params.dir!, params.id))
|
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => workspace().ready(),
|
ready: () => workspace().ready(),
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "فتح الإعدادات",
|
"command.settings.open": "فتح الإعدادات",
|
||||||
"command.session.previous": "الجلسة السابقة",
|
"command.session.previous": "الجلسة السابقة",
|
||||||
"command.session.next": "الجلسة التالية",
|
"command.session.next": "الجلسة التالية",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "الجلسة غير المقروءة السابقة",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "الجلسة غير المقروءة التالية",
|
||||||
"command.session.archive": "أرشفة الجلسة",
|
"command.session.archive": "أرشفة الجلسة",
|
||||||
|
|
||||||
"command.palette": "لوحة الأوامر",
|
"command.palette": "لوحة الأوامر",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Abrir configurações",
|
"command.settings.open": "Abrir configurações",
|
||||||
"command.session.previous": "Sessão anterior",
|
"command.session.previous": "Sessão anterior",
|
||||||
"command.session.next": "Próxima sessão",
|
"command.session.next": "Próxima sessão",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Sessão não lida anterior",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "Próxima sessão não lida",
|
||||||
"command.session.archive": "Arquivar sessão",
|
"command.session.archive": "Arquivar sessão",
|
||||||
|
|
||||||
"command.palette": "Paleta de comandos",
|
"command.palette": "Paleta de comandos",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Åbn indstillinger",
|
"command.settings.open": "Åbn indstillinger",
|
||||||
"command.session.previous": "Forrige session",
|
"command.session.previous": "Forrige session",
|
||||||
"command.session.next": "Næste session",
|
"command.session.next": "Næste session",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Forrige ulæste session",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "Næste ulæste session",
|
||||||
"command.session.archive": "Arkivér session",
|
"command.session.archive": "Arkivér session",
|
||||||
|
|
||||||
"command.palette": "Kommandopalette",
|
"command.palette": "Kommandopalette",
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Einstellungen öffnen",
|
"command.settings.open": "Einstellungen öffnen",
|
||||||
"command.session.previous": "Vorherige Sitzung",
|
"command.session.previous": "Vorherige Sitzung",
|
||||||
"command.session.next": "Nächste Sitzung",
|
"command.session.next": "Nächste Sitzung",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Vorherige ungelesene Sitzung",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "Nächste ungelesene Sitzung",
|
||||||
"command.session.archive": "Sitzung archivieren",
|
"command.session.archive": "Sitzung archivieren",
|
||||||
|
|
||||||
"command.palette": "Befehlspalette",
|
"command.palette": "Befehlspalette",
|
||||||
@@ -147,6 +147,44 @@ export const dict = {
|
|||||||
"provider.connect.toast.connected.title": "{{provider}} verbunden",
|
"provider.connect.toast.connected.title": "{{provider}} verbunden",
|
||||||
"provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.",
|
"provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.",
|
||||||
|
|
||||||
|
"provider.custom.title": "Benutzerdefinierter Anbieter",
|
||||||
|
"provider.custom.description.prefix": "Konfigurieren Sie einen OpenAI-kompatiblen Anbieter. Siehe die ",
|
||||||
|
"provider.custom.description.link": "Anbieter-Konfigurationsdokumente",
|
||||||
|
"provider.custom.description.suffix": ".",
|
||||||
|
"provider.custom.field.providerID.label": "Anbieter-ID",
|
||||||
|
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||||
|
"provider.custom.field.providerID.description": "Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
|
||||||
|
"provider.custom.field.name.label": "Anzeigename",
|
||||||
|
"provider.custom.field.name.placeholder": "Mein KI-Anbieter",
|
||||||
|
"provider.custom.field.baseURL.label": "Basis-URL",
|
||||||
|
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||||
|
"provider.custom.field.apiKey.label": "API-Schlüssel",
|
||||||
|
"provider.custom.field.apiKey.placeholder": "API-Schlüssel",
|
||||||
|
"provider.custom.field.apiKey.description":
|
||||||
|
"Optional. Leer lassen, wenn Sie die Authentifizierung über Header verwalten.",
|
||||||
|
"provider.custom.models.label": "Modelle",
|
||||||
|
"provider.custom.models.id.label": "ID",
|
||||||
|
"provider.custom.models.id.placeholder": "model-id",
|
||||||
|
"provider.custom.models.name.label": "Name",
|
||||||
|
"provider.custom.models.name.placeholder": "Anzeigename",
|
||||||
|
"provider.custom.models.remove": "Modell entfernen",
|
||||||
|
"provider.custom.models.add": "Modell hinzufügen",
|
||||||
|
"provider.custom.headers.label": "Header (optional)",
|
||||||
|
"provider.custom.headers.key.label": "Header",
|
||||||
|
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||||
|
"provider.custom.headers.value.label": "Wert",
|
||||||
|
"provider.custom.headers.value.placeholder": "wert",
|
||||||
|
"provider.custom.headers.remove": "Header entfernen",
|
||||||
|
"provider.custom.headers.add": "Header hinzufügen",
|
||||||
|
"provider.custom.error.providerID.required": "Anbieter-ID ist erforderlich",
|
||||||
|
"provider.custom.error.providerID.format": "Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
|
||||||
|
"provider.custom.error.providerID.exists": "Diese Anbieter-ID existiert bereits",
|
||||||
|
"provider.custom.error.name.required": "Anzeigename ist erforderlich",
|
||||||
|
"provider.custom.error.baseURL.required": "Basis-URL ist erforderlich",
|
||||||
|
"provider.custom.error.baseURL.format": "Muss mit http:// oder https:// beginnen",
|
||||||
|
"provider.custom.error.required": "Erforderlich",
|
||||||
|
"provider.custom.error.duplicate": "Duplikat",
|
||||||
|
|
||||||
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
|
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
|
||||||
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
|
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
|
||||||
"model.tag.free": "Kostenlos",
|
"model.tag.free": "Kostenlos",
|
||||||
@@ -380,6 +418,7 @@ export const dict = {
|
|||||||
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
|
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
|
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
|
||||||
|
"directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
|
||||||
|
|
||||||
"error.chain.unknown": "Unbekannter Fehler",
|
"error.chain.unknown": "Unbekannter Fehler",
|
||||||
"error.chain.causedBy": "Verursacht durch:",
|
"error.chain.causedBy": "Verursacht durch:",
|
||||||
|
|||||||
@@ -149,6 +149,43 @@ export const dict = {
|
|||||||
"provider.connect.toast.connected.title": "{{provider}} connected",
|
"provider.connect.toast.connected.title": "{{provider}} connected",
|
||||||
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
|
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
|
||||||
|
|
||||||
|
"provider.custom.title": "Custom provider",
|
||||||
|
"provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ",
|
||||||
|
"provider.custom.description.link": "provider config docs",
|
||||||
|
"provider.custom.description.suffix": ".",
|
||||||
|
"provider.custom.field.providerID.label": "Provider ID",
|
||||||
|
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||||
|
"provider.custom.field.providerID.description": "Lowercase letters, numbers, hyphens, or underscores",
|
||||||
|
"provider.custom.field.name.label": "Display name",
|
||||||
|
"provider.custom.field.name.placeholder": "My AI Provider",
|
||||||
|
"provider.custom.field.baseURL.label": "Base URL",
|
||||||
|
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||||
|
"provider.custom.field.apiKey.label": "API key",
|
||||||
|
"provider.custom.field.apiKey.placeholder": "API key",
|
||||||
|
"provider.custom.field.apiKey.description": "Optional. Leave empty if you manage auth via headers.",
|
||||||
|
"provider.custom.models.label": "Models",
|
||||||
|
"provider.custom.models.id.label": "ID",
|
||||||
|
"provider.custom.models.id.placeholder": "model-id",
|
||||||
|
"provider.custom.models.name.label": "Name",
|
||||||
|
"provider.custom.models.name.placeholder": "Display Name",
|
||||||
|
"provider.custom.models.remove": "Remove model",
|
||||||
|
"provider.custom.models.add": "Add model",
|
||||||
|
"provider.custom.headers.label": "Headers (optional)",
|
||||||
|
"provider.custom.headers.key.label": "Header",
|
||||||
|
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||||
|
"provider.custom.headers.value.label": "Value",
|
||||||
|
"provider.custom.headers.value.placeholder": "value",
|
||||||
|
"provider.custom.headers.remove": "Remove header",
|
||||||
|
"provider.custom.headers.add": "Add header",
|
||||||
|
"provider.custom.error.providerID.required": "Provider ID is required",
|
||||||
|
"provider.custom.error.providerID.format": "Use lowercase letters, numbers, hyphens, or underscores",
|
||||||
|
"provider.custom.error.providerID.exists": "That provider ID already exists",
|
||||||
|
"provider.custom.error.name.required": "Display name is required",
|
||||||
|
"provider.custom.error.baseURL.required": "Base URL is required",
|
||||||
|
"provider.custom.error.baseURL.format": "Must start with http:// or https://",
|
||||||
|
"provider.custom.error.required": "Required",
|
||||||
|
"provider.custom.error.duplicate": "Duplicate",
|
||||||
|
|
||||||
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnected",
|
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnected",
|
||||||
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
|
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
|
||||||
|
|
||||||
@@ -404,6 +441,7 @@ export const dict = {
|
|||||||
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
|
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
|
||||||
|
"directory.error.invalidUrl": "Invalid directory in URL.",
|
||||||
|
|
||||||
"error.chain.unknown": "Unknown error",
|
"error.chain.unknown": "Unknown error",
|
||||||
"error.chain.causedBy": "Caused by:",
|
"error.chain.causedBy": "Caused by:",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Abrir ajustes",
|
"command.settings.open": "Abrir ajustes",
|
||||||
"command.session.previous": "Sesión anterior",
|
"command.session.previous": "Sesión anterior",
|
||||||
"command.session.next": "Siguiente sesión",
|
"command.session.next": "Siguiente sesión",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Sesión no leída anterior",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "Siguiente sesión no leída",
|
||||||
"command.session.archive": "Archivar sesión",
|
"command.session.archive": "Archivar sesión",
|
||||||
|
|
||||||
"command.palette": "Paleta de comandos",
|
"command.palette": "Paleta de comandos",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Ouvrir les paramètres",
|
"command.settings.open": "Ouvrir les paramètres",
|
||||||
"command.session.previous": "Session précédente",
|
"command.session.previous": "Session précédente",
|
||||||
"command.session.next": "Session suivante",
|
"command.session.next": "Session suivante",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Session non lue précédente",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "Session non lue suivante",
|
||||||
"command.session.archive": "Archiver la session",
|
"command.session.archive": "Archiver la session",
|
||||||
|
|
||||||
"command.palette": "Palette de commandes",
|
"command.palette": "Palette de commandes",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "設定を開く",
|
"command.settings.open": "設定を開く",
|
||||||
"command.session.previous": "前のセッション",
|
"command.session.previous": "前のセッション",
|
||||||
"command.session.next": "次のセッション",
|
"command.session.next": "次のセッション",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "前の未読セッション",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "次の未読セッション",
|
||||||
"command.session.archive": "セッションをアーカイブ",
|
"command.session.archive": "セッションをアーカイブ",
|
||||||
|
|
||||||
"command.palette": "コマンドパレット",
|
"command.palette": "コマンドパレット",
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export const dict = {
|
|||||||
"command.settings.open": "설정 열기",
|
"command.settings.open": "설정 열기",
|
||||||
"command.session.previous": "이전 세션",
|
"command.session.previous": "이전 세션",
|
||||||
"command.session.next": "다음 세션",
|
"command.session.next": "다음 세션",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "이전 읽지 않은 세션",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "다음 읽지 않은 세션",
|
||||||
"command.session.archive": "세션 보관",
|
"command.session.archive": "세션 보관",
|
||||||
|
|
||||||
"command.palette": "명령 팔레트",
|
"command.palette": "명령 팔레트",
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Åpne innstillinger",
|
"command.settings.open": "Åpne innstillinger",
|
||||||
"command.session.previous": "Forrige sesjon",
|
"command.session.previous": "Forrige sesjon",
|
||||||
"command.session.next": "Neste sesjon",
|
"command.session.next": "Neste sesjon",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Forrige uleste økt",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "Neste uleste økt",
|
||||||
"command.session.archive": "Arkiver sesjon",
|
"command.session.archive": "Arkiver sesjon",
|
||||||
|
|
||||||
"command.palette": "Kommandopalett",
|
"command.palette": "Kommandopalett",
|
||||||
|
|||||||
31
packages/app/src/i18n/parity.test.ts
Normal file
31
packages/app/src/i18n/parity.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { dict as en } from "./en"
|
||||||
|
import { dict as ar } from "./ar"
|
||||||
|
import { dict as br } from "./br"
|
||||||
|
import { dict as bs } from "./bs"
|
||||||
|
import { dict as da } from "./da"
|
||||||
|
import { dict as de } from "./de"
|
||||||
|
import { dict as es } from "./es"
|
||||||
|
import { dict as fr } from "./fr"
|
||||||
|
import { dict as ja } from "./ja"
|
||||||
|
import { dict as ko } from "./ko"
|
||||||
|
import { dict as no } from "./no"
|
||||||
|
import { dict as pl } from "./pl"
|
||||||
|
import { dict as ru } from "./ru"
|
||||||
|
import { dict as th } from "./th"
|
||||||
|
import { dict as zh } from "./zh"
|
||||||
|
import { dict as zht } from "./zht"
|
||||||
|
|
||||||
|
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, zh, zht]
|
||||||
|
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
|
||||||
|
|
||||||
|
describe("i18n parity", () => {
|
||||||
|
test("non-English locales translate targeted unseen session keys", () => {
|
||||||
|
for (const locale of locales) {
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(locale[key]).toBeDefined()
|
||||||
|
expect(locale[key]).not.toBe(en[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Otwórz ustawienia",
|
"command.settings.open": "Otwórz ustawienia",
|
||||||
"command.session.previous": "Poprzednia sesja",
|
"command.session.previous": "Poprzednia sesja",
|
||||||
"command.session.next": "Następna sesja",
|
"command.session.next": "Następna sesja",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Poprzednia nieprzeczytana sesja",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "Następna nieprzeczytana sesja",
|
||||||
"command.session.archive": "Zarchiwizuj sesję",
|
"command.session.archive": "Zarchiwizuj sesję",
|
||||||
|
|
||||||
"command.palette": "Paleta poleceń",
|
"command.palette": "Paleta poleceń",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "Открыть настройки",
|
"command.settings.open": "Открыть настройки",
|
||||||
"command.session.previous": "Предыдущая сессия",
|
"command.session.previous": "Предыдущая сессия",
|
||||||
"command.session.next": "Следующая сессия",
|
"command.session.next": "Следующая сессия",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "Предыдущая непрочитанная сессия",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "Следующая непрочитанная сессия",
|
||||||
"command.session.archive": "Архивировать сессию",
|
"command.session.archive": "Архивировать сессию",
|
||||||
|
|
||||||
"command.palette": "Палитра команд",
|
"command.palette": "Палитра команд",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export const dict = {
|
|||||||
"command.settings.open": "เปิดการตั้งค่า",
|
"command.settings.open": "เปิดการตั้งค่า",
|
||||||
"command.session.previous": "เซสชันก่อนหน้า",
|
"command.session.previous": "เซสชันก่อนหน้า",
|
||||||
"command.session.next": "เซสชันถัดไป",
|
"command.session.next": "เซสชันถัดไป",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "เซสชันที่ยังไม่ได้อ่านก่อนหน้า",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "เซสชันที่ยังไม่ได้อ่านถัดไป",
|
||||||
"command.session.archive": "จัดเก็บเซสชัน",
|
"command.session.archive": "จัดเก็บเซสชัน",
|
||||||
|
|
||||||
"command.palette": "คำสั่งค้นหา",
|
"command.palette": "คำสั่งค้นหา",
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export const dict = {
|
|||||||
"command.settings.open": "打开设置",
|
"command.settings.open": "打开设置",
|
||||||
"command.session.previous": "上一个会话",
|
"command.session.previous": "上一个会话",
|
||||||
"command.session.next": "下一个会话",
|
"command.session.next": "下一个会话",
|
||||||
"command.session.previous.unseen": "Previous unread session",
|
"command.session.previous.unseen": "上一个未读会话",
|
||||||
"command.session.next.unseen": "Next unread session",
|
"command.session.next.unseen": "下一个未读会话",
|
||||||
"command.session.archive": "归档会话",
|
"command.session.archive": "归档会话",
|
||||||
|
|
||||||
"command.palette": "命令面板",
|
"command.palette": "命令面板",
|
||||||
@@ -147,6 +147,43 @@ export const dict = {
|
|||||||
"provider.connect.toast.connected.title": "{{provider}} 已连接",
|
"provider.connect.toast.connected.title": "{{provider}} 已连接",
|
||||||
"provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。",
|
"provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。",
|
||||||
|
|
||||||
|
"provider.custom.title": "自定义提供商",
|
||||||
|
"provider.custom.description.prefix": "配置与 OpenAI 兼容的提供商。请查看",
|
||||||
|
"provider.custom.description.link": "提供商配置文档",
|
||||||
|
"provider.custom.description.suffix": "。",
|
||||||
|
"provider.custom.field.providerID.label": "提供商 ID",
|
||||||
|
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||||
|
"provider.custom.field.providerID.description": "使用小写字母、数字、连字符或下划线",
|
||||||
|
"provider.custom.field.name.label": "显示名称",
|
||||||
|
"provider.custom.field.name.placeholder": "我的 AI 提供商",
|
||||||
|
"provider.custom.field.baseURL.label": "基础 URL",
|
||||||
|
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||||
|
"provider.custom.field.apiKey.label": "API 密钥",
|
||||||
|
"provider.custom.field.apiKey.placeholder": "API 密钥",
|
||||||
|
"provider.custom.field.apiKey.description": "可选。如果你通过请求头管理认证,可留空。",
|
||||||
|
"provider.custom.models.label": "模型",
|
||||||
|
"provider.custom.models.id.label": "ID",
|
||||||
|
"provider.custom.models.id.placeholder": "model-id",
|
||||||
|
"provider.custom.models.name.label": "名称",
|
||||||
|
"provider.custom.models.name.placeholder": "显示名称",
|
||||||
|
"provider.custom.models.remove": "移除模型",
|
||||||
|
"provider.custom.models.add": "添加模型",
|
||||||
|
"provider.custom.headers.label": "请求头(可选)",
|
||||||
|
"provider.custom.headers.key.label": "请求头",
|
||||||
|
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||||
|
"provider.custom.headers.value.label": "值",
|
||||||
|
"provider.custom.headers.value.placeholder": "value",
|
||||||
|
"provider.custom.headers.remove": "移除请求头",
|
||||||
|
"provider.custom.headers.add": "添加请求头",
|
||||||
|
"provider.custom.error.providerID.required": "提供商 ID 为必填项",
|
||||||
|
"provider.custom.error.providerID.format": "请使用小写字母、数字、连字符或下划线",
|
||||||
|
"provider.custom.error.providerID.exists": "该提供商 ID 已存在",
|
||||||
|
"provider.custom.error.name.required": "显示名称为必填项",
|
||||||
|
"provider.custom.error.baseURL.required": "基础 URL 为必填项",
|
||||||
|
"provider.custom.error.baseURL.format": "必须以 http:// 或 https:// 开头",
|
||||||
|
"provider.custom.error.required": "必填",
|
||||||
|
"provider.custom.error.duplicate": "重复",
|
||||||
|
|
||||||
"provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接",
|
"provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接",
|
||||||
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
|
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
|
||||||
"model.tag.free": "免费",
|
"model.tag.free": "免费",
|
||||||
@@ -380,6 +417,7 @@ export const dict = {
|
|||||||
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
|
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
|
||||||
|
|
||||||
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
|
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
|
||||||
|
"directory.error.invalidUrl": "URL 中的目录无效。",
|
||||||
|
|
||||||
"error.chain.unknown": "未知错误",
|
"error.chain.unknown": "未知错误",
|
||||||
"error.chain.causedBy": "原因:",
|
"error.chain.causedBy": "原因:",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user