Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597ae57bb1 | ||
|
|
a552652fcc | ||
|
|
511c7abaca | ||
|
|
f834915d3f | ||
|
|
65c21f8fe4 | ||
|
|
6b972329fd | ||
|
|
6ecd011e51 | ||
|
|
8e5db3083c | ||
|
|
d005e70f50 | ||
|
|
46122d9a0a | ||
|
|
81ac41e089 | ||
|
|
c0e71c4261 | ||
|
|
507f13a30c | ||
|
|
90f39bf672 | ||
|
|
95bf01a757 | ||
|
|
b6bbb95704 | ||
|
|
d713026a6a | ||
|
|
73c4d3644c | ||
|
|
571f5b31c9 | ||
|
|
644f0d4e92 | ||
|
|
d9f18e4006 | ||
|
|
2f4374c829 | ||
|
|
3542f3e406 | ||
|
|
f1caf84064 | ||
|
|
85126556b8 | ||
|
|
252b2c450d | ||
|
|
0c32afbc35 | ||
|
|
aef0e58ad7 | ||
|
|
1a6461e8bc | ||
|
|
e834a2e6c9 | ||
|
|
9d3f32065b | ||
|
|
e7ff7143b6 | ||
|
|
2c36cbb87c | ||
|
|
77fa8ddc88 | ||
|
|
4a56491e42 | ||
|
|
f51bd28ed8 | ||
|
|
6cd2a68851 | ||
|
|
9259d2bf52 | ||
|
|
e94ae550ea | ||
|
|
e9ef94dc4d | ||
|
|
5495fdde9d | ||
|
|
7d0777a7ff | ||
|
|
f48e2e56c9 | ||
|
|
fe66ca163c | ||
|
|
20619a6a26 | ||
|
|
1bbe84ed8d | ||
|
|
21edc00f11 | ||
|
|
0b91e9087d | ||
|
|
7cb84f13d3 | ||
|
|
1aade4b308 | ||
|
|
2e005de670 | ||
|
|
e80a99e7bd | ||
|
|
9a0132e750 | ||
|
|
7fb22ab681 | ||
|
|
e4d3b961cd | ||
|
|
9cf3e651ca | ||
|
|
e0b60d9f34 | ||
|
|
3f57f4913d | ||
|
|
b9e9c8c763 | ||
|
|
0d53f34c43 | ||
|
|
0a0b54aa4b | ||
|
|
4a4fc48ee8 | ||
|
|
5e823fd208 | ||
|
|
ad5d495b2c | ||
|
|
abb87eac8f | ||
|
|
a530c1b5b6 | ||
|
|
601744eacd | ||
|
|
97a428cf69 | ||
|
|
698cf6dfc1 | ||
|
|
9493316502 | ||
|
|
08fa7f7188 | ||
|
|
11d486707c | ||
|
|
71e0ba271f | ||
|
|
d58661d4fb | ||
|
|
09f4ef8996 | ||
|
|
1794319a4c | ||
|
|
8f53794017 | ||
|
|
48d6d72e25 | ||
|
|
5bef8e316a | ||
|
|
08f11f4da6 | ||
|
|
9e0747c9b4 | ||
|
|
66ec378680 | ||
|
|
015eda36ce | ||
|
|
e666ddb630 | ||
|
|
da7f45bd4c | ||
|
|
273e7b8379 | ||
|
|
36041c0000 | ||
|
|
b5e5d4c92f | ||
|
|
b109ab7830 | ||
|
|
b28891473f | ||
|
|
5d0122b5a9 | ||
|
|
1ab4bbc275 | ||
|
|
908350c2ea | ||
|
|
3fef490187 | ||
|
|
3ac05201c6 | ||
|
|
2d3c7a0f24 | ||
|
|
cd664a189b | ||
|
|
849f488744 | ||
|
|
5ea1042ffb | ||
|
|
71d280d570 | ||
|
|
5cfb5fdd06 | ||
|
|
30969dc33e | ||
|
|
5f282c268d | ||
|
|
d3d6e7e275 | ||
|
|
a70c66eb3f | ||
|
|
60de810d9a | ||
|
|
95309c2149 | ||
|
|
e9e8d97b0d | ||
|
|
553316af2a | ||
|
|
f27ee4674a | ||
|
|
ad91f9143a | ||
|
|
b43a35b737 | ||
|
|
03803621db | ||
|
|
81326377f2 | ||
|
|
7ed6f690e9 | ||
|
|
1f3bf56640 | ||
|
|
bbc7bdb3fd | ||
|
|
a5c01a81ff | ||
|
|
4f4694d9e3 | ||
|
|
4c82ad6280 | ||
|
|
b35265823c | ||
|
|
8ce19f8cca | ||
|
|
b5ffa997da | ||
|
|
75166a1961 | ||
|
|
6cc739701b | ||
|
|
2125dc11c7 | ||
|
|
aa1d0f6167 | ||
|
|
fdd484d2c1 | ||
|
|
cd4075faf6 | ||
|
|
33311e9950 | ||
|
|
a92b7923c2 | ||
|
|
cf5cf7b23e | ||
|
|
a9a7595234 | ||
|
|
9ed3b0742f | ||
|
|
ae9199e101 | ||
|
|
f996e05b42 | ||
|
|
8dedb3f4ae | ||
|
|
45ec3105b1 | ||
|
|
5a56e8172f | ||
|
|
aa92ef37fd | ||
|
|
d5c59a66c1 | ||
|
|
301895c7f7 | ||
|
|
03ba49af4e | ||
|
|
008ad54cb5 | ||
|
|
571751c313 | ||
|
|
82717f6e8b | ||
|
|
c946f5f7eb | ||
|
|
2af326606c | ||
|
|
7c0067d59d | ||
|
|
92eb982863 | ||
|
|
33c5c100ff | ||
|
|
0fabdccf11 | ||
|
|
41ea4694db | ||
|
|
e84d92da28 | ||
|
|
58ba486375 | ||
|
|
121016af81 | ||
|
|
f40bdd1ac3 | ||
|
|
efc9303b27 | ||
|
|
b938e1ea99 | ||
|
|
9cfdbbb1af | ||
|
|
29ea9fcf25 | ||
|
|
870c38a6aa | ||
|
|
b937fe9450 | ||
|
|
427ef95f7d | ||
|
|
d8fe12aafc | ||
|
|
a7d7f5bb07 | ||
|
|
8cdb82038a | ||
|
|
a9e757b5bb | ||
|
|
4d2696e027 | ||
|
|
0c8de47f7d | ||
|
|
7ad165fbdc | ||
|
|
e5b33f8a5e | ||
|
|
90a7e3d64e | ||
|
|
33dc70b754 | ||
|
|
832bbba616 | ||
|
|
40d5d14304 | ||
|
|
36df0d823a | ||
|
|
9424f829eb | ||
|
|
7b561be159 | ||
|
|
c60464de07 | ||
|
|
4e41ca74b9 | ||
|
|
8c05eb22b1 | ||
|
|
f607353be6 | ||
|
|
af3c97f192 | ||
|
|
26e14ce628 | ||
|
|
57ad1814e3 | ||
|
|
4f60ea6108 | ||
|
|
775d288027 | ||
|
|
bc4968abbb | ||
|
|
acb92fcd34 | ||
|
|
c9bbea4266 | ||
|
|
65e1186efe | ||
|
|
8faa2ffcf8 | ||
|
|
bdfd8f8b0f | ||
|
|
2f35c40bb5 | ||
|
|
6650aa6f35 | ||
|
|
8798a77a72 | ||
|
|
6eb2bdd665 | ||
|
|
f97197bdb0 | ||
|
|
5a0b3ee673 | ||
|
|
3bb10773e6 | ||
|
|
558590712d | ||
|
|
d76e1448f0 | ||
|
|
026b3cc88d | ||
|
|
28e5557bf4 | ||
|
|
9a8da20a97 | ||
|
|
63f5669eb5 | ||
|
|
427cc3e153 | ||
|
|
aedd760141 | ||
|
|
6da9fb8fb9 | ||
|
|
b73e0240a5 | ||
|
|
5f2a7c630b | ||
|
|
7988f52231 |
@@ -3,20 +3,17 @@ description: "Setup Bun with caching and install dependencies"
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
|
- name: Mount Bun Cache
|
||||||
|
uses: useblacksmith/stickydisk@v1
|
||||||
|
with:
|
||||||
|
key: ${{ github.repository }}-bun-cache
|
||||||
|
path: ~/.bun
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version-file: package.json
|
bun-version-file: package.json
|
||||||
|
|
||||||
- name: Cache ~/.bun
|
|
||||||
id: cache-bun
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('package.json') }}-${{ hashFiles('bun.lockb', 'bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-${{ hashFiles('package.json') }}-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: "Setup Git Committer"
|
||||||
|
description: "Create app token and configure git user"
|
||||||
|
inputs:
|
||||||
|
opencode-app-id:
|
||||||
|
description: "OpenCode GitHub App ID"
|
||||||
|
required: true
|
||||||
|
opencode-app-secret:
|
||||||
|
description: "OpenCode GitHub App private key"
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
token:
|
||||||
|
description: "GitHub App token"
|
||||||
|
value: ${{ steps.apptoken.outputs.token }}
|
||||||
|
app-slug:
|
||||||
|
description: "GitHub App slug"
|
||||||
|
value: ${{ steps.apptoken.outputs.app-slug }}
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Create app token
|
||||||
|
id: apptoken
|
||||||
|
uses: actions/create-github-app-token@v2
|
||||||
|
with:
|
||||||
|
app-id: ${{ inputs.opencode-app-id }}
|
||||||
|
private-key: ${{ inputs.opencode-app-secret }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
|
||||||
|
- name: Configure git user
|
||||||
|
run: |
|
||||||
|
slug="${{ steps.apptoken.outputs.app-slug }}"
|
||||||
|
git config --global user.name "${slug}[bot]"
|
||||||
|
git config --global user.email "${slug}[bot]@users.noreply.github.com"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Clear checkout auth
|
||||||
|
run: |
|
||||||
|
git config --local --unset-all http.https://github.com/.extraheader || true
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Configure git remote
|
||||||
|
run: |
|
||||||
|
git remote set-url origin https://x-access-token:${{ steps.apptoken.outputs.token }}@github.com/${{ github.repository }}
|
||||||
|
shell: bash
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
### What does this PR do?
|
### What does this PR do?
|
||||||
|
|
||||||
|
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the pr.
|
||||||
|
|
||||||
|
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
|
||||||
|
|
||||||
### How did you verify your code works?
|
### How did you verify your code works?
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: beta
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 * * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Setup Git Committer
|
||||||
|
id: setup-git-committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
|
- name: Sync beta branch
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
|
||||||
|
run: bun script/beta.ts
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Close stale PRs
|
name: close-stale-prs
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: containers
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- packages/containers/**
|
||||||
|
- .github/workflows/containers.yml
|
||||||
|
- package.json
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io/${{ github.repository_owner }}
|
||||||
|
TAG: "24.04"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push containers
|
||||||
|
run: bun ./packages/containers/script/build.ts --push
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
TAG: ${{ env.TAG }}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Daily Issues Recap
|
name: daily-issues-recap
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Daily PR Recap
|
name: daily-pr-recap
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Docs Update
|
name: docs-update
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Duplicate Issue Detection
|
name: duplicate-issues
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate:
|
generate:
|
||||||
@@ -15,14 +14,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
|
||||||
ref: ${{ github.event.pull_request.head.ref || github.ref_name }}
|
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Setup git committer
|
||||||
|
id: committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
- name: Generate
|
- name: Generate
|
||||||
run: ./script/generate.ts
|
run: ./script/generate.ts
|
||||||
|
|
||||||
@@ -32,10 +34,8 @@ jobs:
|
|||||||
echo "No changes to commit"
|
echo "No changes to commit"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
git config --local user.email "action@github.com"
|
|
||||||
git config --local user.name "GitHub Action"
|
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: generate"
|
git commit -m "chore: generate" --allow-empty
|
||||||
git push origin HEAD:${{ github.ref_name }} --no-verify
|
git push origin HEAD:${{ github.ref_name }} --no-verify
|
||||||
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
|
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
|
||||||
# echo ""
|
# echo ""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: nix desktop
|
name: nix-desktop
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -21,7 +21,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-desktop:
|
nix-desktop:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Update Nix Hashes
|
name: nix-hashes
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -11,17 +11,17 @@ on:
|
|||||||
- "package.json"
|
- "package.json"
|
||||||
- "packages/*/package.json"
|
- "packages/*/package.json"
|
||||||
- "flake.lock"
|
- "flake.lock"
|
||||||
- ".github/workflows/update-nix-hashes.yml"
|
- ".github/workflows/nix-hashes.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "bun.lock"
|
- "bun.lock"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- "packages/*/package.json"
|
- "packages/*/package.json"
|
||||||
- "flake.lock"
|
- "flake.lock"
|
||||||
- ".github/workflows/update-nix-hashes.yml"
|
- ".github/workflows/nix-hashes.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-node-modules-hashes:
|
nix-hashes:
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
env:
|
env:
|
||||||
@@ -36,14 +36,16 @@ jobs:
|
|||||||
ref: ${{ github.head_ref || github.ref_name }}
|
ref: ${{ github.head_ref || github.ref_name }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
||||||
|
|
||||||
|
- name: Setup git committer
|
||||||
|
id: committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
- name: Setup Nix
|
- name: Setup Nix
|
||||||
uses: nixbuild/nix-quick-install-action@v34
|
uses: nixbuild/nix-quick-install-action@v34
|
||||||
|
|
||||||
- name: Configure git
|
|
||||||
run: |
|
|
||||||
git config --global user.email "action@github.com"
|
|
||||||
git config --global user.name "Github Action"
|
|
||||||
|
|
||||||
- name: Pull latest changes
|
- name: Pull latest changes
|
||||||
env:
|
env:
|
||||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: discord
|
name: notify-discord
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Duplicate PR Check
|
name: pr-management
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
@@ -63,3 +63,26 @@ jobs:
|
|||||||
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
||||||
|
|
||||||
$COMMENT"
|
$COMMENT"
|
||||||
|
|
||||||
|
add-contributor-label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Add Contributor Label
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const isPR = !!context.payload.pull_request;
|
||||||
|
const issueNumber = isPR ? context.payload.pull_request.number : context.payload.issue.number;
|
||||||
|
const authorAssociation = isPR ? context.payload.pull_request.author_association : context.payload.issue.author_association;
|
||||||
|
|
||||||
|
if (authorAssociation === 'CONTRIBUTOR') {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
labels: ['contributor']
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: PR Standards
|
name: pr-standards
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
|
|||||||
+106
-72
@@ -4,7 +4,9 @@ run-name: "${{ format('release {0}', inputs.bump) }}"
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
- ci
|
||||||
- dev
|
- dev
|
||||||
|
- beta
|
||||||
- snapshot-*
|
- snapshot-*
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -29,7 +31,7 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
version:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
steps:
|
steps:
|
||||||
@@ -37,48 +39,44 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- run: git fetch --force --tags
|
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install OpenCode
|
- name: Install OpenCode
|
||||||
if: inputs.bump || inputs.version
|
if: inputs.bump || inputs.version
|
||||||
run: bun i -g opencode-ai@1.0.169
|
run: bun i -g opencode-ai
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- id: version
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
|
|
||||||
- name: Setup Git Identity
|
|
||||||
run: |
|
run: |
|
||||||
git config --global user.email "opencode@sst.dev"
|
./script/version.ts
|
||||||
git config --global user.name "opencode"
|
|
||||||
git remote set-url origin https://x-access-token:${{ secrets.SST_GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
|
||||||
|
|
||||||
- name: Publish
|
|
||||||
id: publish
|
|
||||||
run: ./script/publish-start.ts
|
|
||||||
env:
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
OPENCODE_BUMP: ${{ inputs.bump }}
|
||||||
OPENCODE_VERSION: ${{ inputs.version }}
|
OPENCODE_VERSION: ${{ inputs.version }}
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
outputs:
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
NPM_CONFIG_PROVENANCE: false
|
release: ${{ steps.version.outputs.release }}
|
||||||
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
|
|
||||||
|
build-cli:
|
||||||
|
needs: version
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
./packages/opencode/script/build.ts
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -86,12 +84,12 @@ jobs:
|
|||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
release: ${{ steps.publish.outputs.release }}
|
version: ${{ needs.version.outputs.version }}
|
||||||
tag: ${{ steps.publish.outputs.tag }}
|
|
||||||
version: ${{ steps.publish.outputs.version }}
|
|
||||||
|
|
||||||
publish-tauri:
|
build-tauri:
|
||||||
needs: publish
|
needs:
|
||||||
|
- build-cli
|
||||||
|
- version
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -105,14 +103,13 @@ jobs:
|
|||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404
|
- host: blacksmith-4vcpu-ubuntu-2404
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404-arm
|
- host: blacksmith-8vcpu-ubuntu-2404-arm
|
||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-tags: true
|
||||||
ref: ${{ needs.publish.outputs.tag }}
|
|
||||||
|
|
||||||
- uses: apple-actions/import-codesign-certs@v2
|
- uses: apple-actions/import-codesign-certs@v2
|
||||||
if: ${{ runner.os == 'macOS' }}
|
if: ${{ runner.os == 'macOS' }}
|
||||||
@@ -134,10 +131,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
||||||
|
|
||||||
- run: git fetch --force --tags
|
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Cache apt packages
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ matrix.settings.target }}-apt-
|
||||||
|
|
||||||
- name: install dependencies (ubuntu only)
|
- name: install dependencies (ubuntu only)
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
run: |
|
run: |
|
||||||
@@ -159,11 +163,8 @@ jobs:
|
|||||||
cd packages/desktop
|
cd packages/desktop
|
||||||
bun ./scripts/prepare.ts
|
bun ./scripts/prepare.ts
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
|
||||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
@@ -177,22 +178,18 @@ jobs:
|
|||||||
cargo tauri --version
|
cargo tauri --version
|
||||||
|
|
||||||
- name: Build and upload artifacts
|
- name: Build and upload artifacts
|
||||||
uses: Wandalen/wretry.action@v3
|
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
with:
|
with:
|
||||||
attempt_limit: 3
|
projectPath: packages/desktop
|
||||||
attempt_delay: 10000
|
uploadWorkflowArtifacts: true
|
||||||
action: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||||
with: |
|
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
||||||
projectPath: packages/desktop
|
updaterJsonPreferNsis: true
|
||||||
uploadWorkflowArtifacts: true
|
releaseId: ${{ needs.version.outputs.release }}
|
||||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
tagName: ${{ needs.version.outputs.tag }}
|
||||||
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
releaseDraft: true
|
||||||
updaterJsonPreferNsis: true
|
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
||||||
releaseId: ${{ needs.publish.outputs.release }}
|
|
||||||
tagName: ${{ needs.publish.outputs.tag }}
|
|
||||||
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
|
||||||
releaseDraft: true
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
||||||
@@ -205,20 +202,55 @@ jobs:
|
|||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
|
|
||||||
publish-release:
|
publish:
|
||||||
needs:
|
needs:
|
||||||
- publish
|
- version
|
||||||
- publish-tauri
|
- build-cli
|
||||||
if: needs.publish.outputs.tag
|
- build-tauri
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ needs.publish.outputs.tag }}
|
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
|
- name: Setup git committer
|
||||||
|
id: committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli
|
||||||
|
path: packages/opencode/dist
|
||||||
|
|
||||||
|
- name: Cache apt packages (AUR)
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-apt-aur-
|
||||||
|
|
||||||
- name: Setup SSH for AUR
|
- name: Setup SSH for AUR
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
@@ -230,8 +262,10 @@ jobs:
|
|||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
||||||
|
|
||||||
- run: ./script/publish-complete.ts
|
- run: ./script/publish.ts
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
NPM_CONFIG_PROVENANCE: false
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Guidelines Check
|
name: review
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: "Auto-close stale issues"
|
name: stale-issues
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ jobs:
|
|||||||
command: |
|
command: |
|
||||||
git config --global user.email "bot@opencode.ai"
|
git config --global user.email "bot@opencode.ai"
|
||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
bun turbo typecheck
|
|
||||||
bun turbo test
|
bun turbo test
|
||||||
- name: windows
|
- name: windows
|
||||||
host: windows-latest
|
host: windows-latest
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Issue Triage
|
name: triage
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||||
|
|
||||||
## Style Guide
|
## Style Guide
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a>
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai">
|
||||||
|
<picture>
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
|
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">เอเจนต์การเขียนโค้ดด้วย AI แบบโอเพนซอร์ส</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="สถานะการสร้าง" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">English</a> |
|
||||||
|
<a href="README.zh.md">简体中文</a> |
|
||||||
|
<a href="README.zht.md">繁體中文</a> |
|
||||||
|
<a href="README.ko.md">한국어</a> |
|
||||||
|
<a href="README.de.md">Deutsch</a> |
|
||||||
|
<a href="README.es.md">Español</a> |
|
||||||
|
<a href="README.fr.md">Français</a> |
|
||||||
|
<a href="README.it.md">Italiano</a> |
|
||||||
|
<a href="README.da.md">Dansk</a> |
|
||||||
|
<a href="README.ja.md">日本語</a> |
|
||||||
|
<a href="README.pl.md">Polski</a> |
|
||||||
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.ar.md">العربية</a> |
|
||||||
|
<a href="README.no.md">Norsk</a> |
|
||||||
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### การติดตั้ง
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# YOLO
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# ตัวจัดการแพ็กเกจ
|
||||||
|
npm i -g opencode-ai@latest # หรือ bun/pnpm/yarn
|
||||||
|
scoop install opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
|
||||||
|
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
|
||||||
|
paru -S opencode-bin # Arch Linux
|
||||||
|
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
|
||||||
|
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> ลบเวอร์ชันที่เก่ากว่า 0.1.x ก่อนติดตั้ง
|
||||||
|
|
||||||
|
### แอปพลิเคชันเดสก์ท็อป (เบต้า)
|
||||||
|
|
||||||
|
OpenCode มีให้ใช้งานเป็นแอปพลิเคชันเดสก์ท็อป ดาวน์โหลดโดยตรงจาก [หน้ารุ่น](https://github.com/anomalyco/opencode/releases) หรือ [opencode.ai/download](https://opencode.ai/download)
|
||||||
|
|
||||||
|
| แพลตฟอร์ม | ดาวน์โหลด |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||||
|
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||||
|
| Linux | `.deb`, `.rpm`, หรือ AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
# Windows (Scoop)
|
||||||
|
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ไดเรกทอรีการติดตั้ง
|
||||||
|
|
||||||
|
สคริปต์การติดตั้งจะใช้ลำดับความสำคัญตามเส้นทางการติดตั้ง:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - ไดเรกทอรีการติดตั้งที่กำหนดเอง
|
||||||
|
2. `$XDG_BIN_DIR` - เส้นทางที่สอดคล้องกับ XDG Base Directory Specification
|
||||||
|
3. `$HOME/bin` - ไดเรกทอรีไบนารีผู้ใช้มาตรฐาน (หากมีอยู่หรือสามารถสร้างได้)
|
||||||
|
4. `$HOME/.opencode/bin` - ค่าสำรองเริ่มต้น
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ตัวอย่าง
|
||||||
|
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### เอเจนต์
|
||||||
|
|
||||||
|
OpenCode รวมเอเจนต์ในตัวสองตัวที่คุณสามารถสลับได้ด้วยปุ่ม `Tab`
|
||||||
|
|
||||||
|
- **build** - เอเจนต์เริ่มต้น มีสิทธิ์เข้าถึงแบบเต็มสำหรับงานพัฒนา
|
||||||
|
- **plan** - เอเจนต์อ่านอย่างเดียวสำหรับการวิเคราะห์และการสำรวจโค้ด
|
||||||
|
- ปฏิเสธการแก้ไขไฟล์โดยค่าเริ่มต้น
|
||||||
|
- ขอสิทธิ์ก่อนเรียกใช้คำสั่ง bash
|
||||||
|
- เหมาะสำหรับสำรวจโค้ดเบสที่ไม่คุ้นเคยหรือวางแผนการเปลี่ยนแปลง
|
||||||
|
|
||||||
|
นอกจากนี้ยังมีเอเจนต์ย่อย **general** สำหรับการค้นหาที่ซับซ้อนและงานหลายขั้นตอน
|
||||||
|
ใช้ภายในและสามารถเรียกใช้ได้โดยใช้ `@general` ในข้อความ
|
||||||
|
|
||||||
|
เรียนรู้เพิ่มเติมเกี่ยวกับ [เอเจนต์](https://opencode.ai/docs/agents)
|
||||||
|
|
||||||
|
### เอกสารประกอบ
|
||||||
|
|
||||||
|
สำหรับข้อมูลเพิ่มเติมเกี่ยวกับวิธีกำหนดค่า OpenCode [**ไปที่เอกสารของเรา**](https://opencode.ai/docs)
|
||||||
|
|
||||||
|
### การมีส่วนร่วม
|
||||||
|
|
||||||
|
หากคุณสนใจที่จะมีส่วนร่วมใน OpenCode โปรดอ่าน [เอกสารการมีส่วนร่วม](./CONTRIBUTING.md) ก่อนส่ง Pull Request
|
||||||
|
|
||||||
|
### การสร้างบน OpenCode
|
||||||
|
|
||||||
|
หากคุณทำงานในโปรเจกต์ที่เกี่ยวข้องกับ OpenCode และใช้ "opencode" เป็นส่วนหนึ่งของชื่อ เช่น "opencode-dashboard" หรือ "opencode-mobile" โปรดเพิ่มหมายเหตุใน README ของคุณเพื่อชี้แจงว่าไม่ได้สร้างโดยทีม OpenCode และไม่ได้เกี่ยวข้องกับเราในทางใด
|
||||||
|
|
||||||
|
### คำถามที่พบบ่อย
|
||||||
|
|
||||||
|
#### ต่างจาก Claude Code อย่างไร?
|
||||||
|
|
||||||
|
คล้ายกับ Claude Code มากในแง่ความสามารถ นี่คือความแตกต่างหลัก:
|
||||||
|
|
||||||
|
- โอเพนซอร์ส 100%
|
||||||
|
- ไม่ผูกมัดกับผู้ให้บริการใดๆ แม้ว่าเราจะแนะนำโมเดลที่เราจัดหาให้ผ่าน [OpenCode Zen](https://opencode.ai/zen) OpenCode สามารถใช้กับ Claude, OpenAI, Google หรือแม้กระทั่งโมเดลในเครื่องได้ เมื่อโมเดลพัฒนาช่องว่างระหว่างพวกมันจะปิดลงและราคาจะลดลง ดังนั้นการไม่ผูกมัดกับผู้ให้บริการจึงสำคัญ
|
||||||
|
- รองรับ LSP ใช้งานได้ทันทีหลังการติดตั้งโดยไม่ต้องปรับแต่งหรือเปลี่ยนแปลงฟังก์ชันการทำงานใด ๆ
|
||||||
|
- เน้นที่ TUI OpenCode สร้างโดยผู้ใช้ neovim และผู้สร้าง [terminal.shop](https://terminal.shop) เราจะผลักดันขีดจำกัดของสิ่งที่เป็นไปได้ในเทอร์มินัล
|
||||||
|
- สถาปัตยกรรมไคลเอนต์/เซิร์ฟเวอร์ ตัวอย่างเช่น อาจอนุญาตให้ OpenCode ทำงานบนคอมพิวเตอร์ของคุณ ในขณะที่คุณสามารถขับเคลื่อนจากระยะไกลผ่านแอปมือถือ หมายความว่า TUI frontend เป็นหนึ่งในไคลเอนต์ที่เป็นไปได้เท่านั้น
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**ร่วมชุมชนของเรา** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
@@ -1,215 +1,217 @@
|
|||||||
# Download Stats
|
# Download Stats
|
||||||
|
|
||||||
| Date | GitHub Downloads | npm Downloads | Total |
|
| Date | GitHub Downloads | npm Downloads | Total |
|
||||||
| ---------- | -------------------- | -------------------- | -------------------- |
|
| ---------- | -------------------- | -------------------- | --------------------- |
|
||||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||||
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
||||||
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
||||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||||
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
||||||
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
||||||
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
||||||
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
||||||
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
||||||
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
||||||
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
||||||
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
||||||
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
||||||
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
||||||
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
||||||
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
||||||
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
||||||
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
||||||
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
||||||
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
||||||
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
||||||
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
|
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
|
||||||
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
|
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
|
||||||
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
|
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
|
||||||
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
|
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
|
||||||
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
|
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
|
||||||
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
|
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
|
||||||
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
|
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
|
||||||
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
|
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
|
||||||
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
|
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
|
||||||
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
|
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
|
||||||
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
|
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
|
||||||
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
|
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
|
||||||
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
|
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
|
||||||
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
|
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
|
||||||
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
|
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
|
||||||
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
|
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
|
||||||
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
|
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
|
||||||
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
|
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
|
||||||
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
|
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
|
||||||
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
|
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
|
||||||
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
|
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
|
||||||
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
|
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
|
||||||
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
|
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
|
||||||
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
|
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
|
||||||
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
|
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
|
||||||
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
|
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
|
||||||
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
|
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
|
||||||
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
|
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
|
||||||
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
|
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
|
||||||
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
|
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
|
||||||
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
|
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
|
||||||
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
|
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
|
||||||
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
|
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
|
||||||
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
|
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
|
||||||
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
|
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
|
||||||
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
|
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
|
||||||
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
|
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
|
||||||
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
|
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
|
||||||
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
|
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
|
||||||
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
|
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
|
||||||
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
|
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
|
||||||
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
|
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
|
||||||
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
|
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
|
||||||
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
|
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
|
||||||
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
|
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
|
||||||
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
|
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
|
||||||
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
|
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
|
||||||
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
|
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
|
||||||
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
|
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
|
||||||
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
|
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
|
||||||
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
|
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
|
||||||
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
|
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
|
||||||
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
|
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
|
||||||
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
|
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
|
||||||
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
|
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
|
||||||
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
|
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
|
||||||
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
|
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
|
||||||
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
|
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
|
||||||
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
|
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
|
||||||
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
|
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
|
||||||
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
|
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
|
||||||
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
|
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
|
||||||
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
|
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
|
||||||
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
|
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
|
||||||
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
|
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
|
||||||
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
|
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
|
||||||
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
|
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
|
||||||
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
|
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
|
||||||
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
|
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
|
||||||
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
|
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
|
||||||
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
|
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
|
||||||
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
|
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
|
||||||
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
|
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
|
||||||
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
|
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
|
||||||
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
|
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
|
||||||
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
|
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
|
||||||
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
|
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
|
||||||
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
|
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
|
||||||
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
|
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
|
||||||
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
||||||
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
||||||
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
|
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
|
||||||
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
|
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
|
||||||
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
|
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
|
||||||
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
|
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
|
||||||
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
|
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
|
||||||
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
|
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
|
||||||
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
|
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
|
||||||
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
|
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
|
||||||
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
|
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
|
||||||
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
|
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
|
||||||
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
|
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
|
||||||
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
|
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
|
||||||
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
|
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
|
||||||
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
|
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
|
||||||
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
|
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
|
||||||
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
|
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
|
||||||
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
|
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
|
||||||
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
|
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
|
||||||
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
|
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
|
||||||
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
|
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
|
||||||
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
|
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
|
||||||
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
|
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
|
||||||
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
|
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
|
||||||
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
|
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
|
||||||
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
|
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
|
||||||
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
|
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
|
||||||
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
|
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
|
||||||
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
|
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
|
||||||
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
|
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
|
||||||
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
|
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
|
||||||
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
|
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
|
||||||
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
|
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
|
||||||
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
|
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
|
||||||
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
|
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
|
||||||
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
|
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
|
||||||
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
|
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
|
||||||
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
|
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
|
||||||
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
|
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
|
||||||
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
|
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
|
||||||
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
|
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
|
||||||
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
|
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
|
||||||
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
|
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
|
||||||
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
|
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
|
||||||
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
|
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
|
||||||
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
|
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
|
||||||
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
|
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
|
||||||
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
|
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
|
||||||
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
|
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
|
||||||
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
|
||||||
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
|
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
|
||||||
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
|
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
|
||||||
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
|
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
|
||||||
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
|
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
|
||||||
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
|
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
|
||||||
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
|
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
|
||||||
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
|
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
|
||||||
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
|
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
|
||||||
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
|
||||||
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
|
||||||
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
|
||||||
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
|
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
|
||||||
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
|
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
|
||||||
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
|
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
|
||||||
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
|
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
|
||||||
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
|
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
|
||||||
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
|
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
|
||||||
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
|
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
|
||||||
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
|
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
|
||||||
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
|
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
|
||||||
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
|
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
|
||||||
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
|
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
|
||||||
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
|
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
|
||||||
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
|
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
|
||||||
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
|
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
|
||||||
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
|
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
|
||||||
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
|
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
|
||||||
| 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) |
|
| 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) |
|
||||||
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
|
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
|
||||||
| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) |
|
| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) |
|
||||||
| 2026-01-19 | 4,861,108 (+233,485) | 1,863,112 (+23,941) | 6,724,220 (+257,426) |
|
| 2026-01-19 | 4,861,108 (+233,485) | 1,863,112 (+23,941) | 6,724,220 (+257,426) |
|
||||||
| 2026-01-20 | 5,128,999 (+267,891) | 1,903,665 (+40,553) | 7,032,664 (+308,444) |
|
| 2026-01-20 | 5,128,999 (+267,891) | 1,903,665 (+40,553) | 7,032,664 (+308,444) |
|
||||||
| 2026-01-21 | 5,444,842 (+315,843) | 1,962,531 (+58,866) | 7,407,373 (+374,709) |
|
| 2026-01-21 | 5,444,842 (+315,843) | 1,962,531 (+58,866) | 7,407,373 (+374,709) |
|
||||||
| 2026-01-22 | 5,766,340 (+321,498) | 2,029,487 (+66,956) | 7,795,827 (+388,454) |
|
| 2026-01-22 | 5,766,340 (+321,498) | 2,029,487 (+66,956) | 7,795,827 (+388,454) |
|
||||||
| 2026-01-23 | 6,096,236 (+329,896) | 2,096,235 (+66,748) | 8,192,471 (+396,644) |
|
| 2026-01-23 | 6,096,236 (+329,896) | 2,096,235 (+66,748) | 8,192,471 (+396,644) |
|
||||||
| 2026-01-24 | 6,371,019 (+274,783) | 2,156,870 (+60,635) | 8,527,889 (+335,418) |
|
| 2026-01-24 | 6,371,019 (+274,783) | 2,156,870 (+60,635) | 8,527,889 (+335,418) |
|
||||||
| 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983) | 8,826,935 (+299,046) |
|
| 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983) | 8,826,935 (+299,046) |
|
||||||
| 2026-01-26 | 6,941,620 (+302,538) | 2,232,115 (+44,262) | 9,173,735 (+346,800) |
|
| 2026-01-26 | 6,941,620 (+302,538) | 2,232,115 (+44,262) | 9,173,735 (+346,800) |
|
||||||
| 2026-01-27 | 7,208,093 (+266,473) | 2,280,762 (+48,647) | 9,488,855 (+315,120) |
|
| 2026-01-27 | 7,208,093 (+266,473) | 2,280,762 (+48,647) | 9,488,855 (+315,120) |
|
||||||
|
| 2026-01-28 | 7,489,370 (+281,277) | 2,314,849 (+34,087) | 9,804,219 (+315,364) |
|
||||||
|
| 2026-01-29 | 7,815,471 (+326,101) | 2,374,982 (+60,133) | 10,190,453 (+386,234) |
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ const ZEN_MODELS = [
|
|||||||
new sst.Secret("ZEN_MODELS6"),
|
new sst.Secret("ZEN_MODELS6"),
|
||||||
new sst.Secret("ZEN_MODELS7"),
|
new sst.Secret("ZEN_MODELS7"),
|
||||||
new sst.Secret("ZEN_MODELS8"),
|
new sst.Secret("ZEN_MODELS8"),
|
||||||
|
new sst.Secret("ZEN_MODELS9"),
|
||||||
|
new sst.Secret("ZEN_MODELS10"),
|
||||||
]
|
]
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-9oI1gekRbjY6L8VwlkLdPty/9rCxC20EJlESkazEX8Y=",
|
"x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=",
|
||||||
"aarch64-linux": "sha256-vn+eCVanOSNfjyqHRJn4VdqbpdMoBFm49REuIkByAio=",
|
"aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=",
|
||||||
"aarch64-darwin": "sha256-0dMP5WbqDq3qdLRrKfmCjXz2kUDjTttGTqD3v6PDbkg=",
|
"aarch64-darwin": "sha256-pWfXomWTDvG8WpWmUCwNXdbSHw6hPlqoT0Q/XuNceMc=",
|
||||||
"x86_64-darwin": "sha256-9dEWluRXY7RTPdSEhhPsDJeGo+qa3V8dqh6n6WsLeGw="
|
"x86_64-darwin": "sha256-Dmg4+cUq2r6vZB2ta9tLpNAWqcl11ZCu4ZpieegRFrY="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"ai": "5.0.119",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
import { modKey, serverUrl } from "./utils"
|
||||||
|
import {
|
||||||
|
sessionItemSelector,
|
||||||
|
dropdownMenuTriggerSelector,
|
||||||
|
dropdownMenuContentSelector,
|
||||||
|
titlebarRightSelector,
|
||||||
|
popoverBodySelector,
|
||||||
|
listItemSelector,
|
||||||
|
listItemKeySelector,
|
||||||
|
listItemKeyStartsWithSelector,
|
||||||
|
} from "./selectors"
|
||||||
|
import type { createSdk } from "./utils"
|
||||||
|
|
||||||
|
export async function defocus(page: Page) {
|
||||||
|
await page.mouse.click(5, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPalette(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+P`)
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDialog(page: Page, dialog: Locator) {
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
const closed = await dialog
|
||||||
|
.waitFor({ state: "detached", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (closed) return
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
const closedSecond = await dialog
|
||||||
|
.waitFor({ state: "detached", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (closedSecond) return
|
||||||
|
|
||||||
|
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isSidebarClosed(page: Page) {
|
||||||
|
const main = page.locator("main")
|
||||||
|
const classes = (await main.getAttribute("class")) ?? ""
|
||||||
|
return classes.includes("xl:border-l")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleSidebar(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+B`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSidebar(page: Page) {
|
||||||
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSidebar(page: Page) {
|
||||||
|
if (await isSidebarClosed(page)) return
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSettings(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||||
|
|
||||||
|
const opened = await dialog
|
||||||
|
.waitFor({ state: "visible", timeout: 3000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (opened) return dialog
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
|
await page.addInitScript(
|
||||||
|
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||||
|
const key = "opencode.global.dat:server"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const parsed = (() => {
|
||||||
|
if (!raw) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||||
|
const list = Array.isArray(store.list) ? store.list : []
|
||||||
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
|
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||||
|
|
||||||
|
const add = (origin: string, directory: string) => {
|
||||||
|
const current = nextProjects[origin]
|
||||||
|
const items = Array.isArray(current) ? current : []
|
||||||
|
const existing = items.filter(
|
||||||
|
(p): p is { worktree: string; expanded?: boolean } =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === "object" &&
|
||||||
|
"worktree" in p &&
|
||||||
|
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.some((p) => p.worktree === directory)) return
|
||||||
|
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = [args.directory, ...args.extra]
|
||||||
|
for (const directory of directories) {
|
||||||
|
add("local", directory)
|
||||||
|
add(args.serverUrl, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
list,
|
||||||
|
projects: nextProjects,
|
||||||
|
lastProject,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestProject() {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||||
|
|
||||||
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupTestProject(directory: string) {
|
||||||
|
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionIDFromUrl(url: string) {
|
||||||
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
|
return match?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||||
|
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||||
|
await expect(sessionEl).toBeVisible()
|
||||||
|
await sessionEl.hover()
|
||||||
|
return sessionEl
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||||
|
const sessionEl = await hoverSessionItem(page, sessionID)
|
||||||
|
|
||||||
|
const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
|
||||||
|
await expect(menuTrigger).toBeVisible()
|
||||||
|
await menuTrigger.click()
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
|
||||||
|
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: options?.force })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||||
|
const dialog = page.getByRole("dialog").first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSharePopover(page: Page) {
|
||||||
|
const rightSection = page.locator(titlebarRightSelector)
|
||||||
|
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
||||||
|
await expect(shareButton).toBeVisible()
|
||||||
|
|
||||||
|
const popoverBody = page
|
||||||
|
.locator(popoverBodySelector)
|
||||||
|
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||||
|
.first()
|
||||||
|
|
||||||
|
const opened = await popoverBody
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
await shareButton.click()
|
||||||
|
await expect(popoverBody).toBeVisible()
|
||||||
|
}
|
||||||
|
return { rightSection, popoverBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
||||||
|
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickListItem(
|
||||||
|
container: Locator | Page,
|
||||||
|
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
|
||||||
|
): Promise<Locator> {
|
||||||
|
let item: Locator
|
||||||
|
|
||||||
|
if (typeof filter === "string" || filter instanceof RegExp) {
|
||||||
|
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
|
||||||
|
} else if (filter.keyStartsWith) {
|
||||||
|
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
|
||||||
|
} else if (filter.key) {
|
||||||
|
item = container.locator(listItemKeySelector(filter.key)).first()
|
||||||
|
} else if (filter.text) {
|
||||||
|
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid filter provided to clickListItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withSession<T>(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
title: string,
|
||||||
|
callback: (session: { id: string; title: string }) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||||
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await callback(session)
|
||||||
|
} finally {
|
||||||
|
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverName } from "./utils"
|
import { serverName } from "../utils"
|
||||||
|
|
||||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { dirPath, promptSelector } from "./utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { dirPath } from "../utils"
|
||||||
|
|
||||||
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
||||||
await page.goto(dirPath(directory))
|
await page.goto(dirPath(directory))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { openPalette } from "../actions"
|
||||||
|
|
||||||
|
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openPalette(page)
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
+11
-23
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverName, serverUrl } from "./utils"
|
import { serverName, serverUrl } from "../utils"
|
||||||
|
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
@@ -33,31 +34,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
|||||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
||||||
await expect(row).toBeVisible()
|
await expect(row).toBeVisible()
|
||||||
|
|
||||||
const menu = row.locator('[data-component="icon-button"]').last()
|
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||||
await menu.click()
|
await expect(menuTrigger).toBeVisible()
|
||||||
await page.getByRole("menuitem", { name: "Set as default" }).click()
|
await menuTrigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await clickMenuItem(menu, /set as default/i)
|
||||||
|
|
||||||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
||||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await closeDialog(page, dialog)
|
||||||
const closed = await dialog
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!closed) {
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
const closedSecond = await dialog
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!closedSecond) {
|
|
||||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensurePopoverOpen()
|
await ensurePopoverOpen()
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const title = `e2e smoke ${Date.now()}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("hello from e2e")
|
||||||
|
await expect(prompt).toContainText("hello from e2e")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { openSidebar, withSession } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const stamp = Date.now()
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
|
||||||
|
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
|
||||||
|
await gotoSession(one.id)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
|
await expect(link).toBeVisible()
|
||||||
|
await link.scrollIntoViewIfNeeded()
|
||||||
|
await link.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
const back = page.getByRole("button", { name: "Back" })
|
||||||
|
const forward = page.getByRole("button", { name: "Forward" })
|
||||||
|
|
||||||
|
await expect(back).toBeVisible()
|
||||||
|
await expect(back).toBeEnabled()
|
||||||
|
await back.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await expect(forward).toBeVisible()
|
||||||
|
await expect(forward).toBeEnabled()
|
||||||
|
await forward.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey } from "./utils"
|
import { openPalette, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
const dialog = await openPalette(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill("package.json")
|
await input.fill("package.json")
|
||||||
|
|
||||||
const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
await clickListItem(dialog, { keyStartsWith: "file:" })
|
||||||
await expect(fileItem).toBeVisible()
|
|
||||||
await fileItem.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const toggle = page.getByRole("button", { name: "Toggle file tree" })
|
const toggle = page.getByRole("button", { name: "Toggle file tree" })
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey } from "./utils"
|
import { openPalette, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -7,21 +7,12 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
|||||||
const sep = process.platform === "win32" ? "\\" : "/"
|
const sep = process.platform === "win32" ? "\\" : "/"
|
||||||
const file = ["packages", "app", "package.json"].join(sep)
|
const file = ["packages", "app", "package.json"].join(sep)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
const dialog = await openPalette(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill(file)
|
await input.fill(file)
|
||||||
|
|
||||||
const fileItem = dialog
|
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
||||||
.locator(
|
|
||||||
'[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
await expect(fileItem).toBeVisible()
|
|
||||||
await fileItem.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { test as base, expect } from "@playwright/test"
|
import { test as base, expect } from "@playwright/test"
|
||||||
import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
|
import { seedProjects } from "./actions"
|
||||||
|
import { promptSelector } from "./selectors"
|
||||||
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
@@ -29,54 +31,17 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
await use(createSdk(directory))
|
await use(createSdk(directory))
|
||||||
},
|
},
|
||||||
gotoSession: async ({ page, directory }, use) => {
|
gotoSession: async ({ page, directory }, use) => {
|
||||||
await page.addInitScript(
|
await seedProjects(page, { directory })
|
||||||
(input: { directory: string; serverUrl: string }) => {
|
await page.addInitScript(() => {
|
||||||
const key = "opencode.global.dat:server"
|
localStorage.setItem(
|
||||||
const raw = localStorage.getItem(key)
|
"opencode.global.dat:model",
|
||||||
const parsed = (() => {
|
JSON.stringify({
|
||||||
if (!raw) return undefined
|
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||||
try {
|
user: [],
|
||||||
return JSON.parse(raw) as unknown
|
variant: {},
|
||||||
} catch {
|
}),
|
||||||
return undefined
|
)
|
||||||
}
|
})
|
||||||
})()
|
|
||||||
|
|
||||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
|
||||||
const list = Array.isArray(store.list) ? store.list : []
|
|
||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
|
||||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
|
||||||
|
|
||||||
const add = (origin: string) => {
|
|
||||||
const current = nextProjects[origin]
|
|
||||||
const items = Array.isArray(current) ? current : []
|
|
||||||
const existing = items.filter(
|
|
||||||
(p): p is { worktree: string; expanded?: boolean } =>
|
|
||||||
!!p &&
|
|
||||||
typeof p === "object" &&
|
|
||||||
"worktree" in p &&
|
|
||||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existing.some((p) => p.worktree === input.directory)) return
|
|
||||||
nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
|
|
||||||
}
|
|
||||||
|
|
||||||
add("local")
|
|
||||||
add(input.serverUrl)
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
list,
|
|
||||||
projects: nextProjects,
|
|
||||||
lastProject,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, serverUrl },
|
|
||||||
)
|
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
|
|||||||
+4
-5
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "./utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { clickListItem } from "../actions"
|
||||||
|
|
||||||
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
|||||||
|
|
||||||
await input.fill(model)
|
await input.fill(model)
|
||||||
|
|
||||||
const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
|
await clickListItem(dialog, { key })
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
+5
-30
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey, promptSelector } from "./utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
|||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(picker).toHaveCount(0)
|
await expect(picker).toHaveCount(0)
|
||||||
|
|
||||||
const settings = page.getByRole("dialog")
|
const settings = await openSettings(page)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
|
||||||
const opened = await settings
|
|
||||||
.waitFor({ state: "visible", timeout: 3000 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
||||||
await expect(settings).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
await settings.getByRole("tab", { name: "Models" }).click()
|
await settings.getByRole("tab", { name: "Models" }).click()
|
||||||
const search = settings.getByPlaceholder("Search models")
|
const search = settings.getByPlaceholder("Search models")
|
||||||
@@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
|||||||
await toggle.locator('[data-slot="switch-control"]').click()
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await closeDialog(page, settings)
|
||||||
const closed = await settings
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!closed) {
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
const closedSecond = await settings
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!closedSecond) {
|
|
||||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
|
||||||
await expect(settings).toHaveCount(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
await page.keyboard.type("/model")
|
await page.keyboard.type("/model")
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures"
|
|
||||||
import { modKey } from "./utils"
|
|
||||||
|
|
||||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { openSidebar } from "../actions"
|
||||||
|
|
||||||
|
test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
const header = page.locator(".group\\/project").first()
|
||||||
|
await header.hover()
|
||||||
|
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||||
|
await expect(editItem).toBeVisible()
|
||||||
|
await editItem.click({ force: true })
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = `e2e project ${Date.now()}`
|
||||||
|
const startup = `echo e2e_${Date.now()}`
|
||||||
|
|
||||||
|
const dialog = await open()
|
||||||
|
|
||||||
|
const nameInput = dialog.getByLabel("Name")
|
||||||
|
await nameInput.fill(name)
|
||||||
|
|
||||||
|
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||||
|
await startupInput.fill(startup)
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Save" }).click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const header = page.locator(".group\\/project").first()
|
||||||
|
await expect(header).toContainText(name)
|
||||||
|
|
||||||
|
const reopened = await open()
|
||||||
|
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||||
|
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||||
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
|
await expect(reopened).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||||
|
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||||
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
|
test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
await seedProjects(page, { directory, extra: [other] })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.hover()
|
||||||
|
|
||||||
|
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
await close.click()
|
||||||
|
|
||||||
|
await expect(otherButton).toHaveCount(0)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherName = other.split("/").pop() ?? other
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
await seedProjects(page, { directory, extra: [other] })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
|
const header = page
|
||||||
|
.locator(".group\\/project")
|
||||||
|
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||||
|
.first()
|
||||||
|
await expect(header).toContainText(otherName)
|
||||||
|
|
||||||
|
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||||
|
await expect(trigger).toHaveCount(1)
|
||||||
|
await trigger.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
await expect(otherButton).toHaveCount(0)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
|
||||||
|
import { projectSwitchSelector } from "../selectors"
|
||||||
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
|
test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
|
await seedProjects(page, { directory, extra: [other] })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const currentSlug = dirSlug(directory)
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
|
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||||
|
await expect(currentButton).toBeVisible()
|
||||||
|
await currentButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "./utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
const title = `e2e smoke context ${Date.now()}`
|
const title = `e2e smoke context ${Date.now()}`
|
||||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
|
||||||
|
|
||||||
if (!created?.id) throw new Error("Session create did not return an id")
|
await withSession(sdk, title, async (session) => {
|
||||||
const sessionID = created.id
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sdk.session.promptAsync({
|
await sdk.session.promptAsync({
|
||||||
sessionID,
|
sessionID: session.id,
|
||||||
noReply: true,
|
noReply: true,
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
@@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
||||||
return messages.length
|
return messages.length
|
||||||
})
|
})
|
||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
|
|
||||||
await gotoSession(sessionID)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const contextButton = page
|
const contextButton = page
|
||||||
.locator('[data-component="button"]')
|
.locator('[data-component="button"]')
|
||||||
@@ -39,7 +36,5 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
|||||||
|
|
||||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
} finally {
|
})
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "./utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
|
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "./utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
|
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "./utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { sessionIDFromUrl, withSession } from "../actions"
|
||||||
function sessionIDFromUrl(url: string) {
|
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
|
||||||
return match?.[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
|
export const terminalSelector = '[data-component="terminal"]'
|
||||||
|
|
||||||
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
|
|
||||||
|
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||||
|
|
||||||
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectMenuTriggerSelector = (slug: string) =>
|
||||||
|
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const titlebarRightSelector = "#opencode-titlebar-right"
|
||||||
|
|
||||||
|
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||||
|
|
||||||
|
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
||||||
|
|
||||||
|
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||||
|
|
||||||
|
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||||
|
|
||||||
|
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||||
|
|
||||||
|
export const listItemSelector = '[data-slot="list-item"]'
|
||||||
|
|
||||||
|
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
||||||
|
|
||||||
|
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures"
|
|
||||||
import { promptSelector } from "./utils"
|
|
||||||
|
|
||||||
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
|
||||||
const title = `e2e smoke ${Date.now()}`
|
|
||||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
|
||||||
|
|
||||||
if (!created?.id) throw new Error("Session create did not return an id")
|
|
||||||
const sessionID = created.id
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoSession(sessionID)
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("hello from e2e")
|
|
||||||
await expect(prompt).toContainText("hello from e2e")
|
|
||||||
} finally {
|
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import {
|
||||||
|
openSidebar,
|
||||||
|
openSessionMoreMenu,
|
||||||
|
clickMenuItem,
|
||||||
|
confirmDialog,
|
||||||
|
openSharePopover,
|
||||||
|
withSession,
|
||||||
|
} from "../actions"
|
||||||
|
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||||
|
|
||||||
|
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||||
|
|
||||||
|
test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const stamp = Date.now()
|
||||||
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
|
const newTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
|
const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first()
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(newTitle)
|
||||||
|
await input.press("Enter")
|
||||||
|
|
||||||
|
await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const stamp = Date.now()
|
||||||
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||||
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
|
await clickMenuItem(menu, /archive/i)
|
||||||
|
|
||||||
|
await expect(sessionEl).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const stamp = Date.now()
|
||||||
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||||
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
|
await clickMenuItem(menu, /delete/i)
|
||||||
|
await confirmDialog(page, /delete/i)
|
||||||
|
|
||||||
|
await expect(sessionEl).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
|
const stamp = Date.now()
|
||||||
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||||
|
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeUndefined()
|
||||||
|
|
||||||
|
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
||||||
|
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const sharedPopover = await openSharePopover(page)
|
||||||
|
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||||
|
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||||
|
await unpublish.click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeUndefined()
|
||||||
|
|
||||||
|
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const unsharedPopover = await openSharePopover(page)
|
||||||
|
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures"
|
|
||||||
import { modKey } from "./utils"
|
|
||||||
|
|
||||||
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
|
||||||
|
|
||||||
const opened = await dialog
|
|
||||||
.waitFor({ state: "visible", timeout: 3000 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
|
|
||||||
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
|
|
||||||
const closed = await dialog
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (closed) return
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
const closedSecond = await dialog
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (closedSecond) return
|
|
||||||
|
|
||||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { settingsLanguageSelectSelector } from "../selectors"
|
||||||
|
import { openSettings } from "../actions"
|
||||||
|
|
||||||
|
test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
|
||||||
|
const heading = dialog.getByRole("heading", { level: 2 })
|
||||||
|
await expect(heading).toHaveText("General")
|
||||||
|
|
||||||
|
const select = dialog.locator(settingsLanguageSelectSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
|
||||||
|
|
||||||
|
await expect(heading).toHaveText("Allgemein")
|
||||||
|
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
|
||||||
|
await expect(heading).toHaveText("General")
|
||||||
|
})
|
||||||
+5
-31
@@ -1,22 +1,11 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey, promptSelector } from "./utils"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
|
test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = await openSettings(page)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
|
||||||
|
|
||||||
const opened = await dialog
|
|
||||||
.waitFor({ state: "visible", timeout: 3000 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
await dialog.getByRole("tab", { name: "Providers" }).click()
|
await dialog.getByRole("tab", { name: "Providers" }).click()
|
||||||
await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
|
await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
|
||||||
@@ -37,20 +26,5 @@ test("smoke providers settings opens provider selector", async ({ page, gotoSess
|
|||||||
const stillOpen = await dialog.isVisible().catch(() => false)
|
const stillOpen = await dialog.isVisible().catch(() => false)
|
||||||
if (!stillOpen) return
|
if (!stillOpen) return
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await closeDialog(page, dialog)
|
||||||
const closed = await dialog
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (closed) return
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
const closedSecond = await dialog
|
|
||||||
.waitFor({ state: "detached", timeout: 1500 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
if (closedSecond) return
|
|
||||||
|
|
||||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { closeDialog, openSettings } from "../actions"
|
||||||
|
|
||||||
|
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
|
||||||
|
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
})
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures"
|
|
||||||
import { modKey } from "./utils"
|
|
||||||
|
|
||||||
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const main = page.locator("main")
|
|
||||||
const closedClass = /xl:border-l/
|
|
||||||
const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
|
|
||||||
|
|
||||||
if (isClosed) {
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
|
||||||
await expect(main).not.toHaveClass(closedClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
|
||||||
await expect(main).toHaveClass(closedClass)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
|
||||||
await expect(main).not.toHaveClass(closedClass)
|
|
||||||
})
|
|
||||||
+5
-35
@@ -1,33 +1,8 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { modKey, promptSelector } from "./utils"
|
import { openSidebar, withSession } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
type Locator = {
|
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
first: () => Locator
|
|
||||||
getAttribute: (name: string) => Promise<string | null>
|
|
||||||
scrollIntoViewIfNeeded: () => Promise<void>
|
|
||||||
click: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Page = {
|
|
||||||
locator: (selector: string) => Locator
|
|
||||||
keyboard: {
|
|
||||||
press: (key: string) => Promise<void>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Fixtures = {
|
|
||||||
page: Page
|
|
||||||
slug: string
|
|
||||||
sdk: {
|
|
||||||
session: {
|
|
||||||
create: (input: { title: string }) => Promise<{ data?: { id?: string } }>
|
|
||||||
delete: (input: { sessionID: string }) => Promise<unknown>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }: Fixtures) => {
|
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
|
|
||||||
const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
|
const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
|
||||||
@@ -39,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
|||||||
try {
|
try {
|
||||||
await gotoSession(one.id)
|
await gotoSession(one.id)
|
||||||
|
|
||||||
const main = page.locator("main")
|
await openSidebar(page)
|
||||||
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
|
|
||||||
if (collapsed) {
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
|
||||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(target).toBeVisible()
|
await expect(target).toBeVisible()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { openSidebar, toggleSidebar } from "../actions"
|
||||||
|
|
||||||
|
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||||
|
})
|
||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector, terminalSelector, terminalToggleKey } from "./utils"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
|
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "./fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { terminalSelector, terminalToggleKey } from "./utils"
|
import { terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from "./fixtures"
|
||||||
|
import { modelVariantCycleSelector } from "./selectors"
|
||||||
|
|
||||||
|
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: `${modelVariantCycleSelector} { display: inline-block !important; }`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const button = page.locator(modelVariantCycleSelector)
|
||||||
|
const exists = (await button.count()) > 0
|
||||||
|
test.skip(!exists, "current model has no variants")
|
||||||
|
if (!exists) return
|
||||||
|
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
|
||||||
|
const before = (await button.innerText()).trim()
|
||||||
|
await button.click()
|
||||||
|
await expect(button).not.toHaveText(before)
|
||||||
|
|
||||||
|
const after = (await button.innerText()).trim()
|
||||||
|
await button.click()
|
||||||
|
await expect(button).not.toHaveText(after)
|
||||||
|
})
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures"
|
|
||||||
import { modKey, promptSelector } from "./utils"
|
|
||||||
|
|
||||||
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const stamp = Date.now()
|
|
||||||
const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data)
|
|
||||||
const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data)
|
|
||||||
|
|
||||||
if (!one?.id) throw new Error("Session create did not return an id")
|
|
||||||
if (!two?.id) throw new Error("Session create did not return an id")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoSession(one.id)
|
|
||||||
|
|
||||||
const main = page.locator("main")
|
|
||||||
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
|
|
||||||
if (collapsed) {
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
|
||||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
|
||||||
}
|
|
||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
|
||||||
await expect(link).toBeVisible()
|
|
||||||
await link.scrollIntoViewIfNeeded()
|
|
||||||
await link.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
const back = page.getByRole("button", { name: "Back" })
|
|
||||||
const forward = page.getByRole("button", { name: "Forward" })
|
|
||||||
|
|
||||||
await expect(back).toBeVisible()
|
|
||||||
await expect(back).toBeEnabled()
|
|
||||||
await back.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await expect(forward).toBeVisible()
|
|
||||||
await expect(forward).toBeEnabled()
|
|
||||||
await forward.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
} finally {
|
|
||||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
|
||||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts"]
|
"include": ["./**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ export const serverName = `${serverHost}:${serverPort}`
|
|||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
export const promptSelector = '[data-component="prompt-input"]'
|
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
|
||||||
|
|
||||||
export function createSdk(directory?: string) {
|
export function createSdk(directory?: string) {
|
||||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.37",
|
"version": "1.1.48",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
|||||||
|
|
||||||
const serverEnv = {
|
const serverEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENCODE_DISABLE_SHARE: "true",
|
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SerializeAddon", () => {
|
describe.skip("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()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
|
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { For } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { Link } from "@/components/link"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
|
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||||
|
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
back?: "providers" | "close"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogCustomProvider(props: Props) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const [form, setForm] = createStore({
|
||||||
|
providerID: "",
|
||||||
|
name: "",
|
||||||
|
baseURL: "",
|
||||||
|
apiKey: "",
|
||||||
|
models: [{ id: "", name: "" }],
|
||||||
|
headers: [{ key: "", value: "" }],
|
||||||
|
saving: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [errors, setErrors] = createStore({
|
||||||
|
providerID: undefined as string | undefined,
|
||||||
|
name: undefined as string | undefined,
|
||||||
|
baseURL: undefined as string | undefined,
|
||||||
|
models: [{} as { id?: string; name?: string }],
|
||||||
|
headers: [{} as { key?: string; value?: string }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (props.back === "close") {
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addModel = () => {
|
||||||
|
setForm(
|
||||||
|
"models",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.push({ id: "", name: "" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setErrors(
|
||||||
|
"models",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.push({})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeModel = (index: number) => {
|
||||||
|
if (form.models.length <= 1) return
|
||||||
|
setForm(
|
||||||
|
"models",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setErrors(
|
||||||
|
"models",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addHeader = () => {
|
||||||
|
setForm(
|
||||||
|
"headers",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.push({ key: "", value: "" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setErrors(
|
||||||
|
"headers",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.push({})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeHeader = (index: number) => {
|
||||||
|
if (form.headers.length <= 1) return
|
||||||
|
setForm(
|
||||||
|
"headers",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setErrors(
|
||||||
|
"headers",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const providerID = form.providerID.trim()
|
||||||
|
const name = form.name.trim()
|
||||||
|
const baseURL = form.baseURL.trim()
|
||||||
|
const apiKey = form.apiKey.trim()
|
||||||
|
|
||||||
|
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||||
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
|
const idError = !providerID
|
||||||
|
? "Provider ID is required"
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? "Use lowercase letters, numbers, hyphens, or underscores"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? "Display name is required" : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? "Base URL is required"
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? "Must start with http:// or https://"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
||||||
|
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: existingProvider && !disabled
|
||||||
|
? "That provider ID already exists"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const modelErrors = form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const modelIdError = !id
|
||||||
|
? "Required"
|
||||||
|
: seenModels.has(id)
|
||||||
|
? "Duplicate"
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const modelNameError = !m.name.trim() ? "Required" : undefined
|
||||||
|
return { id: modelIdError, name: modelNameError }
|
||||||
|
})
|
||||||
|
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||||
|
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headerErrors = form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? "Required"
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? "Duplicate"
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? "Required" : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
setErrors(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.providerID = idError ?? existsError
|
||||||
|
draft.name = nameError
|
||||||
|
draft.baseURL = urlError
|
||||||
|
draft.models = modelErrors
|
||||||
|
draft.headers = headerErrors
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options,
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async (e: SubmitEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (form.saving) return
|
||||||
|
|
||||||
|
const result = validate()
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
setForm("saving", true)
|
||||||
|
|
||||||
|
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||||
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
|
const auth = result.key
|
||||||
|
? globalSDK.client.auth.set({
|
||||||
|
providerID: result.providerID,
|
||||||
|
auth: {
|
||||||
|
type: "api",
|
||||||
|
key: result.key,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve()
|
||||||
|
|
||||||
|
auth
|
||||||
|
.then(() =>
|
||||||
|
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
dialog.close()
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||||
|
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setForm("saving", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={goBack}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
transition
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
|
<div class="text-16-medium text-text-strong">Custom provider</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||||
|
<p class="text-14-regular text-text-base">
|
||||||
|
Configure an OpenAI-compatible provider. See the{" "}
|
||||||
|
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||||
|
provider config docs
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
label="Provider ID"
|
||||||
|
placeholder="myprovider"
|
||||||
|
description="Lowercase letters, numbers, hyphens, or underscores"
|
||||||
|
value={form.providerID}
|
||||||
|
onChange={setForm.bind(null, "providerID")}
|
||||||
|
validationState={errors.providerID ? "invalid" : undefined}
|
||||||
|
error={errors.providerID}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Display name"
|
||||||
|
placeholder="My AI Provider"
|
||||||
|
value={form.name}
|
||||||
|
onChange={setForm.bind(null, "name")}
|
||||||
|
validationState={errors.name ? "invalid" : undefined}
|
||||||
|
error={errors.name}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Base URL"
|
||||||
|
placeholder="https://api.myprovider.com/v1"
|
||||||
|
value={form.baseURL}
|
||||||
|
onChange={setForm.bind(null, "baseURL")}
|
||||||
|
validationState={errors.baseURL ? "invalid" : undefined}
|
||||||
|
error={errors.baseURL}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="API key"
|
||||||
|
placeholder="API key"
|
||||||
|
description="Optional. Leave empty if you manage auth via headers."
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={setForm.bind(null, "apiKey")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<label class="text-12-medium text-text-weak">Models</label>
|
||||||
|
<For each={form.models}>
|
||||||
|
{(m, i) => (
|
||||||
|
<div class="flex gap-2 items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextField
|
||||||
|
label="ID"
|
||||||
|
hideLabel
|
||||||
|
placeholder="model-id"
|
||||||
|
value={m.id}
|
||||||
|
onChange={(v) => setForm("models", i(), "id", v)}
|
||||||
|
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||||
|
error={errors.models[i()]?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
hideLabel
|
||||||
|
placeholder="Display Name"
|
||||||
|
value={m.name}
|
||||||
|
onChange={(v) => setForm("models", i(), "name", v)}
|
||||||
|
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||||
|
error={errors.models[i()]?.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="trash"
|
||||||
|
variant="ghost"
|
||||||
|
class="mt-1.5"
|
||||||
|
onClick={() => removeModel(i())}
|
||||||
|
disabled={form.models.length <= 1}
|
||||||
|
aria-label="Remove model"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||||
|
Add model
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<label class="text-12-medium text-text-weak">Headers (optional)</label>
|
||||||
|
<For each={form.headers}>
|
||||||
|
{(h, i) => (
|
||||||
|
<div class="flex gap-2 items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextField
|
||||||
|
label="Header"
|
||||||
|
hideLabel
|
||||||
|
placeholder="Header-Name"
|
||||||
|
value={h.key}
|
||||||
|
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||||
|
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||||
|
error={errors.headers[i()]?.key}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextField
|
||||||
|
label="Value"
|
||||||
|
hideLabel
|
||||||
|
placeholder="value"
|
||||||
|
value={h.value}
|
||||||
|
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||||
|
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||||
|
error={errors.headers[i()]?.value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="trash"
|
||||||
|
variant="ghost"
|
||||||
|
class="mt-1.5"
|
||||||
|
onClick={() => removeHeader(i())}
|
||||||
|
disabled={form.headers.length <= 1}
|
||||||
|
aria-label="Remove header"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||||
|
Add header
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||||
|
{form.saving ? "Saving..." : language.t("common.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
"session.previous",
|
"session.previous",
|
||||||
"session.next",
|
"session.next",
|
||||||
"terminal.toggle",
|
"terminal.toggle",
|
||||||
"fileTree.toggle",
|
"review.toggle",
|
||||||
]
|
]
|
||||||
const limit = 5
|
const limit = 5
|
||||||
|
|
||||||
@@ -162,6 +162,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const value = file.tab(path)
|
const value = file.tab(path)
|
||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
|
layout.fileTree.open()
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
props.onOpenFile?.(path)
|
props.onOpenFile?.(path)
|
||||||
}
|
}
|
||||||
@@ -195,7 +196,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
: language.t("palette.search.placeholder"),
|
: language.t("palette.search.placeholder"),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
hideIcon: true,
|
hideIcon: true,
|
||||||
class: "pl-3 pr-2 !mb-0",
|
|
||||||
}}
|
}}
|
||||||
emptyMessage={language.t("palette.empty")}
|
emptyMessage={language.t("palette.empty")}
|
||||||
loadingMessage={language.t("common.loading")}
|
loadingMessage={language.t("common.loading")}
|
||||||
@@ -223,7 +223,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="w-full flex items-center justify-between gap-4 pl-1">
|
<div class="w-full flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
||||||
<Show when={item.description}>
|
<Show when={item.description}>
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.model.select.title")}>
|
<Dialog
|
||||||
<div class="flex flex-col gap-3 px-2.5 flex-1 min-h-0">
|
title={language.t("dialog.model.select.title")}
|
||||||
|
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3 px-2.5">
|
||||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||||
<List
|
<List
|
||||||
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
|
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||||
ref={(ref) => (listRef = ref)}
|
ref={(ref) => (listRef = ref)}
|
||||||
items={local.model.list}
|
items={local.model.list}
|
||||||
current={local.model.current()}
|
current={local.model.current()}
|
||||||
@@ -76,8 +79,6 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
<div />
|
|
||||||
<div />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-1.5 pb-1.5">
|
<div class="px-1.5 pb-1.5">
|
||||||
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
|
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
|||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
ref={(el) => setStore("content", el)}
|
ref={(el) => setStore("content", el)}
|
||||||
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setStore("dismiss", "escape")
|
setStore("dismiss", "escape")
|
||||||
setStore("open", false)
|
setStore("open", false)
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
|||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { IconName } from "@opencode-ai/ui/icons/provider"
|
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
|
||||||
|
const CUSTOM_ID = "_custom"
|
||||||
|
|
||||||
|
function icon(id: string): IconName {
|
||||||
|
if (iconNames.includes(id as IconName)) return id as IconName
|
||||||
|
return "synthetic"
|
||||||
|
}
|
||||||
|
|
||||||
export const DialogSelectProvider: Component = () => {
|
export const DialogSelectProvider: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -26,11 +34,13 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return providers.all()
|
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
sortBy={(a, b) => {
|
sortBy={(a, b) => {
|
||||||
|
if (a.id === CUSTOM_ID) return -1
|
||||||
|
if (b.id === CUSTOM_ID) return 1
|
||||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
||||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
@@ -43,13 +53,20 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
|
if (x.id === CUSTOM_ID) {
|
||||||
|
dialog.show(() => <DialogCustomProvider back="providers" />)
|
||||||
|
return
|
||||||
|
}
|
||||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
<ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
|
<Show when={i.id === CUSTOM_ID}>
|
||||||
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
|
</Show>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -189,12 +189,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||||
if (wantsReview) {
|
if (wantsReview) {
|
||||||
|
layout.fileTree.open()
|
||||||
layout.fileTree.setTab("changes")
|
layout.fileTree.setTab("changes")
|
||||||
if (!layout.fileTree.opened()) tabs().open("review")
|
|
||||||
requestAnimationFrame(() => comments.setFocus(focus))
|
requestAnimationFrame(() => comments.setFocus(focus))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
layout.fileTree.open()
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
const tab = files.tab(item.path)
|
const tab = files.tab(item.path)
|
||||||
tabs().open(tab)
|
tabs().open(tab)
|
||||||
@@ -1037,13 +1038,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
|
||||||
|
|
||||||
if (store.popover) {
|
if (store.popover) {
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
selectPopoverActive()
|
selectPopoverActive()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter") {
|
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
|
||||||
|
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
|
||||||
|
if (nav || ctrlNav) {
|
||||||
if (store.popover === "at") {
|
if (store.popover === "at") {
|
||||||
atOnKeyDown(event)
|
atOnKeyDown(event)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1057,8 +1062,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
|
|
||||||
|
|
||||||
if (ctrl && event.code === "KeyG") {
|
if (ctrl && event.code === "KeyG") {
|
||||||
if (store.popover) {
|
if (store.popover) {
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
@@ -1950,6 +1953,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
keybind={command.keybind("model.variant.cycle")}
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
data-action="model-variant-cycle"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
||||||
onClick={() => local.model.variant.cycle()}
|
onClick={() => local.model.variant.cycle()}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
|
layout.fileTree.open()
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
tabs().open("context")
|
tabs().open("context")
|
||||||
tabs().setActive("context")
|
tabs().setActive("context")
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function SessionHeader() {
|
|||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
class="hidden md:flex w-[320px] max-w-full min-w-0 p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
|
||||||
onClick={() => command.trigger("file.open")}
|
onClick={() => command.trigger("file.open")}
|
||||||
aria-label={language.t("session.header.searchFiles")}
|
aria-label={language.t("session.header.searchFiles")}
|
||||||
>
|
>
|
||||||
@@ -280,17 +280,14 @@ export function SessionHeader() {
|
|||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:block shrink-0">
|
<div class="hidden md:block shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
|
||||||
title={language.t("command.fileTree.toggle")}
|
|
||||||
keybind={command.keybind("fileTree.toggle")}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="group/file-tree-toggle size-6 p-0"
|
class="group/file-tree-toggle size-6 p-0"
|
||||||
onClick={() => layout.fileTree.toggle()}
|
onClick={() => layout.fileTree.toggle()}
|
||||||
aria-label={language.t("command.fileTree.toggle")}
|
aria-label={language.t("command.review.toggle")}
|
||||||
aria-expanded={layout.fileTree.opened()}
|
aria-expanded={layout.fileTree.opened()}
|
||||||
aria-controls="file-tree-panel"
|
aria-controls="review-panel"
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const soundOptions = [...SOUND_OPTIONS]
|
const soundOptions = [...SOUND_OPTIONS]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-10 pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
||||||
@@ -148,6 +148,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={language.t("settings.general.row.language.description")}
|
description={language.t("settings.general.row.language.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
data-action="settings-language"
|
||||||
options={languageOptions()}
|
options={languageOptions()}
|
||||||
current={languageOptions().find((o) => o.value === language.locale())}
|
current={languageOptions().find((o) => o.value === language.locale())}
|
||||||
value={(o) => o.value}
|
value={(o) => o.value}
|
||||||
@@ -406,8 +407,8 @@ interface SettingsRowProps {
|
|||||||
|
|
||||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-10 pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const SettingsModels: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-10 pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
|
||||||
@@ -99,7 +99,7 @@ export const SettingsModels: Component = () => {
|
|||||||
{(item) => {
|
{(item) => {
|
||||||
const key = { providerID: item.provider.id, modelID: item.id }
|
const key = { providerID: item.provider.id, modelID: item.id }
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
|
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -176,13 +176,13 @@ export const SettingsPermissions: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-1 p-8 max-w-[720px]">
|
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]">
|
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
|
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
|
||||||
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
||||||
@@ -217,8 +217,8 @@ interface SettingsRowProps {
|
|||||||
|
|
||||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
|
||||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||||
type ProviderMeta = { source?: ProviderSource }
|
type ProviderMeta = { source?: ProviderSource }
|
||||||
@@ -18,8 +20,14 @@ export const SettingsProviders: Component = () => {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|
||||||
|
const icon = (id: string): IconName => {
|
||||||
|
if (iconNames.includes(id as IconName)) return id as IconName
|
||||||
|
return "synthetic"
|
||||||
|
}
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
return providers
|
return providers
|
||||||
.connected()
|
.connected()
|
||||||
@@ -42,14 +50,53 @@ export const SettingsProviders: Component = () => {
|
|||||||
const current = source(item)
|
const current = source(item)
|
||||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||||
if (current === "config") return language.t("settings.providers.tag.config")
|
if (current === "config") {
|
||||||
|
const id = (item as { id?: string }).id
|
||||||
|
if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
|
||||||
|
return language.t("settings.providers.tag.config")
|
||||||
|
}
|
||||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: unknown) => source(item) !== "env"
|
const canDisconnect = (item: unknown) => source(item) !== "env"
|
||||||
|
|
||||||
|
const isConfigCustom = (providerID: string) => {
|
||||||
|
const provider = globalSync.data.config.provider?.[providerID]
|
||||||
|
if (!provider) return false
|
||||||
|
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||||
|
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableProvider = async (providerID: string, name: string) => {
|
||||||
|
const before = globalSync.data.config.disabled_providers ?? []
|
||||||
|
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||||
|
globalSync.set("config", "disabled_providers", next)
|
||||||
|
|
||||||
|
await globalSync
|
||||||
|
.updateConfig({ disabled_providers: next })
|
||||||
|
.then(() => {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||||
|
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
globalSync.set("config", "disabled_providers", before)
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
|
if (isConfigCustom(providerID)) {
|
||||||
|
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||||
|
await disableProvider(providerID, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
await globalSDK.client.auth
|
await globalSDK.client.auth
|
||||||
.remove({ providerID })
|
.remove({ providerID })
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -68,7 +115,7 @@ export const SettingsProviders: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-10 pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
|
||||||
@@ -89,9 +136,9 @@ export const SettingsProviders: Component = () => {
|
|||||||
>
|
>
|
||||||
<For each={connected()}>
|
<For each={connected()}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div class="group flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
|
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<ProviderIcon id={item.id as IconName} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
||||||
<Tag>{type(item)}</Tag>
|
<Tag>{type(item)}</Tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,10 +166,10 @@ export const SettingsProviders: Component = () => {
|
|||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
<For each={popular()}>
|
<For each={popular()}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
|
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<ProviderIcon id={item.id as IconName} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||||
<Show when={item.id === "opencode"}>
|
<Show when={item.id === "opencode"}>
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
@@ -177,6 +224,27 @@ export const SettingsProviders: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
<div class="flex items-center gap-x-3">
|
||||||
|
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
||||||
|
<span class="text-14-medium text-text-strong">Custom provider</span>
|
||||||
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
|
</div>
|
||||||
|
<span class="text-12-regular text-text-weak pl-8">Add an OpenAI-compatible provider by base URL.</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
icon="plus-small"
|
||||||
|
onClick={() => {
|
||||||
|
dialog.show(() => <DialogCustomProvider back="close" />)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{language.t("common.connect")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -132,12 +132,14 @@ export function Titlebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header class="h-10 shrink-0 bg-background-base flex items-center relative" data-tauri-drag-region>
|
<header
|
||||||
|
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex items-center w-full min-w-0": true,
|
"flex items-center min-w-0": true,
|
||||||
"pl-2": !mac(),
|
"pl-2": !mac(),
|
||||||
"pr-6": !windows(),
|
|
||||||
}}
|
}}
|
||||||
onMouseDown={drag}
|
onMouseDown={drag}
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
@@ -218,20 +220,29 @@ export function Titlebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
|
||||||
<div class="flex-1 h-full" data-tauri-drag-region />
|
</div>
|
||||||
<div
|
|
||||||
id="opencode-titlebar-right"
|
<div
|
||||||
class="flex items-center gap-3 shrink-0 flex-1 justify-end"
|
class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
/>
|
>
|
||||||
|
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex items-center min-w-0 justify-end": true,
|
||||||
|
"pr-6": !windows(),
|
||||||
|
}}
|
||||||
|
onMouseDown={drag}
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
|
||||||
<Show when={windows()}>
|
<Show when={windows()}>
|
||||||
<div class="w-6 shrink-0" />
|
<div class="w-6 shrink-0" />
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
||||||
<div id="opencode-titlebar-center" class="pointer-events-auto" />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,74 @@ function createGlobalSync() {
|
|||||||
config: {},
|
config: {},
|
||||||
reload: undefined,
|
reload: undefined,
|
||||||
})
|
})
|
||||||
let bootstrapQueue: string[] = []
|
|
||||||
|
const queued = new Set<string>()
|
||||||
|
let root = false
|
||||||
|
let running = false
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const paused = () => untrack(() => globalStore.reload) !== 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 (paused()) return
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
root = true
|
||||||
|
if (paused()) return
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain() {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
if (paused()) return
|
||||||
|
|
||||||
|
if (root) {
|
||||||
|
root = false
|
||||||
|
await bootstrap()
|
||||||
|
await tick()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = take(2)
|
||||||
|
if (dirs.length === 0) return
|
||||||
|
|
||||||
|
await Promise.all(dirs.map((dir) => bootstrapInstance(dir)))
|
||||||
|
await tick()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
if (paused()) return
|
||||||
|
if (root || queued.size) schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!projectCacheReady()) return
|
if (!projectCacheReady()) return
|
||||||
@@ -210,14 +277,8 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (globalStore.reload !== "complete") return
|
if (globalStore.reload !== "complete") return
|
||||||
if (bootstrapQueue.length) {
|
|
||||||
for (const directory of bootstrapQueue) {
|
|
||||||
bootstrapInstance(directory)
|
|
||||||
}
|
|
||||||
bootstrap()
|
|
||||||
}
|
|
||||||
bootstrapQueue = []
|
|
||||||
setGlobalStore("reload", undefined)
|
setGlobalStore("reload", undefined)
|
||||||
|
refresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
@@ -584,9 +645,8 @@ function createGlobalSync() {
|
|||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
switch (event?.type) {
|
switch (event?.type) {
|
||||||
case "global.disposed": {
|
case "global.disposed": {
|
||||||
if (globalStore.reload) return
|
refresh()
|
||||||
bootstrap()
|
return
|
||||||
break
|
|
||||||
}
|
}
|
||||||
case "project.updated": {
|
case "project.updated": {
|
||||||
const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
|
const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
|
||||||
@@ -647,12 +707,8 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "server.instance.disposed": {
|
case "server.instance.disposed": {
|
||||||
if (globalStore.reload) {
|
push(directory)
|
||||||
bootstrapQueue.push(directory)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
bootstrapInstance(directory)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
case "session.created": {
|
case "session.created": {
|
||||||
const info = event.properties.info
|
const info = event.properties.info
|
||||||
@@ -893,6 +949,10 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!timer) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
})
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const health = await globalSDK.client.global
|
const health = await globalSDK.client.global
|
||||||
@@ -916,7 +976,7 @@ function createGlobalSync() {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
globalSDK.client.config.get().then((x) => {
|
globalSDK.client.global.config.get().then((x) => {
|
||||||
setGlobalStore("config", x.data!)
|
setGlobalStore("config", x.data!)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -999,13 +1059,13 @@ function createGlobalSync() {
|
|||||||
},
|
},
|
||||||
child,
|
child,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
updateConfig: async (config: Config) => {
|
updateConfig: (config: Config) => {
|
||||||
setGlobalStore("reload", "pending")
|
setGlobalStore("reload", "pending")
|
||||||
const response = await globalSDK.client.config.update({ config })
|
return globalSDK.client.global.config.update({ config }).finally(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setGlobalStore("reload", "complete")
|
setGlobalStore("reload", "complete")
|
||||||
}, 1000)
|
}, 1000)
|
||||||
return response
|
})
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
loadSessions,
|
loadSessions,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { dict as ru } from "@/i18n/ru"
|
|||||||
import { dict as ar } from "@/i18n/ar"
|
import { dict as ar } from "@/i18n/ar"
|
||||||
import { dict as no } from "@/i18n/no"
|
import { dict as no } from "@/i18n/no"
|
||||||
import { dict as br } from "@/i18n/br"
|
import { dict as br } from "@/i18n/br"
|
||||||
|
import { dict as th } from "@/i18n/th"
|
||||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||||
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
||||||
@@ -31,13 +32,45 @@ import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
|
|||||||
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
|
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
|
||||||
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
||||||
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
||||||
|
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
|
||||||
|
|
||||||
export type Locale = "en" | "zh" | "zht" | "ko" | "de" | "es" | "fr" | "da" | "ja" | "pl" | "ru" | "ar" | "no" | "br"
|
export type Locale =
|
||||||
|
| "en"
|
||||||
|
| "zh"
|
||||||
|
| "zht"
|
||||||
|
| "ko"
|
||||||
|
| "de"
|
||||||
|
| "es"
|
||||||
|
| "fr"
|
||||||
|
| "da"
|
||||||
|
| "ja"
|
||||||
|
| "pl"
|
||||||
|
| "ru"
|
||||||
|
| "ar"
|
||||||
|
| "no"
|
||||||
|
| "br"
|
||||||
|
| "th"
|
||||||
|
|
||||||
type RawDictionary = typeof en & typeof uiEn
|
type RawDictionary = typeof en & typeof uiEn
|
||||||
type Dictionary = i18n.Flatten<RawDictionary>
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
|
|
||||||
const LOCALES: readonly Locale[] = ["en", "zh", "zht", "ko", "de", "es", "fr", "da", "ja", "pl", "ru", "ar", "no", "br"]
|
const LOCALES: readonly Locale[] = [
|
||||||
|
"en",
|
||||||
|
"zh",
|
||||||
|
"zht",
|
||||||
|
"ko",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"da",
|
||||||
|
"ja",
|
||||||
|
"pl",
|
||||||
|
"ru",
|
||||||
|
"ar",
|
||||||
|
"no",
|
||||||
|
"br",
|
||||||
|
"th",
|
||||||
|
]
|
||||||
|
|
||||||
function detectLocale(): Locale {
|
function detectLocale(): Locale {
|
||||||
if (typeof navigator !== "object") return "en"
|
if (typeof navigator !== "object") return "en"
|
||||||
@@ -65,6 +98,7 @@ function detectLocale(): Locale {
|
|||||||
)
|
)
|
||||||
return "no"
|
return "no"
|
||||||
if (language.toLowerCase().startsWith("pt")) return "br"
|
if (language.toLowerCase().startsWith("pt")) return "br"
|
||||||
|
if (language.toLowerCase().startsWith("th")) return "th"
|
||||||
}
|
}
|
||||||
|
|
||||||
return "en"
|
return "en"
|
||||||
@@ -94,6 +128,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
if (store.locale === "ar") return "ar"
|
if (store.locale === "ar") return "ar"
|
||||||
if (store.locale === "no") return "no"
|
if (store.locale === "no") return "no"
|
||||||
if (store.locale === "br") return "br"
|
if (store.locale === "br") return "br"
|
||||||
|
if (store.locale === "th") return "th"
|
||||||
return "en"
|
return "en"
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -118,6 +153,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
|
if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
|
||||||
if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
|
if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
|
||||||
if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
|
if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
|
||||||
|
if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
|
||||||
return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
|
return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,6 +174,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
ar: "language.ar",
|
ar: "language.ar",
|
||||||
no: "language.no",
|
no: "language.no",
|
||||||
br: "language.br",
|
br: "language.br",
|
||||||
|
th: "language.th",
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = (value: Locale) => t(labelKey[value])
|
const label = (value: Locale) => t(labelKey[value])
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
|
|||||||
import { createScrollPersistence } from "./layout-scroll"
|
import { createScrollPersistence } from "./layout-scroll"
|
||||||
|
|
||||||
describe("createScrollPersistence", () => {
|
describe("createScrollPersistence", () => {
|
||||||
test("debounces persisted scroll writes", async () => {
|
test.skip("debounces persisted scroll writes", async () => {
|
||||||
const key = "layout-scroll.test"
|
const key = "layout-scroll.test"
|
||||||
const data = new Map<string, string>()
|
const data = new Map<string, string>()
|
||||||
const writes: string[] = []
|
const writes: string[] = []
|
||||||
|
|||||||
@@ -624,10 +624,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs,
|
||||||
active: createMemo(() => tabs().active),
|
active: createMemo(() => (tabs().active === "review" ? undefined : tabs().active)),
|
||||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||||
setActive(tab: string | undefined) {
|
setActive(tab: string | undefined) {
|
||||||
const session = key()
|
const session = key()
|
||||||
|
if (tab === "review") return
|
||||||
if (!store.sessionTabs[session]) {
|
if (!store.sessionTabs[session]) {
|
||||||
setStore("sessionTabs", session, { all: [], active: tab })
|
setStore("sessionTabs", session, { all: [], active: tab })
|
||||||
} else {
|
} else {
|
||||||
@@ -644,18 +645,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
|
if (tab === "review") return
|
||||||
const session = key()
|
const session = key()
|
||||||
const current = store.sessionTabs[session] ?? { all: [] }
|
const current = store.sessionTabs[session] ?? { all: [] }
|
||||||
|
|
||||||
if (tab === "review") {
|
|
||||||
if (!store.sessionTabs[session]) {
|
|
||||||
setStore("sessionTabs", session, { all: current.all, active: tab })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("sessionTabs", session, "active", tab)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab === "context") {
|
if (tab === "context") {
|
||||||
const all = [tab, ...current.all.filter((x) => x !== tab)]
|
const all = [tab, ...current.all.filter((x) => x !== tab)]
|
||||||
if (!store.sessionTabs[session]) {
|
if (!store.sessionTabs[session]) {
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "لغة",
|
"toast.language.title": "لغة",
|
||||||
"toast.language.description": "تم التبديل إلى {{language}}",
|
"toast.language.description": "تم التبديل إلى {{language}}",
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Idioma",
|
"toast.language.title": "Idioma",
|
||||||
"toast.language.description": "Alterado para {{language}}",
|
"toast.language.description": "Alterado para {{language}}",
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Sprog",
|
"toast.language.title": "Sprog",
|
||||||
"toast.language.description": "Skiftede til {{language}}",
|
"toast.language.description": "Skiftede til {{language}}",
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Sprache",
|
"toast.language.title": "Sprache",
|
||||||
"toast.language.description": "Zu {{language}} gewechselt",
|
"toast.language.description": "Zu {{language}} gewechselt",
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Language",
|
"toast.language.title": "Language",
|
||||||
"toast.language.description": "Switched to {{language}}",
|
"toast.language.description": "Switched to {{language}}",
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Idioma",
|
"toast.language.title": "Idioma",
|
||||||
"toast.language.description": "Cambiado a {{language}}",
|
"toast.language.description": "Cambiado a {{language}}",
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Langue",
|
"toast.language.title": "Langue",
|
||||||
"toast.language.description": "Passé à {{language}}",
|
"toast.language.description": "Passé à {{language}}",
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "言語",
|
"toast.language.title": "言語",
|
||||||
"toast.language.description": "{{language}}に切り替えました",
|
"toast.language.description": "{{language}}に切り替えました",
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "언어",
|
"toast.language.title": "언어",
|
||||||
"toast.language.description": "{{language}}(으)로 전환됨",
|
"toast.language.description": "{{language}}(으)로 전환됨",
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Språk",
|
"toast.language.title": "Språk",
|
||||||
"toast.language.description": "Byttet til {{language}}",
|
"toast.language.description": "Byttet til {{language}}",
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Język",
|
"toast.language.title": "Język",
|
||||||
"toast.language.description": "Przełączono na {{language}}",
|
"toast.language.description": "Przełączono na {{language}}",
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ export const dict = {
|
|||||||
"language.ar": "العربية",
|
"language.ar": "العربية",
|
||||||
"language.no": "Norsk",
|
"language.no": "Norsk",
|
||||||
"language.br": "Português (Brasil)",
|
"language.br": "Português (Brasil)",
|
||||||
|
"language.th": "ไทย",
|
||||||
|
|
||||||
"toast.language.title": "Язык",
|
"toast.language.title": "Язык",
|
||||||
"toast.language.description": "Переключено на {{language}}",
|
"toast.language.description": "Переключено на {{language}}",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user