Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1482e1483 | ||
|
|
45f0050372 | ||
|
|
b5c8bd3421 | ||
|
|
2bab5e8c39 | ||
|
|
85b5f5b705 | ||
|
|
460a87f359 | ||
|
|
c190f5f611 | ||
|
|
7911cb62ab | ||
|
|
839c5cda12 | ||
|
|
67c985ce82 | ||
|
|
575f2cf2a5 | ||
|
|
933a491ade | ||
|
|
3b6b3e6fc8 | ||
|
|
8631d6c01d | ||
|
|
68bb8ce1da | ||
|
|
306fc77076 | ||
|
|
759ec104b6 | ||
|
|
ef205c3660 | ||
|
|
df3203d2dd | ||
|
|
ed439b2057 | ||
|
|
cd775a2862 | ||
|
|
b020758446 | ||
|
|
179c40749d | ||
|
|
1e25df21a2 | ||
|
|
ffc000de8e | ||
|
|
0b9e929f68 | ||
|
|
d0dcffefa7 | ||
|
|
7d46872775 | ||
|
|
afb04ed5d4 | ||
|
|
6d95f0d14c | ||
|
|
d018903887 | ||
|
|
d30e917385 | ||
|
|
72c09e1dcc | ||
|
|
bc1fd0633d | ||
|
|
88e2eb5416 | ||
|
|
b8848cfae1 | ||
|
|
4f51c0912d | ||
|
|
1c71604e0a | ||
|
|
e242fe19e4 | ||
|
|
f991a6c0b6 | ||
|
|
b1764b2ffd | ||
|
|
ebe5a2b74a | ||
|
|
9f20e0d14b | ||
|
|
ebb907d646 | ||
|
|
b8ee882126 | ||
|
|
693127d382 | ||
|
|
0d90a22f90 | ||
|
|
34ebe814dd | ||
|
|
1fb6c0b5b3 | ||
|
|
98aeb60a7f | ||
|
|
1608565c80 | ||
|
|
b06afd657d | ||
|
|
dd296f7033 | ||
|
|
fb7b2f6b4d | ||
|
|
e0f1c3c20e | ||
|
|
dec304a273 | ||
|
|
c9719dff72 | ||
|
|
7f95cc64c5 | ||
|
|
b525c03d20 | ||
|
|
8da5fd0a66 | ||
|
|
0303c29e3f | ||
|
|
adb0c4d4f9 | ||
|
|
991496a753 | ||
|
|
76db218674 | ||
|
|
29671c1397 | ||
|
|
f66624fe6e | ||
|
|
d475fd6137 | ||
|
|
93eee0daf4 | ||
|
|
445e0d7676 | ||
|
|
4018c863e3 | ||
|
|
a8f2884521 | ||
|
|
c0814da785 | ||
|
|
20dcff1e2e | ||
|
|
11dd281c92 | ||
|
|
548608b7ad | ||
|
|
4e0f509e7b | ||
|
|
ff3b174c42 | ||
|
|
70303d0b42 | ||
|
|
7ccf223c84 | ||
|
|
e9b9a62fe4 | ||
|
|
81c623f26e | ||
|
|
3696d1ded1 | ||
|
|
50f208d69f | ||
|
|
958320f9c1 | ||
|
|
d1ee4c8dca | ||
|
|
ac018e3a35 | ||
|
|
e6e9c15d34 | ||
|
|
aaee5fb680 | ||
|
|
ff0abacf4b | ||
|
|
0771e3a8be | ||
|
|
da952135ca | ||
|
|
789705ea96 | ||
|
|
ba54cee55e | ||
|
|
847e06f9e1 | ||
|
|
2db618dea3 | ||
|
|
ecab692ca1 | ||
|
|
59a323e9a8 | ||
|
|
658bf6fa58 | ||
|
|
a82ca86008 | ||
|
|
ed472d8a67 | ||
|
|
ff4414bb15 | ||
|
|
56ad2db020 | ||
|
|
ae811ad8d2 | ||
|
|
85df106713 | ||
|
|
892bb75265 | ||
|
|
a115565054 | ||
|
|
d82d22b2d7 | ||
|
|
d723147083 | ||
|
|
9f9f0fb8eb | ||
|
|
ecb274273a | ||
|
|
5f421883a8 | ||
|
|
fa97475ee8 | ||
|
|
0eaeb4588e | ||
|
|
1413d77b1f | ||
|
|
624dd94b5d | ||
|
|
d86f24b6b3 | ||
|
|
03de51bd3c | ||
|
|
8f9742d988 | ||
|
|
f6e7aefa72 | ||
|
|
e269788a8f | ||
|
|
66780195dc | ||
|
|
ad2087094d | ||
|
|
5bdf1c4b96 | ||
|
|
135f8ffb2a | ||
|
|
bf5a01edd9 | ||
|
|
81ca2df6ad | ||
|
|
aea68c386a | ||
|
|
8eea53a41e | ||
|
|
3befd0c6c5 | ||
|
|
8577eb8ec9 | ||
|
|
c856f875a1 | ||
|
|
264dd213f9 | ||
|
|
125727d09c | ||
|
|
8c7b35ad05 | ||
|
|
e2a33f75e1 | ||
|
|
006d673ed2 | ||
|
|
6b4d617df0 | ||
|
|
e3471526f4 | ||
|
|
6b30e0b752 | ||
|
|
fbabce1125 | ||
|
|
8f56ed5b85 | ||
|
|
81b5a6a08b | ||
|
|
94cb6390aa | ||
|
|
42bea5d297 | ||
|
|
f252e3234c | ||
|
|
eef3ae3e1f | ||
|
|
fc88dde63f | ||
|
|
4619e9d183 | ||
|
|
4dc363f306 | ||
|
|
2e8082dd21 | ||
|
|
a52fe28246 | ||
|
|
8c5ba8aeb0 | ||
|
|
50330820c0 | ||
|
|
7222fc0ba0 | ||
|
|
17bdb5d56a | ||
|
|
7a463cd193 | ||
|
|
352a54c698 | ||
|
|
93957da2c9 | ||
|
|
edcfd562af | ||
|
|
ef5ec5dc28 | ||
|
|
c426cb0f14 | ||
|
|
dd1862cc2b | ||
|
|
a25b2af05a | ||
|
|
8bfd6fdba2 | ||
|
|
cf7a1b8d80 | ||
|
|
5ba4c0e024 | ||
|
|
567e094e6c | ||
|
|
b523998329 | ||
|
|
7e1247c420 | ||
|
|
783888131e | ||
|
|
213a87234d | ||
|
|
d98bd4bd52 | ||
|
|
22125d1347 | ||
|
|
8c120f2fab | ||
|
|
c6ec2f47ef | ||
|
|
0fd6f365be | ||
|
|
60bdb6e9ba | ||
|
|
6e9cd576ea | ||
|
|
53ec15a56a | ||
|
|
a90b62267f | ||
|
|
24556331c8 | ||
|
|
39145b99e8 | ||
|
|
0afa6e03a8 | ||
|
|
7a3c775dc1 | ||
|
|
3ea58bb790 | ||
|
|
50c705cd2d | ||
|
|
3894c217cc | ||
|
|
66c2bb8f37 | ||
|
|
1bbbd51d48 | ||
|
|
50f3e74d05 | ||
|
|
21475a1dfd | ||
|
|
dce4c05fa9 | ||
|
|
8c56571ef9 | ||
|
|
92a77b72fb | ||
|
|
4f6b929784 | ||
|
|
55119559b3 | ||
|
|
fd5531316f | ||
|
|
fbc41475b4 | ||
|
|
a0673256db | ||
|
|
fc37337a3e | ||
|
|
80220cebe4 | ||
|
|
8bdf6fa359 | ||
|
|
1d11a0adfd | ||
|
|
1e2f664410 | ||
|
|
a3aad9c9bf | ||
|
|
eb2587844b | ||
|
|
d863a9cf4e | ||
|
|
7d5be1556a | ||
|
|
659f15aa9b | ||
|
|
d1f5b9e911 | ||
|
|
284b00ff23 | ||
|
|
2c5760742b | ||
|
|
70c794e913 | ||
|
|
3929f0b5bd | ||
|
|
6f5dfe125a | ||
|
|
27fa9dc843 | ||
|
|
1e03a55acd | ||
|
|
65c9669283 | ||
|
|
18b6257119 | ||
|
|
c607c01fb9 | ||
|
|
4c4e30cd71 | ||
|
|
19ad7ad809 | ||
|
|
87795384de | ||
|
|
0732ab3393 | ||
|
|
2bccfd7462 | ||
|
|
83853cc5e6 | ||
|
|
4a73d51acd | ||
|
|
63cd763418 | ||
|
|
32394b699e | ||
|
|
12262862cd | ||
|
|
56a752092e | ||
|
|
439e7ec1fd | ||
|
|
20cf3fc679 | ||
|
|
949f61075f | ||
|
|
705200e199 | ||
|
|
85fa8abd50 | ||
|
|
3118cab2d8 | ||
|
|
31f893f8cb | ||
|
|
056d0c1197 | ||
|
|
832902c8e3 | ||
|
|
3d6fb29f0c | ||
|
|
9824370f82 | ||
|
|
371e106faa | ||
|
|
19809e7680 | ||
|
|
389afef336 | ||
|
|
274bb948e7 | ||
|
|
d9b4535d64 | ||
|
|
3dc720ff9c | ||
|
|
56b340b5d5 | ||
|
|
ba740eaefd | ||
|
|
39c5da4405 | ||
|
|
83708c295c | ||
|
|
a84bdd7cd7 | ||
|
|
110f6804fb | ||
|
|
e5ec2f9991 | ||
|
|
7bca3fbf18 | ||
|
|
d578f80f00 | ||
|
|
dc53086c1e | ||
|
|
f74c0339cc | ||
|
|
fb94b4f8e8 | ||
|
|
8ad4768ecd | ||
|
|
24fd8c166d | ||
|
|
a7c5d5ac4c |
@@ -1,4 +1,4 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: 💬 Discord Community
|
- name: 💬 Discord Community
|
||||||
url: https://discord.gg/opencode
|
url: https://discord.gg/opencode
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Vouched contributors for this project.
|
||||||
|
#
|
||||||
|
# See https://github.com/mitchellh/vouch for details.
|
||||||
|
#
|
||||||
|
# Syntax:
|
||||||
|
# - One handle per line (without @), sorted alphabetically.
|
||||||
|
# - Optional platform prefix: platform:username (e.g., github:user).
|
||||||
|
# - Denounce with minus prefix: -username or -platform:username.
|
||||||
|
# - Optional details after a space following the handle.
|
||||||
|
adamdotdevin
|
||||||
|
ariane-emory
|
||||||
|
-florianleibert
|
||||||
|
fwang
|
||||||
|
iamdavidhill
|
||||||
|
jayair
|
||||||
|
kitlangton
|
||||||
|
kommander
|
||||||
|
r44vc0rp
|
||||||
|
rekram1-node
|
||||||
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
|
thdxr
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
### 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.
|
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!**
|
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
name: compliance-close
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every 30 minutes to check for expired compliance windows
|
||||||
|
- cron: "*/30 * * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-non-compliant:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close non-compliant issues and PRs after 2 hours
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { data: items } = await github.rest.issues.listForRepo({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
labels: 'needs:compliance',
|
||||||
|
state: 'open',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
core.info('No open issues/PRs with needs:compliance label');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const twoHours = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const isPR = !!item.pull_request;
|
||||||
|
const kind = isPR ? 'PR' : 'issue';
|
||||||
|
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: item.number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const complianceComment = comments.find(c => c.body.includes('<!-- issue-compliance -->'));
|
||||||
|
if (!complianceComment) continue;
|
||||||
|
|
||||||
|
const commentAge = now - new Date(complianceComment.created_at).getTime();
|
||||||
|
if (commentAge < twoHours) {
|
||||||
|
core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMessage = isPR
|
||||||
|
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
|
||||||
|
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: item.number,
|
||||||
|
body: closeMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPR) {
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: item.number,
|
||||||
|
state: 'closed',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: item.number,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`);
|
||||||
|
}
|
||||||
@@ -48,8 +48,12 @@ jobs:
|
|||||||
TODAY'S DATE: ${TODAY}
|
TODAY'S DATE: ${TODAY}
|
||||||
|
|
||||||
STEP 1: Gather today's issues
|
STEP 1: Gather today's issues
|
||||||
Search for all issues created today (${TODAY}) using:
|
Search for all OPEN issues created today (${TODAY}) using:
|
||||||
gh issue list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
|
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
|
||||||
|
|
||||||
|
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
|
||||||
|
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
|
||||||
|
This recap is specifically for COMMUNITY (external) issues only.
|
||||||
|
|
||||||
STEP 2: Analyze and categorize
|
STEP 2: Analyze and categorize
|
||||||
For each issue created today, categorize it:
|
For each issue created today, categorize it:
|
||||||
|
|||||||
@@ -47,14 +47,18 @@ jobs:
|
|||||||
TODAY'S DATE: ${TODAY}
|
TODAY'S DATE: ${TODAY}
|
||||||
|
|
||||||
STEP 1: Gather PR data
|
STEP 1: Gather PR data
|
||||||
Run these commands to gather PR information. ONLY include PRs created or updated TODAY (${TODAY}):
|
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
|
||||||
|
|
||||||
# PRs created today
|
# Open PRs created today
|
||||||
gh pr list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||||
|
|
||||||
# PRs with activity today (updated today)
|
# Open PRs with activity today (updated today)
|
||||||
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||||
|
|
||||||
|
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
|
||||||
|
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
|
||||||
|
This recap is specifically for COMMUNITY (external) contributions only.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
STEP 2: For high-activity PRs, check comment counts
|
STEP 2: For high-activity PRs, check comment counts
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
name: docs-locale-sync
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- packages/web/src/content/docs/*.mdx
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-locales:
|
||||||
|
if: github.actor != 'opencode-agent[bot]'
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
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: committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
|
- name: Compute changed English docs
|
||||||
|
id: changes
|
||||||
|
run: |
|
||||||
|
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
|
||||||
|
if [ -z "$FILES" ]; then
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No English docs changed in push range"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
{
|
||||||
|
echo "files<<EOF"
|
||||||
|
echo "$FILES"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Sync locale docs with OpenCode
|
||||||
|
if: steps.changes.outputs.has_changes == 'true'
|
||||||
|
uses: sst/opencode/github@latest
|
||||||
|
env:
|
||||||
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
|
with:
|
||||||
|
model: opencode/gpt-5.2
|
||||||
|
agent: docs
|
||||||
|
prompt: |
|
||||||
|
Update localized docs to match the latest English docs changes.
|
||||||
|
|
||||||
|
Changed English doc files:
|
||||||
|
<changed_english_docs>
|
||||||
|
${{ steps.changes.outputs.files }}
|
||||||
|
</changed_english_docs>
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
1. Update all relevant locale docs under packages/web/src/content/docs/<locale>/ so they reflect these English page changes.
|
||||||
|
2. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md).
|
||||||
|
3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates.
|
||||||
|
4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent.
|
||||||
|
5. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
|
||||||
|
6. Keep locale docs structure aligned with their corresponding English pages.
|
||||||
|
7. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
|
||||||
|
8. If no locale updates are needed, make no changes.
|
||||||
|
|
||||||
|
- name: Commit and push locale docs updates
|
||||||
|
if: steps.changes.outputs.has_changes == 'true'
|
||||||
|
run: |
|
||||||
|
if [ -z "$(git status --porcelain)" ]; then
|
||||||
|
echo "No locale docs changes to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git add -A
|
||||||
|
git commit -m "docs(i18n): sync locale docs from english changes"
|
||||||
|
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||||
|
git push origin HEAD:"$GITHUB_REF_NAME"
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
- name: Check for duplicate issues
|
- name: Check duplicates and compliance
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -34,30 +34,84 @@ jobs:
|
|||||||
"webfetch": "deny"
|
"webfetch": "deny"
|
||||||
}
|
}
|
||||||
run: |
|
run: |
|
||||||
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:'
|
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
|
||||||
|
|
||||||
Issue number:
|
Issue number: ${{ github.event.issue.number }}
|
||||||
${{ github.event.issue.number }}
|
|
||||||
|
|
||||||
Lookup this issue and search through existing issues (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue.
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
|
|
||||||
|
You have TWO tasks. Perform both, then post a SINGLE comment (if needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
|
||||||
|
|
||||||
|
Check whether the issue follows our contributing guidelines and issue templates.
|
||||||
|
|
||||||
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|
||||||
|
1. Bug Report - requires a Description field with real content
|
||||||
|
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
|
||||||
|
3. Question - requires the Question field with real content
|
||||||
|
|
||||||
|
Additionally check:
|
||||||
|
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
|
||||||
|
- The issue has real content, not just template placeholder text left unchanged
|
||||||
|
- Bug reports should include some context about how to reproduce
|
||||||
|
- Feature requests should explain the problem or need
|
||||||
|
- We want to push for having the user provide system description & information
|
||||||
|
|
||||||
|
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
TASK 2: DUPLICATE CHECK
|
||||||
|
|
||||||
|
Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
|
||||||
Consider:
|
Consider:
|
||||||
1. Similar titles or descriptions
|
1. Similar titles or descriptions
|
||||||
2. Same error messages or symptoms
|
2. Same error messages or symptoms
|
||||||
3. Related functionality or components
|
3. Related functionality or components
|
||||||
4. Similar feature requests
|
4. Similar feature requests
|
||||||
|
|
||||||
If you find any potential duplicates, please comment on the new issue with:
|
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
|
||||||
- A brief explanation of why it might be a duplicate
|
|
||||||
- Links to the potentially duplicate issues
|
---
|
||||||
- A suggestion to check those issues first
|
|
||||||
|
POSTING YOUR COMMENT:
|
||||||
|
|
||||||
|
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
||||||
|
|
||||||
|
If the issue is NOT compliant, start the comment with:
|
||||||
|
<!-- issue-compliance -->
|
||||||
|
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||||
|
|
||||||
|
If duplicates were found, include a section about potential duplicates with links.
|
||||||
|
|
||||||
|
If the issue mentions keybinds/keyboard shortcuts, include a note about #4997.
|
||||||
|
|
||||||
|
If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all.
|
||||||
|
|
||||||
Use this format for the comment:
|
Use this format for the comment:
|
||||||
'This issue might be a duplicate of existing issues. Please check:
|
|
||||||
|
[If not compliant:]
|
||||||
|
<!-- issue-compliance -->
|
||||||
|
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
**What needs to be fixed:**
|
||||||
|
- [specific reasons]
|
||||||
|
|
||||||
|
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
|
||||||
|
|
||||||
|
[If duplicates found, add:]
|
||||||
|
---
|
||||||
|
This issue might be a duplicate of existing issues. Please check:
|
||||||
- #[issue_number]: [brief description of similarity]
|
- #[issue_number]: [brief description of similarity]
|
||||||
|
|
||||||
Feel free to ignore if none of these address your specific case.'
|
[If keybind-related, add:]
|
||||||
|
For keybind-related issues, please also check our pinned keybinds documentation: #4997
|
||||||
|
|
||||||
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, please add a comment mentioning the pinned keybinds issue #4997:
|
[End with if not compliant:]
|
||||||
'For keybind-related issues, please also check our pinned keybinds documentation: #4997'
|
If you believe this was flagged incorrectly, please let a maintainer know.
|
||||||
|
|
||||||
If no clear duplicates are found, do not comment."
|
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ on:
|
|||||||
- "package.json"
|
- "package.json"
|
||||||
- "packages/*/package.json"
|
- "packages/*/package.json"
|
||||||
- "flake.lock"
|
- "flake.lock"
|
||||||
|
- "nix/node_modules.nix"
|
||||||
|
- "nix/scripts/**"
|
||||||
|
- "patches/**"
|
||||||
- ".github/workflows/nix-hashes.yml"
|
- ".github/workflows/nix-hashes.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
|
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
|
||||||
|
|
||||||
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
if [ "$COMMENT" != "No duplicate PRs found" ]; then
|
||||||
|
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
|
||||||
|
|
||||||
$COMMENT"
|
$COMMENT"
|
||||||
|
fi
|
||||||
|
|
||||||
add-contributor-label:
|
add-contributor-label:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: sign-cli
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- brendan/desktop-signpath
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sign-cli:
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
./packages/opencode/script/build.ts
|
||||||
|
|
||||||
|
- name: Upload unsigned Windows CLI
|
||||||
|
id: upload_unsigned_windows_cli
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unsigned-opencode-windows-cli
|
||||||
|
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Submit SignPath signing request
|
||||||
|
id: submit_signpath_signing_request
|
||||||
|
uses: signpath/github-action-submit-signing-request@v1
|
||||||
|
with:
|
||||||
|
api-token: ${{ secrets.SIGNPATH_API_KEY }}
|
||||||
|
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||||
|
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||||
|
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||||
|
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||||
|
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
|
||||||
|
wait-for-completion: true
|
||||||
|
output-artifact-directory: signed-opencode-cli
|
||||||
|
|
||||||
|
- name: Upload signed Windows CLI
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: signed-opencode-windows-cli
|
||||||
|
path: signed-opencode-cli/*.exe
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
name: vouch-check-issue
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check if issue author is denounced
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const author = context.payload.issue.user.login;
|
||||||
|
const issueNumber = context.payload.issue.number;
|
||||||
|
|
||||||
|
// Skip bots
|
||||||
|
if (author.endsWith('[bot]')) {
|
||||||
|
core.info(`Skipping bot: ${author}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the VOUCHED.td file via API (no checkout needed)
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
const response = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path: '.github/VOUCHED.td',
|
||||||
|
});
|
||||||
|
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
core.info('No .github/VOUCHED.td file found, skipping check.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the .td file for denounced users
|
||||||
|
const denounced = new Map();
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
if (!trimmed.startsWith('-')) continue;
|
||||||
|
|
||||||
|
const rest = trimmed.slice(1).trim();
|
||||||
|
if (!rest) continue;
|
||||||
|
const spaceIdx = rest.indexOf(' ');
|
||||||
|
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||||
|
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||||
|
|
||||||
|
// Handle platform:username or bare username
|
||||||
|
// Only match bare usernames or github: prefix (skip other platforms)
|
||||||
|
const colonIdx = handle.indexOf(':');
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
const platform = handle.slice(0, colonIdx).toLowerCase();
|
||||||
|
if (platform !== 'github') continue;
|
||||||
|
}
|
||||||
|
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||||
|
if (!username) continue;
|
||||||
|
|
||||||
|
denounced.set(username.toLowerCase(), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the author is denounced
|
||||||
|
const reason = denounced.get(author.toLowerCase());
|
||||||
|
if (reason === undefined) {
|
||||||
|
core.info(`User ${author} is not denounced. Allowing issue.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author is denounced — close the issue
|
||||||
|
const body = 'This issue has been automatically closed.';
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned',
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
name: vouch-check-pr
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check if PR author is denounced
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const author = context.payload.pull_request.user.login;
|
||||||
|
const prNumber = context.payload.pull_request.number;
|
||||||
|
|
||||||
|
// Skip bots
|
||||||
|
if (author.endsWith('[bot]')) {
|
||||||
|
core.info(`Skipping bot: ${author}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the VOUCHED.td file via API (no checkout needed)
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
const response = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path: '.github/VOUCHED.td',
|
||||||
|
});
|
||||||
|
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
core.info('No .github/VOUCHED.td file found, skipping check.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the .td file for denounced users
|
||||||
|
const denounced = new Map();
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
if (!trimmed.startsWith('-')) continue;
|
||||||
|
|
||||||
|
const rest = trimmed.slice(1).trim();
|
||||||
|
if (!rest) continue;
|
||||||
|
const spaceIdx = rest.indexOf(' ');
|
||||||
|
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||||
|
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
|
||||||
|
|
||||||
|
// Handle platform:username or bare username
|
||||||
|
// Only match bare usernames or github: prefix (skip other platforms)
|
||||||
|
const colonIdx = handle.indexOf(':');
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
const platform = handle.slice(0, colonIdx).toLowerCase();
|
||||||
|
if (platform !== 'github') continue;
|
||||||
|
}
|
||||||
|
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
|
||||||
|
if (!username) continue;
|
||||||
|
|
||||||
|
denounced.set(username.toLowerCase(), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the author is denounced
|
||||||
|
const reason = denounced.get(author.toLowerCase());
|
||||||
|
if (reason === undefined) {
|
||||||
|
core.info(`User ${author} is not denounced. Allowing PR.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author is denounced — close the PR
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body: 'This pull request has been automatically closed.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: prNumber,
|
||||||
|
state: 'closed',
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: vouch-manage-by-issue
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: vouch-manage
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
manage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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: mitchellh/vouch/action/manage-by-issue@main
|
||||||
|
with:
|
||||||
|
issue-id: ${{ github.event.issue.number }}
|
||||||
|
comment-id: ${{ github.event.comment.id }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
@@ -0,0 +1,883 @@
|
|||||||
|
---
|
||||||
|
description: Translate content for a specified locale while preserving technical terms
|
||||||
|
mode: subagent
|
||||||
|
model: opencode/gemini-3-pro
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a professional translator and localization specialist.
|
||||||
|
|
||||||
|
Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE).
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
|
||||||
|
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
|
||||||
|
- Also preserve every term listed in the Do-Not-Translate glossary below.
|
||||||
|
- Do not modify fenced code blocks.
|
||||||
|
- Output ONLY the translation (no commentary).
|
||||||
|
|
||||||
|
If the target locale is missing, ask the user to provide it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Do-Not-Translate Terms (OpenCode Docs)
|
||||||
|
|
||||||
|
Generated from: `packages/web/src/content/docs/*.mdx` (default English docs)
|
||||||
|
Generated on: 2026-02-10
|
||||||
|
|
||||||
|
Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation).
|
||||||
|
|
||||||
|
General rules (verbatim, even if not listed below):
|
||||||
|
|
||||||
|
- Anything inside inline code (single backticks) or fenced code blocks (triple backticks)
|
||||||
|
- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers
|
||||||
|
- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars
|
||||||
|
|
||||||
|
## Proper nouns and product names
|
||||||
|
|
||||||
|
Additional (not reliably captured via link text):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Astro
|
||||||
|
Bun
|
||||||
|
Chocolatey
|
||||||
|
Cursor
|
||||||
|
Docker
|
||||||
|
Git
|
||||||
|
GitHub Actions
|
||||||
|
GitLab CI
|
||||||
|
GNOME Terminal
|
||||||
|
Homebrew
|
||||||
|
Mise
|
||||||
|
Neovim
|
||||||
|
Node.js
|
||||||
|
npm
|
||||||
|
Obsidian
|
||||||
|
opencode
|
||||||
|
opencode-ai
|
||||||
|
Paru
|
||||||
|
pnpm
|
||||||
|
ripgrep
|
||||||
|
Scoop
|
||||||
|
SST
|
||||||
|
Starlight
|
||||||
|
Visual Studio Code
|
||||||
|
VS Code
|
||||||
|
VSCodium
|
||||||
|
Windsurf
|
||||||
|
Windows Terminal
|
||||||
|
Yarn
|
||||||
|
Zellij
|
||||||
|
Zed
|
||||||
|
anomalyco
|
||||||
|
```
|
||||||
|
|
||||||
|
Extracted from link labels in the English docs (review and prune as desired):
|
||||||
|
|
||||||
|
```text
|
||||||
|
@openspoon/subtask2
|
||||||
|
302.AI console
|
||||||
|
ACP progress report
|
||||||
|
Agent Client Protocol
|
||||||
|
Agent Skills
|
||||||
|
Agentic
|
||||||
|
AGENTS.md
|
||||||
|
AI SDK
|
||||||
|
Alacritty
|
||||||
|
Anthropic
|
||||||
|
Anthropic's Data Policies
|
||||||
|
Atom One
|
||||||
|
Avante.nvim
|
||||||
|
Ayu
|
||||||
|
Azure AI Foundry
|
||||||
|
Azure portal
|
||||||
|
Baseten
|
||||||
|
built-in GITHUB_TOKEN
|
||||||
|
Bun.$
|
||||||
|
Catppuccin
|
||||||
|
Cerebras console
|
||||||
|
ChatGPT Plus or Pro
|
||||||
|
Cloudflare dashboard
|
||||||
|
CodeCompanion.nvim
|
||||||
|
CodeNomad
|
||||||
|
Configuring Adapters: Environment Variables
|
||||||
|
Context7 MCP server
|
||||||
|
Cortecs console
|
||||||
|
Deep Infra dashboard
|
||||||
|
DeepSeek console
|
||||||
|
Duo Agent Platform
|
||||||
|
Everforest
|
||||||
|
Fireworks AI console
|
||||||
|
Firmware dashboard
|
||||||
|
Ghostty
|
||||||
|
GitLab CLI agents docs
|
||||||
|
GitLab docs
|
||||||
|
GitLab User Settings > Access Tokens
|
||||||
|
Granular Rules (Object Syntax)
|
||||||
|
Grep by Vercel
|
||||||
|
Groq console
|
||||||
|
Gruvbox
|
||||||
|
Helicone
|
||||||
|
Helicone documentation
|
||||||
|
Helicone Header Directory
|
||||||
|
Helicone's Model Directory
|
||||||
|
Hugging Face Inference Providers
|
||||||
|
Hugging Face settings
|
||||||
|
install WSL
|
||||||
|
IO.NET console
|
||||||
|
JetBrains IDE
|
||||||
|
Kanagawa
|
||||||
|
Kitty
|
||||||
|
MiniMax API Console
|
||||||
|
Models.dev
|
||||||
|
Moonshot AI console
|
||||||
|
Nebius Token Factory console
|
||||||
|
Nord
|
||||||
|
OAuth
|
||||||
|
Ollama integration docs
|
||||||
|
OpenAI's Data Policies
|
||||||
|
OpenChamber
|
||||||
|
OpenCode
|
||||||
|
OpenCode config
|
||||||
|
OpenCode Config
|
||||||
|
OpenCode TUI with the opencode theme
|
||||||
|
OpenCode Web - Active Session
|
||||||
|
OpenCode Web - New Session
|
||||||
|
OpenCode Web - See Servers
|
||||||
|
OpenCode Zen
|
||||||
|
OpenCode-Obsidian
|
||||||
|
OpenRouter dashboard
|
||||||
|
OpenWork
|
||||||
|
OVHcloud panel
|
||||||
|
Pro+ subscription
|
||||||
|
SAP BTP Cockpit
|
||||||
|
Scaleway Console IAM settings
|
||||||
|
Scaleway Generative APIs
|
||||||
|
SDK documentation
|
||||||
|
Sentry MCP server
|
||||||
|
shell API
|
||||||
|
Together AI console
|
||||||
|
Tokyonight
|
||||||
|
Unified Billing
|
||||||
|
Venice AI console
|
||||||
|
Vercel dashboard
|
||||||
|
WezTerm
|
||||||
|
Windows Subsystem for Linux (WSL)
|
||||||
|
WSL
|
||||||
|
WSL (Windows Subsystem for Linux)
|
||||||
|
WSL extension
|
||||||
|
xAI console
|
||||||
|
Z.AI API console
|
||||||
|
Zed
|
||||||
|
ZenMux dashboard
|
||||||
|
Zod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acronyms and initialisms
|
||||||
|
|
||||||
|
```text
|
||||||
|
ACP
|
||||||
|
AGENTS
|
||||||
|
AI
|
||||||
|
AI21
|
||||||
|
ANSI
|
||||||
|
API
|
||||||
|
AST
|
||||||
|
AWS
|
||||||
|
BTP
|
||||||
|
CD
|
||||||
|
CDN
|
||||||
|
CI
|
||||||
|
CLI
|
||||||
|
CMD
|
||||||
|
CORS
|
||||||
|
DEBUG
|
||||||
|
EKS
|
||||||
|
ERROR
|
||||||
|
FAQ
|
||||||
|
GLM
|
||||||
|
GNOME
|
||||||
|
GPT
|
||||||
|
HTML
|
||||||
|
HTTP
|
||||||
|
HTTPS
|
||||||
|
IAM
|
||||||
|
ID
|
||||||
|
IDE
|
||||||
|
INFO
|
||||||
|
IO
|
||||||
|
IP
|
||||||
|
IRSA
|
||||||
|
JS
|
||||||
|
JSON
|
||||||
|
JSONC
|
||||||
|
K2
|
||||||
|
LLM
|
||||||
|
LM
|
||||||
|
LSP
|
||||||
|
M2
|
||||||
|
MCP
|
||||||
|
MR
|
||||||
|
NET
|
||||||
|
NPM
|
||||||
|
NTLM
|
||||||
|
OIDC
|
||||||
|
OS
|
||||||
|
PAT
|
||||||
|
PATH
|
||||||
|
PHP
|
||||||
|
PR
|
||||||
|
PTY
|
||||||
|
README
|
||||||
|
RFC
|
||||||
|
RPC
|
||||||
|
SAP
|
||||||
|
SDK
|
||||||
|
SKILL
|
||||||
|
SSE
|
||||||
|
SSO
|
||||||
|
TS
|
||||||
|
TTY
|
||||||
|
TUI
|
||||||
|
UI
|
||||||
|
URL
|
||||||
|
US
|
||||||
|
UX
|
||||||
|
VCS
|
||||||
|
VPC
|
||||||
|
VPN
|
||||||
|
VS
|
||||||
|
WARN
|
||||||
|
WSL
|
||||||
|
X11
|
||||||
|
YAML
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code identifiers used in prose (CamelCase, mixedCase)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apiKey
|
||||||
|
AppleScript
|
||||||
|
AssistantMessage
|
||||||
|
baseURL
|
||||||
|
BurntSushi
|
||||||
|
ChatGPT
|
||||||
|
ClangFormat
|
||||||
|
CodeCompanion
|
||||||
|
CodeNomad
|
||||||
|
DeepSeek
|
||||||
|
DefaultV2
|
||||||
|
FileContent
|
||||||
|
FileDiff
|
||||||
|
FileNode
|
||||||
|
fineGrained
|
||||||
|
FormatterStatus
|
||||||
|
GitHub
|
||||||
|
GitLab
|
||||||
|
iTerm2
|
||||||
|
JavaScript
|
||||||
|
JetBrains
|
||||||
|
macOS
|
||||||
|
mDNS
|
||||||
|
MiniMax
|
||||||
|
NeuralNomadsAI
|
||||||
|
NickvanDyke
|
||||||
|
NoeFabris
|
||||||
|
OpenAI
|
||||||
|
OpenAPI
|
||||||
|
OpenChamber
|
||||||
|
OpenCode
|
||||||
|
OpenRouter
|
||||||
|
OpenTUI
|
||||||
|
OpenWork
|
||||||
|
ownUserPermissions
|
||||||
|
PowerShell
|
||||||
|
ProviderAuthAuthorization
|
||||||
|
ProviderAuthMethod
|
||||||
|
ProviderInitError
|
||||||
|
SessionStatus
|
||||||
|
TabItem
|
||||||
|
tokenType
|
||||||
|
ToolIDs
|
||||||
|
ToolList
|
||||||
|
TypeScript
|
||||||
|
typesUrl
|
||||||
|
UserMessage
|
||||||
|
VcsInfo
|
||||||
|
WebView2
|
||||||
|
WezTerm
|
||||||
|
xAI
|
||||||
|
ZenMux
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenCode CLI commands (as shown in docs)
|
||||||
|
|
||||||
|
```text
|
||||||
|
opencode
|
||||||
|
opencode [project]
|
||||||
|
opencode /path/to/project
|
||||||
|
opencode acp
|
||||||
|
opencode agent [command]
|
||||||
|
opencode agent create
|
||||||
|
opencode agent list
|
||||||
|
opencode attach [url]
|
||||||
|
opencode attach http://10.20.30.40:4096
|
||||||
|
opencode attach http://localhost:4096
|
||||||
|
opencode auth [command]
|
||||||
|
opencode auth list
|
||||||
|
opencode auth login
|
||||||
|
opencode auth logout
|
||||||
|
opencode auth ls
|
||||||
|
opencode export [sessionID]
|
||||||
|
opencode github [command]
|
||||||
|
opencode github install
|
||||||
|
opencode github run
|
||||||
|
opencode import <file>
|
||||||
|
opencode import https://opncd.ai/s/abc123
|
||||||
|
opencode import session.json
|
||||||
|
opencode mcp [command]
|
||||||
|
opencode mcp add
|
||||||
|
opencode mcp auth [name]
|
||||||
|
opencode mcp auth list
|
||||||
|
opencode mcp auth ls
|
||||||
|
opencode mcp auth my-oauth-server
|
||||||
|
opencode mcp auth sentry
|
||||||
|
opencode mcp debug <name>
|
||||||
|
opencode mcp debug my-oauth-server
|
||||||
|
opencode mcp list
|
||||||
|
opencode mcp logout [name]
|
||||||
|
opencode mcp logout my-oauth-server
|
||||||
|
opencode mcp ls
|
||||||
|
opencode models --refresh
|
||||||
|
opencode models [provider]
|
||||||
|
opencode models anthropic
|
||||||
|
opencode run [message..]
|
||||||
|
opencode run Explain the use of context in Go
|
||||||
|
opencode serve
|
||||||
|
opencode serve --cors http://localhost:5173 --cors https://app.example.com
|
||||||
|
opencode serve --hostname 0.0.0.0 --port 4096
|
||||||
|
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||||
|
opencode session [command]
|
||||||
|
opencode session list
|
||||||
|
opencode stats
|
||||||
|
opencode uninstall
|
||||||
|
opencode upgrade
|
||||||
|
opencode upgrade [target]
|
||||||
|
opencode upgrade v0.1.48
|
||||||
|
opencode web
|
||||||
|
opencode web --cors https://example.com
|
||||||
|
opencode web --hostname 0.0.0.0
|
||||||
|
opencode web --mdns
|
||||||
|
opencode web --mdns --mdns-domain myproject.local
|
||||||
|
opencode web --port 4096
|
||||||
|
opencode web --port 4096 --hostname 0.0.0.0
|
||||||
|
opencode.server.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slash commands and routes
|
||||||
|
|
||||||
|
```text
|
||||||
|
/agent
|
||||||
|
/auth/:id
|
||||||
|
/clear
|
||||||
|
/command
|
||||||
|
/config
|
||||||
|
/config/providers
|
||||||
|
/connect
|
||||||
|
/continue
|
||||||
|
/doc
|
||||||
|
/editor
|
||||||
|
/event
|
||||||
|
/experimental/tool?provider=<p>&model=<m>
|
||||||
|
/experimental/tool/ids
|
||||||
|
/export
|
||||||
|
/file?path=<path>
|
||||||
|
/file/content?path=<p>
|
||||||
|
/file/status
|
||||||
|
/find?pattern=<pat>
|
||||||
|
/find/file
|
||||||
|
/find/file?query=<q>
|
||||||
|
/find/symbol?query=<q>
|
||||||
|
/formatter
|
||||||
|
/global/event
|
||||||
|
/global/health
|
||||||
|
/help
|
||||||
|
/init
|
||||||
|
/instance/dispose
|
||||||
|
/log
|
||||||
|
/lsp
|
||||||
|
/mcp
|
||||||
|
/mnt/
|
||||||
|
/mnt/c/
|
||||||
|
/mnt/d/
|
||||||
|
/models
|
||||||
|
/oc
|
||||||
|
/opencode
|
||||||
|
/path
|
||||||
|
/project
|
||||||
|
/project/current
|
||||||
|
/provider
|
||||||
|
/provider/{id}/oauth/authorize
|
||||||
|
/provider/{id}/oauth/callback
|
||||||
|
/provider/auth
|
||||||
|
/q
|
||||||
|
/quit
|
||||||
|
/redo
|
||||||
|
/resume
|
||||||
|
/session
|
||||||
|
/session/:id
|
||||||
|
/session/:id/abort
|
||||||
|
/session/:id/children
|
||||||
|
/session/:id/command
|
||||||
|
/session/:id/diff
|
||||||
|
/session/:id/fork
|
||||||
|
/session/:id/init
|
||||||
|
/session/:id/message
|
||||||
|
/session/:id/message/:messageID
|
||||||
|
/session/:id/permissions/:permissionID
|
||||||
|
/session/:id/prompt_async
|
||||||
|
/session/:id/revert
|
||||||
|
/session/:id/share
|
||||||
|
/session/:id/shell
|
||||||
|
/session/:id/summarize
|
||||||
|
/session/:id/todo
|
||||||
|
/session/:id/unrevert
|
||||||
|
/session/status
|
||||||
|
/share
|
||||||
|
/summarize
|
||||||
|
/theme
|
||||||
|
/tui
|
||||||
|
/tui/append-prompt
|
||||||
|
/tui/clear-prompt
|
||||||
|
/tui/control/next
|
||||||
|
/tui/control/response
|
||||||
|
/tui/execute-command
|
||||||
|
/tui/open-help
|
||||||
|
/tui/open-models
|
||||||
|
/tui/open-sessions
|
||||||
|
/tui/open-themes
|
||||||
|
/tui/show-toast
|
||||||
|
/tui/submit-prompt
|
||||||
|
/undo
|
||||||
|
/Users/username
|
||||||
|
/Users/username/projects/*
|
||||||
|
/vcs
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI flags and short options
|
||||||
|
|
||||||
|
```text
|
||||||
|
--agent
|
||||||
|
--attach
|
||||||
|
--command
|
||||||
|
--continue
|
||||||
|
--cors
|
||||||
|
--cwd
|
||||||
|
--days
|
||||||
|
--dir
|
||||||
|
--dry-run
|
||||||
|
--event
|
||||||
|
--file
|
||||||
|
--force
|
||||||
|
--fork
|
||||||
|
--format
|
||||||
|
--help
|
||||||
|
--hostname
|
||||||
|
--hostname 0.0.0.0
|
||||||
|
--keep-config
|
||||||
|
--keep-data
|
||||||
|
--log-level
|
||||||
|
--max-count
|
||||||
|
--mdns
|
||||||
|
--mdns-domain
|
||||||
|
--method
|
||||||
|
--model
|
||||||
|
--models
|
||||||
|
--port
|
||||||
|
--print-logs
|
||||||
|
--project
|
||||||
|
--prompt
|
||||||
|
--refresh
|
||||||
|
--session
|
||||||
|
--share
|
||||||
|
--title
|
||||||
|
--token
|
||||||
|
--tools
|
||||||
|
--verbose
|
||||||
|
--version
|
||||||
|
--wait
|
||||||
|
|
||||||
|
-c
|
||||||
|
-d
|
||||||
|
-f
|
||||||
|
-h
|
||||||
|
-m
|
||||||
|
-n
|
||||||
|
-s
|
||||||
|
-v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
```text
|
||||||
|
AI_API_URL
|
||||||
|
AI_FLOW_CONTEXT
|
||||||
|
AI_FLOW_EVENT
|
||||||
|
AI_FLOW_INPUT
|
||||||
|
AICORE_DEPLOYMENT_ID
|
||||||
|
AICORE_RESOURCE_GROUP
|
||||||
|
AICORE_SERVICE_KEY
|
||||||
|
ANTHROPIC_API_KEY
|
||||||
|
AWS_ACCESS_KEY_ID
|
||||||
|
AWS_BEARER_TOKEN_BEDROCK
|
||||||
|
AWS_PROFILE
|
||||||
|
AWS_REGION
|
||||||
|
AWS_ROLE_ARN
|
||||||
|
AWS_SECRET_ACCESS_KEY
|
||||||
|
AWS_WEB_IDENTITY_TOKEN_FILE
|
||||||
|
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||||
|
AZURE_RESOURCE_NAME
|
||||||
|
CI_PROJECT_DIR
|
||||||
|
CI_SERVER_FQDN
|
||||||
|
CI_WORKLOAD_REF
|
||||||
|
CLOUDFLARE_ACCOUNT_ID
|
||||||
|
CLOUDFLARE_API_TOKEN
|
||||||
|
CLOUDFLARE_GATEWAY_ID
|
||||||
|
CONTEXT7_API_KEY
|
||||||
|
GITHUB_TOKEN
|
||||||
|
GITLAB_AI_GATEWAY_URL
|
||||||
|
GITLAB_HOST
|
||||||
|
GITLAB_INSTANCE_URL
|
||||||
|
GITLAB_OAUTH_CLIENT_ID
|
||||||
|
GITLAB_TOKEN
|
||||||
|
GITLAB_TOKEN_OPENCODE
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS
|
||||||
|
GOOGLE_CLOUD_PROJECT
|
||||||
|
HTTP_PROXY
|
||||||
|
HTTPS_PROXY
|
||||||
|
K2_
|
||||||
|
MY_API_KEY
|
||||||
|
MY_ENV_VAR
|
||||||
|
MY_MCP_CLIENT_ID
|
||||||
|
MY_MCP_CLIENT_SECRET
|
||||||
|
NO_PROXY
|
||||||
|
NODE_ENV
|
||||||
|
NODE_EXTRA_CA_CERTS
|
||||||
|
NPM_AUTH_TOKEN
|
||||||
|
OC_ALLOW_WAYLAND
|
||||||
|
OPENCODE_API_KEY
|
||||||
|
OPENCODE_AUTH_JSON
|
||||||
|
OPENCODE_AUTO_SHARE
|
||||||
|
OPENCODE_CLIENT
|
||||||
|
OPENCODE_CONFIG
|
||||||
|
OPENCODE_CONFIG_CONTENT
|
||||||
|
OPENCODE_CONFIG_DIR
|
||||||
|
OPENCODE_DISABLE_AUTOCOMPACT
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE
|
||||||
|
OPENCODE_DISABLE_CLAUDE_CODE
|
||||||
|
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
||||||
|
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||||
|
OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD
|
||||||
|
OPENCODE_DISABLE_MODELS_FETCH
|
||||||
|
OPENCODE_DISABLE_PRUNE
|
||||||
|
OPENCODE_DISABLE_TERMINAL_TITLE
|
||||||
|
OPENCODE_ENABLE_EXA
|
||||||
|
OPENCODE_ENABLE_EXPERIMENTAL_MODELS
|
||||||
|
OPENCODE_EXPERIMENTAL
|
||||||
|
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||||
|
OPENCODE_EXPERIMENTAL_EXA
|
||||||
|
OPENCODE_EXPERIMENTAL_FILEWATCHER
|
||||||
|
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY
|
||||||
|
OPENCODE_EXPERIMENTAL_LSP_TOOL
|
||||||
|
OPENCODE_EXPERIMENTAL_LSP_TY
|
||||||
|
OPENCODE_EXPERIMENTAL_MARKDOWN
|
||||||
|
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||||
|
OPENCODE_EXPERIMENTAL_OXFMT
|
||||||
|
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||||
|
OPENCODE_FAKE_VCS
|
||||||
|
OPENCODE_GIT_BASH_PATH
|
||||||
|
OPENCODE_MODEL
|
||||||
|
OPENCODE_MODELS_URL
|
||||||
|
OPENCODE_PERMISSION
|
||||||
|
OPENCODE_PORT
|
||||||
|
OPENCODE_SERVER_PASSWORD
|
||||||
|
OPENCODE_SERVER_USERNAME
|
||||||
|
PROJECT_ROOT
|
||||||
|
RESOURCE_NAME
|
||||||
|
RUST_LOG
|
||||||
|
VARIABLE_NAME
|
||||||
|
VERTEX_LOCATION
|
||||||
|
XDG_CONFIG_HOME
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package/module identifiers
|
||||||
|
|
||||||
|
```text
|
||||||
|
../../../config.mjs
|
||||||
|
@astrojs/starlight/components
|
||||||
|
@opencode-ai/plugin
|
||||||
|
@opencode-ai/sdk
|
||||||
|
path
|
||||||
|
shescape
|
||||||
|
zod
|
||||||
|
|
||||||
|
@
|
||||||
|
@ai-sdk/anthropic
|
||||||
|
@ai-sdk/cerebras
|
||||||
|
@ai-sdk/google
|
||||||
|
@ai-sdk/openai
|
||||||
|
@ai-sdk/openai-compatible
|
||||||
|
@File#L37-42
|
||||||
|
@modelcontextprotocol/server-everything
|
||||||
|
@opencode
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub owner/repo slugs referenced in docs
|
||||||
|
|
||||||
|
```text
|
||||||
|
24601/opencode-zellij-namer
|
||||||
|
angristan/opencode-wakatime
|
||||||
|
anomalyco/opencode
|
||||||
|
apps/opencode-agent
|
||||||
|
athal7/opencode-devcontainers
|
||||||
|
awesome-opencode/awesome-opencode
|
||||||
|
backnotprop/plannotator
|
||||||
|
ben-vargas/ai-sdk-provider-opencode-sdk
|
||||||
|
btriapitsyn/openchamber
|
||||||
|
BurntSushi/ripgrep
|
||||||
|
Cluster444/agentic
|
||||||
|
code-yeongyu/oh-my-opencode
|
||||||
|
darrenhinde/opencode-agents
|
||||||
|
different-ai/opencode-scheduler
|
||||||
|
different-ai/openwork
|
||||||
|
features/copilot
|
||||||
|
folke/tokyonight.nvim
|
||||||
|
franlol/opencode-md-table-formatter
|
||||||
|
ggml-org/llama.cpp
|
||||||
|
ghoulr/opencode-websearch-cited.git
|
||||||
|
H2Shami/opencode-helicone-session
|
||||||
|
hosenur/portal
|
||||||
|
jamesmurdza/daytona
|
||||||
|
jenslys/opencode-gemini-auth
|
||||||
|
JRedeker/opencode-morph-fast-apply
|
||||||
|
JRedeker/opencode-shell-strategy
|
||||||
|
kdcokenny/ocx
|
||||||
|
kdcokenny/opencode-background-agents
|
||||||
|
kdcokenny/opencode-notify
|
||||||
|
kdcokenny/opencode-workspace
|
||||||
|
kdcokenny/opencode-worktree
|
||||||
|
login/device
|
||||||
|
mohak34/opencode-notifier
|
||||||
|
morhetz/gruvbox
|
||||||
|
mtymek/opencode-obsidian
|
||||||
|
NeuralNomadsAI/CodeNomad
|
||||||
|
nick-vi/opencode-type-inject
|
||||||
|
NickvanDyke/opencode.nvim
|
||||||
|
NoeFabris/opencode-antigravity-auth
|
||||||
|
nordtheme/nord
|
||||||
|
numman-ali/opencode-openai-codex-auth
|
||||||
|
olimorris/codecompanion.nvim
|
||||||
|
panta82/opencode-notificator
|
||||||
|
rebelot/kanagawa.nvim
|
||||||
|
remorses/kimaki
|
||||||
|
sainnhe/everforest
|
||||||
|
shekohex/opencode-google-antigravity-auth
|
||||||
|
shekohex/opencode-pty.git
|
||||||
|
spoons-and-mirrors/subtask2
|
||||||
|
sudo-tee/opencode.nvim
|
||||||
|
supermemoryai/opencode-supermemory
|
||||||
|
Tarquinen/opencode-dynamic-context-pruning
|
||||||
|
Th3Whit3Wolf/one-nvim
|
||||||
|
upstash/context7
|
||||||
|
vtemian/micode
|
||||||
|
vtemian/octto
|
||||||
|
yetone/avante.nvim
|
||||||
|
zenobi-us/opencode-plugin-template
|
||||||
|
zenobi-us/opencode-skillful
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paths, filenames, globs, and URLs
|
||||||
|
|
||||||
|
```text
|
||||||
|
./.opencode/themes/*.json
|
||||||
|
./<project-slug>/storage/
|
||||||
|
./config/#custom-directory
|
||||||
|
./global/storage/
|
||||||
|
.agents/skills/*/SKILL.md
|
||||||
|
.agents/skills/<name>/SKILL.md
|
||||||
|
.clang-format
|
||||||
|
.claude
|
||||||
|
.claude/skills
|
||||||
|
.claude/skills/*/SKILL.md
|
||||||
|
.claude/skills/<name>/SKILL.md
|
||||||
|
.env
|
||||||
|
.github/workflows/opencode.yml
|
||||||
|
.gitignore
|
||||||
|
.gitlab-ci.yml
|
||||||
|
.ignore
|
||||||
|
.NET SDK
|
||||||
|
.npmrc
|
||||||
|
.ocamlformat
|
||||||
|
.opencode
|
||||||
|
.opencode/
|
||||||
|
.opencode/agents/
|
||||||
|
.opencode/commands/
|
||||||
|
.opencode/commands/test.md
|
||||||
|
.opencode/modes/
|
||||||
|
.opencode/plans/*.md
|
||||||
|
.opencode/plugins/
|
||||||
|
.opencode/skills/<name>/SKILL.md
|
||||||
|
.opencode/skills/git-release/SKILL.md
|
||||||
|
.opencode/tools/
|
||||||
|
.well-known/opencode
|
||||||
|
{ type: "raw" \| "patch", content: string }
|
||||||
|
{file:path/to/file}
|
||||||
|
**/*.js
|
||||||
|
%USERPROFILE%/intelephense/license.txt
|
||||||
|
%USERPROFILE%\.cache\opencode
|
||||||
|
%USERPROFILE%\.config\opencode\opencode.jsonc
|
||||||
|
%USERPROFILE%\.config\opencode\plugins
|
||||||
|
%USERPROFILE%\.local\share\opencode
|
||||||
|
%USERPROFILE%\.local\share\opencode\log
|
||||||
|
<project-root>/.opencode/themes/*.json
|
||||||
|
<providerId>/<modelId>
|
||||||
|
<your-project>/.opencode/plugins/
|
||||||
|
~
|
||||||
|
~/...
|
||||||
|
~/.agents/skills/*/SKILL.md
|
||||||
|
~/.agents/skills/<name>/SKILL.md
|
||||||
|
~/.aws/credentials
|
||||||
|
~/.bashrc
|
||||||
|
~/.cache/opencode
|
||||||
|
~/.cache/opencode/node_modules/
|
||||||
|
~/.claude/CLAUDE.md
|
||||||
|
~/.claude/skills/
|
||||||
|
~/.claude/skills/*/SKILL.md
|
||||||
|
~/.claude/skills/<name>/SKILL.md
|
||||||
|
~/.config/opencode
|
||||||
|
~/.config/opencode/AGENTS.md
|
||||||
|
~/.config/opencode/agents/
|
||||||
|
~/.config/opencode/commands/
|
||||||
|
~/.config/opencode/modes/
|
||||||
|
~/.config/opencode/opencode.json
|
||||||
|
~/.config/opencode/opencode.jsonc
|
||||||
|
~/.config/opencode/plugins/
|
||||||
|
~/.config/opencode/skills/*/SKILL.md
|
||||||
|
~/.config/opencode/skills/<name>/SKILL.md
|
||||||
|
~/.config/opencode/themes/*.json
|
||||||
|
~/.config/opencode/tools/
|
||||||
|
~/.config/zed/settings.json
|
||||||
|
~/.local/share
|
||||||
|
~/.local/share/opencode/
|
||||||
|
~/.local/share/opencode/auth.json
|
||||||
|
~/.local/share/opencode/log/
|
||||||
|
~/.local/share/opencode/mcp-auth.json
|
||||||
|
~/.local/share/opencode/opencode.jsonc
|
||||||
|
~/.npmrc
|
||||||
|
~/.zshrc
|
||||||
|
~/code/
|
||||||
|
~/Library/Application Support
|
||||||
|
~/projects/*
|
||||||
|
~/projects/personal/
|
||||||
|
${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts
|
||||||
|
$HOME/intelephense/license.txt
|
||||||
|
$HOME/projects/*
|
||||||
|
$XDG_CONFIG_HOME/opencode/themes/*.json
|
||||||
|
agent/
|
||||||
|
agents/
|
||||||
|
build/
|
||||||
|
commands/
|
||||||
|
dist/
|
||||||
|
http://<wsl-ip>:4096
|
||||||
|
http://127.0.0.1:8080/callback
|
||||||
|
http://localhost:<port>
|
||||||
|
http://localhost:4096
|
||||||
|
http://localhost:4096/doc
|
||||||
|
https://app.example.com
|
||||||
|
https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/
|
||||||
|
https://opencode.ai/zen/v1/chat/completions
|
||||||
|
https://opencode.ai/zen/v1/messages
|
||||||
|
https://opencode.ai/zen/v1/models/gemini-3-flash
|
||||||
|
https://opencode.ai/zen/v1/models/gemini-3-pro
|
||||||
|
https://opencode.ai/zen/v1/responses
|
||||||
|
https://RESOURCE_NAME.openai.azure.com/
|
||||||
|
laravel/pint
|
||||||
|
log/
|
||||||
|
model: "anthropic/claude-sonnet-4-5"
|
||||||
|
modes/
|
||||||
|
node_modules/
|
||||||
|
openai/gpt-4.1
|
||||||
|
opencode.ai/config.json
|
||||||
|
opencode/<model-id>
|
||||||
|
opencode/gpt-5.1-codex
|
||||||
|
opencode/gpt-5.2-codex
|
||||||
|
opencode/kimi-k2
|
||||||
|
openrouter/google/gemini-2.5-flash
|
||||||
|
opncd.ai/s/<share-id>
|
||||||
|
packages/*/AGENTS.md
|
||||||
|
plugins/
|
||||||
|
project/
|
||||||
|
provider_id/model_id
|
||||||
|
provider/model
|
||||||
|
provider/model-id
|
||||||
|
rm -rf ~/.cache/opencode
|
||||||
|
skills/
|
||||||
|
skills/*/SKILL.md
|
||||||
|
src/**/*.ts
|
||||||
|
themes/
|
||||||
|
tools/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keybind strings
|
||||||
|
|
||||||
|
```text
|
||||||
|
alt+b
|
||||||
|
Alt+Ctrl+K
|
||||||
|
alt+d
|
||||||
|
alt+f
|
||||||
|
Cmd+Esc
|
||||||
|
Cmd+Option+K
|
||||||
|
Cmd+Shift+Esc
|
||||||
|
Cmd+Shift+G
|
||||||
|
Cmd+Shift+P
|
||||||
|
ctrl+a
|
||||||
|
ctrl+b
|
||||||
|
ctrl+d
|
||||||
|
ctrl+e
|
||||||
|
Ctrl+Esc
|
||||||
|
ctrl+f
|
||||||
|
ctrl+g
|
||||||
|
ctrl+k
|
||||||
|
Ctrl+Shift+Esc
|
||||||
|
Ctrl+Shift+P
|
||||||
|
ctrl+t
|
||||||
|
ctrl+u
|
||||||
|
ctrl+w
|
||||||
|
ctrl+x
|
||||||
|
DELETE
|
||||||
|
Shift+Enter
|
||||||
|
WIN+R
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model ID strings referenced
|
||||||
|
|
||||||
|
```text
|
||||||
|
{env:OPENCODE_MODEL}
|
||||||
|
anthropic/claude-3-5-sonnet-20241022
|
||||||
|
anthropic/claude-haiku-4-20250514
|
||||||
|
anthropic/claude-haiku-4-5
|
||||||
|
anthropic/claude-sonnet-4-20250514
|
||||||
|
anthropic/claude-sonnet-4-5
|
||||||
|
gitlab/duo-chat-haiku-4-5
|
||||||
|
lmstudio/google/gemma-3n-e4b
|
||||||
|
openai/gpt-4.1
|
||||||
|
openai/gpt-5
|
||||||
|
opencode/gpt-5.1-codex
|
||||||
|
opencode/gpt-5.2-codex
|
||||||
|
opencode/kimi-k2
|
||||||
|
openrouter/google/gemini-2.5-flash
|
||||||
|
```
|
||||||
@@ -16,15 +16,12 @@ wip:
|
|||||||
|
|
||||||
For anything in the packages/web use the docs: prefix.
|
For anything in the packages/web use the docs: prefix.
|
||||||
|
|
||||||
For anything in the packages/app use the ignore: prefix.
|
|
||||||
|
|
||||||
prefer to explain WHY something was done from an end user perspective instead of
|
prefer to explain WHY something was done from an end user perspective instead of
|
||||||
WHAT was done.
|
WHAT was done.
|
||||||
|
|
||||||
do not do generic messages like "improved agent experience" be very specific
|
do not do generic messages like "improved agent experience" be very specific
|
||||||
about what user facing changes were made
|
about what user facing changes were made
|
||||||
|
|
||||||
if there are changes do a git pull --rebase
|
|
||||||
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
|
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
|
||||||
|
|
||||||
## GIT DIFF
|
## GIT DIFF
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ
|
|||||||
- Decode tool stderr with `Bun.readableStreamToText`.
|
- Decode tool stderr with `Bun.readableStreamToText`.
|
||||||
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
||||||
|
|
||||||
|
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
|
||||||
|
Use Filesystem.exists(...) instead if path can be file or directory
|
||||||
|
|
||||||
## Quick checklist
|
## Quick checklist
|
||||||
|
|
||||||
- Use Bun APIs first.
|
- Use Bun APIs first.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Use this tool to assign and/or label a Github issue.
|
Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
You can assign the following users:
|
You can assign the following users:
|
||||||
- thdxr
|
- thdxr
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
github-policies:
|
||||||
|
runners:
|
||||||
|
allowed_groups:
|
||||||
|
- "GitHub Actions"
|
||||||
|
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
||||||
@@ -110,3 +110,4 @@ const table = sqliteTable("session", {
|
|||||||
|
|
||||||
- Avoid mocks as much as possible
|
- Avoid mocks as much as possible
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Test actual implementation, do not duplicate logic into tests
|
||||||
|
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||||
|
|||||||
@@ -258,3 +258,49 @@ These are not strictly enforced, they are just general guidelines:
|
|||||||
## Feature Requests
|
## Feature Requests
|
||||||
|
|
||||||
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
|
||||||
|
|
||||||
|
## Trust & Vouch System
|
||||||
|
|
||||||
|
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
- **Vouched users** are explicitly trusted contributors.
|
||||||
|
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
|
||||||
|
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
|
||||||
|
|
||||||
|
### For maintainers
|
||||||
|
|
||||||
|
Collaborators with write access can manage the vouch list by commenting on any issue:
|
||||||
|
|
||||||
|
- `vouch` — vouch for the issue author
|
||||||
|
- `vouch @username` — vouch for a specific user
|
||||||
|
- `denounce` — denounce the issue author
|
||||||
|
- `denounce @username` — denounce a specific user
|
||||||
|
- `denounce @username <reason>` — denounce with a reason
|
||||||
|
- `unvouch` / `unvouch @username` — remove someone from the list
|
||||||
|
|
||||||
|
Changes are committed automatically to `.github/VOUCHED.td`.
|
||||||
|
|
||||||
|
### Denouncement policy
|
||||||
|
|
||||||
|
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
|
||||||
|
|
||||||
|
## Issue Requirements
|
||||||
|
|
||||||
|
All issues **must** use one of our issue templates:
|
||||||
|
|
||||||
|
- **Bug report** — for reporting bugs (requires a description)
|
||||||
|
- **Feature request** — for suggesting enhancements (requires verification checkbox and description)
|
||||||
|
- **Question** — for asking questions (requires the question)
|
||||||
|
|
||||||
|
Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed.
|
||||||
|
|
||||||
|
Issues may be flagged for:
|
||||||
|
|
||||||
|
- Not using a template
|
||||||
|
- Required fields left empty or filled with placeholder text
|
||||||
|
- AI-generated walls of text
|
||||||
|
- Missing meaningful content
|
||||||
|
|
||||||
|
If you believe your issue was incorrectly flagged, let a maintainer know.
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
|
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
|
||||||
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
|
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # اي نظام
|
mise use -g opencode # اي نظام
|
||||||
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
|
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
|
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
|
||||||
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
|
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # qualquer sistema
|
mise use -g opencode # qualquer sistema
|
||||||
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
|
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
|
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
|
||||||
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
|
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Bilo koji OS
|
mise use -g opencode # Bilo koji OS
|
||||||
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
|
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
|
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
|
||||||
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
|
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # alle OS
|
mise use -g opencode # alle OS
|
||||||
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
|
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
|
||||||
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
|
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # jedes Betriebssystem
|
mise use -g opencode # jedes Betriebssystem
|
||||||
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
|
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
|
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
|
||||||
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
|
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # cualquier sistema
|
mise use -g opencode # cualquier sistema
|
||||||
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
|
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
|
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
|
||||||
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
|
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # n'importe quel OS
|
mise use -g opencode # n'importe quel OS
|
||||||
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
|
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
|
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
|
||||||
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
|
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Qualsiasi OS
|
mise use -g opencode # Qualsiasi OS
|
||||||
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo
|
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
|
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
|
||||||
brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め)
|
brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # どのOSでも
|
mise use -g opencode # どのOSでも
|
||||||
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
|
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
|
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
|
||||||
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
|
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 어떤 OS든
|
mise use -g opencode # 어떤 OS든
|
||||||
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
|
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||||
brew install opencode # macOS and Linux (official brew formula, updated less)
|
brew install opencode # macOS and Linux (official brew formula, updated less)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Any OS
|
mise use -g opencode # Any OS
|
||||||
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
|
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
|
||||||
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
|
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # alle OS
|
mise use -g opencode # alle OS
|
||||||
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
|
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
|
||||||
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
|
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # dowolny system
|
mise use -g opencode # dowolny system
|
||||||
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
|
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
|
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
|
||||||
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
|
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # любая ОС
|
mise use -g opencode # любая ОС
|
||||||
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
|
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
|
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
|
||||||
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
|
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
|
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
|
||||||
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
|
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
||||||
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # Tüm işletim sistemleri
|
mise use -g opencode # Tüm işletim sistemleri
|
||||||
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
|
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
|
||||||
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
|
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 任意系统
|
mise use -g opencode # 任意系统
|
||||||
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
|
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-1
@@ -50,7 +50,8 @@ scoop install opencode # Windows
|
|||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
|
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
|
||||||
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
|
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
|
||||||
paru -S opencode-bin # Arch Linux
|
sudo pacman -S opencode # Arch Linux (Stable)
|
||||||
|
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
||||||
mise use -g opencode # 任何作業系統
|
mise use -g opencode # 任何作業系統
|
||||||
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
|
||||||
```
|
```
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770073757,
|
"lastModified": 1770812194,
|
||||||
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
|
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
|
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+1
-1
@@ -275,7 +275,7 @@ async function assertOpencodeConnected() {
|
|||||||
body: {
|
body: {
|
||||||
service: "github-workflow",
|
service: "github-workflow",
|
||||||
level: "info",
|
level: "info",
|
||||||
message: "Prepare to react to Github Workflow event",
|
message: "Prepare to react to GitHub Workflow event",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
connected = true
|
connected = true
|
||||||
|
|||||||
+5
-9
@@ -166,14 +166,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
|||||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||||
|
|
||||||
let logProcessor
|
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||||
if ($app.stage === "production" || $app.stage === "frank") {
|
handler: "packages/console/function/src/log-processor.ts",
|
||||||
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
|
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||||
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
})
|
||||||
handler: "packages/console/function/src/log-processor.ts",
|
|
||||||
link: [HONEYCOMB_API_KEY],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
new sst.cloudflare.x.SolidStart("Console", {
|
new sst.cloudflare.x.SolidStart("Console", {
|
||||||
domain,
|
domain,
|
||||||
@@ -211,7 +207,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
transform: {
|
transform: {
|
||||||
worker: {
|
worker: {
|
||||||
placement: { mode: "smart" },
|
placement: { mode: "smart" },
|
||||||
tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
|
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ else
|
|||||||
needs_baseline=false
|
needs_baseline=false
|
||||||
if [ "$arch" = "x64" ]; then
|
if [ "$arch" = "x64" ]; then
|
||||||
if [ "$os" = "linux" ]; then
|
if [ "$os" = "linux" ]; then
|
||||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||||
needs_baseline=true
|
needs_baseline=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -141,6 +141,20 @@ else
|
|||||||
needs_baseline=true
|
needs_baseline=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "windows" ]; then
|
||||||
|
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
|
||||||
|
out=""
|
||||||
|
if command -v powershell.exe >/dev/null 2>&1; then
|
||||||
|
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
|
||||||
|
elif command -v pwsh >/dev/null 2>&1; then
|
||||||
|
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||||
|
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
|
||||||
|
needs_baseline=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
target="$os-$arch"
|
target="$os-$arch"
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"keep": {
|
|
||||||
"days": true,
|
|
||||||
"amount": 14
|
|
||||||
},
|
|
||||||
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"date": 1759827172859,
|
|
||||||
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
|
|
||||||
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"hashType": "sha256"
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
|
||||||
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
|
|
||||||
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
|
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-1IpZnnN6+acCcV0AgO4OVdvgf4TFBFId5dms5W5ecA0=",
|
"x86_64-linux": "sha256-5pgd2xuvIIkTbIOGIdK5MIXo6O9qRpvk1RKQZ1e1R+8=",
|
||||||
"aarch64-linux": "sha256-TKmPhXokOav46ucP9AFwHGgKmB9CdGCcUtwqUtLlzG4=",
|
"aarch64-linux": "sha256-FZiHwihM4b82ipQ9XfW08X+sd5CvZhx/+pU/8X1zsns=",
|
||||||
"aarch64-darwin": "sha256-xJQuw3+QHYnlClDrafQKPQyR+aqyAEofvYYjCowHDps=",
|
"aarch64-darwin": "sha256-iZv0w1NthV53pY5uvuf3JlI14GeKmCu7WHwGSRdEQeM=",
|
||||||
"x86_64-darwin": "sha256-ywU3Oka2QNGKu/HI+//3bdYJ9qo1N7K5Wr2vpTgSM/g="
|
"x86_64-darwin": "sha256-c3Zm3P1goFPgg3vNAZPMFOhHX/gyTmsCN/PKbGO/v0E="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ stdenvNoCC.mkDerivation {
|
|||||||
../bun.lock
|
../bun.lock
|
||||||
../package.json
|
../package.json
|
||||||
../patches
|
../patches
|
||||||
../install
|
../install # required by desktop build (cli.rs include_str!)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-4
@@ -4,7 +4,7 @@
|
|||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.8",
|
"packageManager": "bun@1.3.9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@types/bun": "1.3.8",
|
"@types/bun": "1.3.9",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
@@ -35,11 +35,13 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.0.2",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
|
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||||
|
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -101,6 +103,7 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
[test]
|
[test]
|
||||||
|
root = "./src"
|
||||||
preload = ["./happydom.ts"]
|
preload = ["./happydom.ts"]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, withSession } from "../actions"
|
import { defocus, openSidebar, withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
@@ -40,3 +41,84 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const stamp = Date.now()
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
|
||||||
|
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
|
||||||
|
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
|
||||||
|
await gotoSession(a.id)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||||
|
await expect(second).toBeVisible()
|
||||||
|
await second.scrollIntoViewIfNeeded()
|
||||||
|
await second.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.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/${a.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||||
|
await expect(third).toBeVisible()
|
||||||
|
await third.scrollIntoViewIfNeeded()
|
||||||
|
await third.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await expect(forward).toBeVisible()
|
||||||
|
await expect(forward).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const stamp = Date.now()
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
|
||||||
|
await withSession(sdk, `e2e titlebar shortcuts 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()
|
||||||
|
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+[`)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+]`)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette, clickListItem } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
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")
|
||||||
|
|
||||||
await clickListItem(dialog, { keyStartsWith: "file:" })
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,49 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
test("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" })
|
||||||
const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
|
const panel = page.locator("#file-tree-panel")
|
||||||
|
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
|
||||||
|
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
|
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
await expect(treeTabs).toBeVisible()
|
await expect(treeTabs).toBeVisible()
|
||||||
|
|
||||||
await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click()
|
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
|
||||||
|
await expect(allTab).toBeVisible()
|
||||||
|
await allTab.click()
|
||||||
|
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
const node = (name: string) => treeTabs.getByRole("button", { name, exact: true })
|
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
|
||||||
|
await expect(tree).toBeVisible()
|
||||||
|
|
||||||
await expect(node("packages")).toBeVisible()
|
const expand = async (name: string) => {
|
||||||
await node("packages").click()
|
const folder = tree.getByRole("button", { name, exact: true }).first()
|
||||||
|
await expect(folder).toBeVisible()
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
|
||||||
|
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", "true")
|
||||||
|
}
|
||||||
|
|
||||||
await expect(node("app")).toBeVisible()
|
await expand("packages")
|
||||||
await node("app").click()
|
await expand("app")
|
||||||
|
await expand("src")
|
||||||
|
await expand("components")
|
||||||
|
|
||||||
await expect(node("src")).toBeVisible()
|
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
|
||||||
await node("src").click()
|
await expect(file).toBeVisible()
|
||||||
|
await file.click()
|
||||||
await expect(node("components")).toBeVisible()
|
|
||||||
await node("components").click()
|
|
||||||
|
|
||||||
await expect(node("file-tree.tsx")).toBeVisible()
|
|
||||||
await node("file-tree.tsx").click()
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
|
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
|
||||||
await expect(tab).toBeVisible()
|
await expect(tab).toBeVisible()
|
||||||
await tab.click()
|
await tab.click()
|
||||||
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
const code = page.locator('[data-component="code"]').first()
|
const code = page.locator('[data-component="code"]').first()
|
||||||
await expect(code.getByText("export default function FileTree")).toBeVisible()
|
await expect(code).toBeVisible()
|
||||||
|
await expect(code).toContainText("export default function FileTree")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette, clickListItem } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
const sep = process.platform === "win32" ? "\\" : "/"
|
await page.locator(promptSelector).click()
|
||||||
const file = ["packages", "app", "package.json"].join(sep)
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill(file)
|
await input.fill("package.json")
|
||||||
|
|
||||||
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
@@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
|||||||
|
|
||||||
const code = page.locator('[data-component="code"]').first()
|
const code = page.locator('[data-component="code"]').first()
|
||||||
await expect(code).toBeVisible()
|
await expect(code).toBeVisible()
|
||||||
await expect(code.getByText("@opencode-ai/app")).toBeVisible()
|
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
||||||
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
||||||
@@ -31,16 +31,15 @@ test("can close a project via hover card close button", async ({ page, withProje
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can close a project via project header more options menu", async ({ page, withProject }) => {
|
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherName = other.split("/").pop() ?? other
|
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(
|
||||||
async () => {
|
async ({ slug }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
@@ -49,21 +48,20 @@ test("can close a project via project header more options menu", async ({ page,
|
|||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const header = page
|
const menu = await openProjectMenu(page, otherSlug)
|
||||||
.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 clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => {
|
||||||
|
const pathname = new URL(page.url()).pathname
|
||||||
|
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
|
if (pathname === "/") return "home"
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
.toMatch(/^(project|home)$/)
|
||||||
|
|
||||||
|
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||||
await expect(otherButton).toHaveCount(0)
|
await expect(otherButton).toHaveCount(0)
|
||||||
},
|
},
|
||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
||||||
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
|
await openSidebar(page)
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
if (slug === root) return ""
|
||||||
|
if (seen.includes(slug)) return ""
|
||||||
|
return slug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
const directory = base64Decode(slug)
|
||||||
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
|
return { slug, directory }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceNewSession(page: Page, slug: string) {
|
||||||
|
await waitWorkspaceReady(page, slug)
|
||||||
|
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
|
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click({ force: true })
|
||||||
|
|
||||||
|
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||||
|
await openWorkspaceNewSession(page, slug)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await expect(prompt).toBeEditable()
|
||||||
|
await prompt.click()
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.fill(text)
|
||||||
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessionDirectory(directory: string, sessionID: string) {
|
||||||
|
const info = await createSdk(directory)
|
||||||
|
.session.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!info) return ""
|
||||||
|
return info.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await withProject(async ({ directory, slug: root }) => {
|
||||||
|
const workspaces = [] as { slug: string; directory: string }[]
|
||||||
|
const sessions = [] as string[]
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
|
const first = await createWorkspace(page, root, [])
|
||||||
|
workspaces.push(first)
|
||||||
|
await waitWorkspaceReady(page, first.slug)
|
||||||
|
|
||||||
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
|
workspaces.push(second)
|
||||||
|
await waitWorkspaceReady(page, second.slug)
|
||||||
|
|
||||||
|
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||||
|
sessions.push(firstSession)
|
||||||
|
|
||||||
|
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||||
|
sessions.push(secondSession)
|
||||||
|
|
||||||
|
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||||
|
sessions.push(thirdSession)
|
||||||
|
|
||||||
|
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
||||||
|
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
||||||
|
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
||||||
|
} finally {
|
||||||
|
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
||||||
|
await Promise.all(
|
||||||
|
sessions.map((sessionID) =>
|
||||||
|
Promise.all(
|
||||||
|
dirs.map((dir) =>
|
||||||
|
createSdk(dir)
|
||||||
|
.session.delete({ sessionID })
|
||||||
|
.catch(() => undefined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ import {
|
|||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
|
import { createSdk, dirSlug } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
function slugFromUrl(url: string) {
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
@@ -126,6 +128,49 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||||
|
const nonGitSlug = dirSlug(nonGit)
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(async () => {
|
||||||
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
|
|
||||||
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const activeDir = base64Decode(slugFromUrl(page.url()))
|
||||||
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
|
const trigger = page.locator('[data-action="project-menu"]').first()
|
||||||
|
const hasMenu = await trigger
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!hasMenu) return
|
||||||
|
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
||||||
|
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(toggle).toBeDisabled()
|
||||||
|
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(nonGit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("can rename a workspace", async ({ page, withProject }) => {
|
test("can rename a workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
@@ -214,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
|||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await withProject(async (project) => {
|
||||||
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
const sdk = createSdk(project.directory)
|
||||||
|
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,40 +1,95 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { withSession } from "../actions"
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
function contextButton(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator('[data-component="button"]')
|
||||||
|
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
noReply: true,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "seed context",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const messages = await input.sdk.session
|
||||||
|
.messages({ sessionID: input.sessionID, limit: 1 })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
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()}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
await sdk.session.promptAsync({
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
sessionID: session.id,
|
|
||||||
noReply: true,
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "seed context",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
|
||||||
return messages.length
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
|
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const contextButton = page
|
const trigger = contextButton(page)
|
||||||
.locator('[data-component="button"]')
|
await expect(trigger).toBeVisible()
|
||||||
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
await trigger.click()
|
||||||
.first()
|
|
||||||
|
|
||||||
await expect(contextButton).toBeVisible()
|
|
||||||
await contextButton.click()
|
|
||||||
|
|
||||||
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
|
const context = tabs.getByRole("tab", { name: "Context" })
|
||||||
|
await expect(context).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Close tab" }).first().click()
|
||||||
|
await expect(context).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
|
await page.getByRole("button", { name: "Open file" }).first().click()
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { sessionIDFromUrl } from "../actions"
|
||||||
|
|
||||||
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Agent response arrives via SSE despite sync endpoint being dead
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
} finally {
|
||||||
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||||||
)
|
)
|
||||||
|
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
|
|
||||||
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
|
|
||||||
await expect(reply).toBeVisible({ timeout: 90_000 })
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
|
|||||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||||
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
||||||
|
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
|
||||||
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
||||||
|
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
|
||||||
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
||||||
|
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
|
||||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||||
|
|
||||||
@@ -27,6 +30,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
|||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
export const projectClearNotificationsSelector = (slug: string) =>
|
||||||
|
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
@@ -48,6 +54,9 @@ export const workspaceItemSelector = (slug: string) =>
|
|||||||
export const workspaceMenuTriggerSelector = (slug: string) =>
|
export const workspaceMenuTriggerSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
|
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
|
||||||
|
|
||||||
|
export const workspaceNewSessionSelector = (slug: string) =>
|
||||||
|
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
|
||||||
|
|
||||||
export const listItemSelector = '[data-slot="list-item"]'
|
export const listItemSelector = '[data-slot="list-item"]'
|
||||||
|
|
||||||
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
import { createSdk, modKey } from "../utils"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
async function seedConversation(input: {
|
||||||
|
page: Page
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
sessionID: string
|
||||||
|
token: string
|
||||||
|
}) {
|
||||||
|
const messages = async () =>
|
||||||
|
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
|
||||||
|
const seeded = await messages()
|
||||||
|
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
|
||||||
|
|
||||||
|
const prompt = input.page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text: input.token }],
|
||||||
|
})
|
||||||
|
|
||||||
|
let userMessageID: string | undefined
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const users = (await messages()).filter(
|
||||||
|
(m) =>
|
||||||
|
!userIDs.has(m.info.id) &&
|
||||||
|
m.info.role === "user" &&
|
||||||
|
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
||||||
|
)
|
||||||
|
if (users.length === 0) return false
|
||||||
|
|
||||||
|
const user = users[users.length - 1]
|
||||||
|
if (!user) return false
|
||||||
|
userMessageID = user.info.id
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{ timeout: 90_000, intervals: [250, 500, 1_000] },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
if (!userMessageID) throw new Error("Expected a user message id")
|
||||||
|
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
return { prompt, userMessageID }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
const token = `undo_${Date.now()}`
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
|
|
||||||
|
await seeded.prompt.click()
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(seeded.userMessageID)
|
||||||
|
|
||||||
|
await expect(seeded.prompt).toContainText(token)
|
||||||
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
const token = `redo_${Date.now()}`
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
|
|
||||||
|
await seeded.prompt.click()
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(seeded.userMessageID)
|
||||||
|
|
||||||
|
await seeded.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
|
||||||
|
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBeUndefined()
|
||||||
|
|
||||||
|
await expect(seeded.prompt).not.toContainText(token)
|
||||||
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
const firstToken = `undo_redo_first_${Date.now()}`
|
||||||
|
const secondToken = `undo_redo_second_${Date.now()}`
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
const first = await seedConversation({
|
||||||
|
page,
|
||||||
|
sdk,
|
||||||
|
sessionID: session.id,
|
||||||
|
token: firstToken,
|
||||||
|
})
|
||||||
|
const second = await seedConversation({
|
||||||
|
page,
|
||||||
|
sdk,
|
||||||
|
sessionID: session.id,
|
||||||
|
token: secondToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(first.userMessageID).not.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||||
|
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||||
|
|
||||||
|
await expect(firstMessage.first()).toBeVisible()
|
||||||
|
await expect(secondMessage.first()).toBeVisible()
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage.first()).toBeVisible()
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(first.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(0)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
|
||||||
|
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage.first()).toBeVisible()
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBeUndefined()
|
||||||
|
|
||||||
|
await expect(firstMessage.first()).toBeVisible()
|
||||||
|
await expect(secondMessage.first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
|||||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const newTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, originalTitle, async (session) => {
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await input.fill(newTitle)
|
await expect(input).toBeFocused()
|
||||||
|
await input.fill(renamedTitle)
|
||||||
|
await expect(input).toHaveValue(renamedTitle)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.title
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(renamedTitle)
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
|
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||||
|
await publish.click()
|
||||||
|
|
||||||
|
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).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 expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||||
await unpublish.click()
|
await unpublish.click()
|
||||||
|
|
||||||
|
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
const unshared = await openSharePopover(page)
|
||||||
|
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
const unsharedPopover = await openSharePopover(page)
|
|
||||||
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
|||||||
const dialog = await openSettings(page)
|
const dialog = await openSettings(page)
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
|
||||||
await expect(keybindButton).toBeVisible()
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
const initialKeybind = await keybindButton.textContent()
|
||||||
@@ -51,6 +51,40 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
|||||||
expect(finalClosed).toBe(initiallyClosed)
|
expect(finalClosed).toBe(initiallyClosed)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
const initialKeybind = await keybindButton.textContent()
|
||||||
|
expect(initialKeybind).toContain("B")
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+KeyP`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
const toast = page.locator('[data-component="toast"]').last()
|
||||||
|
await expect(toast).toBeVisible()
|
||||||
|
await expect(toast).toContainText(/already/i)
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toContainText("B")
|
||||||
|
|
||||||
|
const stored = await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
})
|
||||||
|
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
})
|
||||||
|
|
||||||
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
|
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
|
||||||
@@ -277,6 +311,44 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
|||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
|
||||||
|
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
|
||||||
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
|
await keybindButton.click()
|
||||||
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+KeyY`)
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
await expect(keybindButton).toContainText("Y")
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const raw = localStorage.getItem("settings.v3")
|
||||||
|
if (!raw) return
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed?.keybinds?.["terminal.toggle"]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBe("mod+shift+y")
|
||||||
|
|
||||||
|
const reloaded = await openSettings(page)
|
||||||
|
await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
|
||||||
|
const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
|
||||||
|
await expect(reloadedKeybind).toContainText("Y")
|
||||||
|
await closeDialog(page, reloaded)
|
||||||
|
})
|
||||||
|
|
||||||
test("changing command palette keybind works", async ({ page, gotoSession }) => {
|
test("changing command palette keybind works", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
settingsNotificationsPermissionsSelector,
|
settingsNotificationsPermissionsSelector,
|
||||||
settingsReleaseNotesSelector,
|
settingsReleaseNotesSelector,
|
||||||
settingsSoundsAgentSelector,
|
settingsSoundsAgentSelector,
|
||||||
|
settingsSoundsAgentEnabledSelector,
|
||||||
|
settingsSoundsErrorsSelector,
|
||||||
|
settingsSoundsPermissionsSelector,
|
||||||
settingsThemeSelector,
|
settingsThemeSelector,
|
||||||
settingsUpdatesStartupSelector,
|
settingsUpdatesStartupSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
@@ -139,6 +142,105 @@ test("changing font persists in localStorage and updates CSS variable", async ({
|
|||||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
|
||||||
|
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
||||||
|
await expect(colorSchemeSelect).toBeVisible()
|
||||||
|
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||||
|
|
||||||
|
const fontSelect = dialog.locator(settingsFontSelector)
|
||||||
|
await expect(fontSelect).toBeVisible()
|
||||||
|
|
||||||
|
const initialFontFamily = await page.evaluate(() => {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialSettings = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
const currentFont =
|
||||||
|
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
|
const fontItems = page.locator('[data-slot="select-select-item"]')
|
||||||
|
expect(await fontItems.count()).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
if (currentFont) {
|
||||||
|
await fontItems.filter({ hasNotText: currentFont }).first().click()
|
||||||
|
}
|
||||||
|
if (!currentFont) {
|
||||||
|
await fontItems.nth(1).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
appearance: {
|
||||||
|
font: expect.any(String),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedSettings = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
const updatedFontFamily = await page.evaluate(() => {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||||
|
})
|
||||||
|
expect(updatedFontFamily).not.toBe(initialFontFamily)
|
||||||
|
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
await page.reload()
|
||||||
|
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
appearance: {
|
||||||
|
font: updatedSettings?.appearance?.font,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rehydratedSettings = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.not.toBe(initialFontFamily)
|
||||||
|
|
||||||
|
const rehydratedFontFamily = await page.evaluate(() => {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||||
|
})
|
||||||
|
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
|
||||||
|
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
|
||||||
|
})
|
||||||
|
|
||||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
@@ -234,6 +336,91 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
|
|||||||
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const select = dialog.locator(settingsSoundsAgentSelector)
|
||||||
|
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
|
||||||
|
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await expect(switchContainer).toBeVisible()
|
||||||
|
await expect(trigger).toBeEnabled()
|
||||||
|
|
||||||
|
await switchContainer.locator('[data-slot="switch-control"]').click()
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
|
await expect(trigger).toBeDisabled()
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.sounds?.agentEnabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
|
||||||
|
const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
|
||||||
|
await expect(permissionsSelect).toBeVisible()
|
||||||
|
await expect(errorsSelect).toBeVisible()
|
||||||
|
|
||||||
|
const initial = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
const permissionsCurrent =
|
||||||
|
(await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
const permissionItems = page.locator('[data-slot="select-select-item"]')
|
||||||
|
expect(await permissionItems.count()).toBeGreaterThan(1)
|
||||||
|
if (permissionsCurrent) {
|
||||||
|
await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
|
||||||
|
}
|
||||||
|
if (!permissionsCurrent) {
|
||||||
|
await permissionItems.nth(1).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorsCurrent =
|
||||||
|
(await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
const errorItems = page.locator('[data-slot="select-select-item"]')
|
||||||
|
expect(await errorItems.count()).toBeGreaterThan(1)
|
||||||
|
if (errorsCurrent) {
|
||||||
|
await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
|
||||||
|
}
|
||||||
|
if (!errorsCurrent) {
|
||||||
|
await errorItems.nth(1).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
sounds: {
|
||||||
|
permissions: expect.any(String),
|
||||||
|
errors: expect.any(String),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const stored = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
|
||||||
|
expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
|
||||||
|
})
|
||||||
|
|
||||||
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
|
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { closeSidebar, hoverSessionItem } from "../actions"
|
||||||
|
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
|
const stamp = Date.now()
|
||||||
|
|
||||||
|
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
|
||||||
|
const two = await sdk.session.create({ title: `e2e sidebar popover archive 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)
|
||||||
|
await closeSidebar(page)
|
||||||
|
|
||||||
|
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
await expect(project).toBeVisible()
|
||||||
|
await project.hover()
|
||||||
|
|
||||||
|
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
||||||
|
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||||
|
|
||||||
|
const item = await hoverSessionItem(page, one.id)
|
||||||
|
await item
|
||||||
|
.getByRole("button", { name: /archive/i })
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
|
||||||
|
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||||
|
} finally {
|
||||||
|
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||||
|
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, toggleSidebar } from "../actions"
|
import { openSidebar, toggleSidebar, withSession } from "../actions"
|
||||||
|
|
||||||
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -12,3 +12,26 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
|||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, "sidebar persist session 1", async (session1) => {
|
||||||
|
await withSession(sdk, "sidebar persist session 2", async (session2) => {
|
||||||
|
await gotoSession(session1.id)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
|
await gotoSession(session2.id)
|
||||||
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||||
|
|
||||||
|
const opened = await page.evaluate(
|
||||||
|
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
||||||
|
)
|
||||||
|
await expect(opened).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.53",
|
"version": "1.2.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ let runner: ReturnType<typeof Bun.spawn> | undefined
|
|||||||
let server: { stop: () => Promise<void> | void } | undefined
|
let server: { stop: () => Promise<void> | void } | undefined
|
||||||
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
||||||
let cleaned = false
|
let cleaned = false
|
||||||
let internalError = false
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
if (cleaned) return
|
if (cleaned) return
|
||||||
@@ -115,9 +114,8 @@ const shutdown = (code: number, reason: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reportInternalError = (reason: string, error: unknown) => {
|
const reportInternalError = (reason: string, error: unknown) => {
|
||||||
internalError = true
|
console.warn(`e2e-local ignored server error: ${reason}`)
|
||||||
console.error(`e2e-local internal error: ${reason}`)
|
console.warn(error)
|
||||||
console.error(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.once("SIGINT", () => shutdown(130, "SIGINT"))
|
process.once("SIGINT", () => shutdown(130, "SIGINT"))
|
||||||
@@ -177,6 +175,4 @@ try {
|
|||||||
await cleanup()
|
await cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code === 0 && internalError) code = 1
|
|
||||||
|
|
||||||
process.exit(code)
|
process.exit(code)
|
||||||
|
|||||||
+98
-70
@@ -1,5 +1,5 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
|
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
|
||||||
import { Router, Route, Navigate } from "@solidjs/router"
|
import { Router, Route, Navigate } from "@solidjs/router"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
@@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights"
|
|||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Suspense, JSX } from "solid-js"
|
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
|
const HomeRoute = () => (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Home />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionRoute = () => (
|
||||||
|
<SessionProviders>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Session />
|
||||||
|
</Suspense>
|
||||||
|
</SessionProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionIndexRoute = () => <Navigate href="session" />
|
||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
||||||
@@ -43,7 +57,7 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
|
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppShellProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<SettingsProvider>
|
||||||
|
<PermissionProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<ModelsProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<HighlightsProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</HighlightsProvider>
|
||||||
|
</CommandProvider>
|
||||||
|
</ModelsProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<TerminalProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<CommentsProvider>{props.children}</CommentsProvider>
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
|
return (
|
||||||
|
<AppShellProviders>
|
||||||
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
</AppShellProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
|
||||||
|
if (platform.platform !== "web") return
|
||||||
|
const result = platform.getDefaultServerUrl?.()
|
||||||
|
if (result instanceof Promise) return
|
||||||
|
if (!result) return
|
||||||
|
return normalizeServerUrl(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDefaultServerUrl = (props: {
|
||||||
|
defaultUrl?: string
|
||||||
|
storedDefaultServerUrl?: string
|
||||||
|
hostname: string
|
||||||
|
origin: string
|
||||||
|
isDev: boolean
|
||||||
|
devHost?: string
|
||||||
|
devPort?: string
|
||||||
|
}) => {
|
||||||
|
if (props.defaultUrl) return props.defaultUrl
|
||||||
|
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
|
||||||
|
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
|
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
|
||||||
|
return props.origin
|
||||||
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -86,80 +165,29 @@ function ServerKey(props: ParentProps) {
|
|||||||
|
|
||||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
|
||||||
const stored = (() => {
|
const defaultServerUrl = resolveDefaultServerUrl({
|
||||||
if (platform.platform !== "web") return
|
defaultUrl: props.defaultUrl,
|
||||||
const result = platform.getDefaultServerUrl?.()
|
storedDefaultServerUrl,
|
||||||
if (result instanceof Promise) return
|
hostname: location.hostname,
|
||||||
if (!result) return
|
origin: window.location.origin,
|
||||||
return normalizeServerUrl(result)
|
isDev: import.meta.env.DEV,
|
||||||
})()
|
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
|
||||||
|
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
|
||||||
const defaultServerUrl = () => {
|
})
|
||||||
if (props.defaultUrl) return props.defaultUrl
|
|
||||||
if (stored) return stored
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
|
||||||
|
|
||||||
return window.location.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
|
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Router
|
<Router
|
||||||
root={(routerProps) => (
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
<SettingsProvider>
|
|
||||||
<PermissionProvider>
|
|
||||||
<LayoutProvider>
|
|
||||||
<NotificationProvider>
|
|
||||||
<ModelsProvider>
|
|
||||||
<CommandProvider>
|
|
||||||
<HighlightsProvider>
|
|
||||||
<Layout>
|
|
||||||
{props.children}
|
|
||||||
{routerProps.children}
|
|
||||||
</Layout>
|
|
||||||
</HighlightsProvider>
|
|
||||||
</CommandProvider>
|
|
||||||
</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
</SettingsProvider>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Route
|
<Route path="/" component={HomeRoute} />
|
||||||
path="/"
|
|
||||||
component={() => (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
<Route
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
path="/session/:id?"
|
|
||||||
component={(p) => (
|
|
||||||
<Show when={p.params.id ?? "new"}>
|
|
||||||
<TerminalProvider>
|
|
||||||
<FileProvider>
|
|
||||||
<PromptProvider>
|
|
||||||
<CommentsProvider>
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Session />
|
|
||||||
</Suspense>
|
|
||||||
</CommentsProvider>
|
|
||||||
</PromptProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</TerminalProvider>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "method.select"; index: number }
|
||||||
|
| { type: "method.reset" }
|
||||||
|
| { type: "auth.pending" }
|
||||||
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
if (action.type === "method.select") {
|
||||||
|
draft.methodIndex = action.index
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "method.reset") {
|
||||||
|
draft.methodIndex = undefined
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.pending") {
|
||||||
|
draft.state = "pending"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.complete") {
|
||||||
|
draft.state = "complete"
|
||||||
|
draft.authorization = action.authorization
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.state = "error"
|
||||||
|
draft.error = action.error
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
@@ -63,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return value.label ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatError(value: unknown, fallback: string): string {
|
||||||
|
if (value && typeof value === "object" && "data" in value) {
|
||||||
|
const data = (value as { data?: { message?: unknown } }).data
|
||||||
|
if (typeof data?.message === "string" && data.message) return data.message
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object" && "error" in value) {
|
||||||
|
const nested = formatError((value as { error?: unknown }).error, "")
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object" && "message" in value) {
|
||||||
|
const message = (value as { message?: unknown }).message
|
||||||
|
if (typeof message === "string" && message) return message
|
||||||
|
}
|
||||||
|
if (value instanceof Error && value.message) return value.message
|
||||||
|
if (typeof value === "string" && value) return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
async function selectMethod(index: number) {
|
async function selectMethod(index: number) {
|
||||||
if (timer.current !== undefined) {
|
if (timer.current !== undefined) {
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
@@ -70,17 +128,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
setStore(
|
dispatch({ type: "method.select", index })
|
||||||
produce((draft) => {
|
|
||||||
draft.methodIndex = index
|
|
||||||
draft.authorization = undefined
|
|
||||||
draft.state = undefined
|
|
||||||
draft.error = undefined
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
setStore("state", "pending")
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await globalSDK.client.provider.oauth
|
||||||
.authorize(
|
.authorize(
|
||||||
@@ -100,18 +151,15 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
setStore("state", "complete")
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
setStore("authorization", x.data!)
|
|
||||||
}, delay)
|
}, delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("state", "complete")
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
setStore("authorization", x.data!)
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
setStore("state", "error")
|
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
||||||
setStore("error", String(e))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,10 +177,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
selectMethod(0)
|
selectMethod(0)
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKey)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKey)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
@@ -152,17 +196,243 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.authorization) {
|
if (store.authorization) {
|
||||||
setStore("authorization", undefined)
|
dispatch({ type: "method.reset" })
|
||||||
setStore("methodIndex", undefined)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.methodIndex) {
|
if (store.methodIndex !== undefined) {
|
||||||
setStore("methodIndex", undefined)
|
dispatch({ type: "method.reset" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MethodSelection() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<List
|
||||||
|
ref={(ref) => {
|
||||||
|
listRef = ref
|
||||||
|
}}
|
||||||
|
items={methods}
|
||||||
|
key={(m) => m?.label}
|
||||||
|
onSelect={async (selected, index) => {
|
||||||
|
if (!selected) return
|
||||||
|
selectMethod(index)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<div class="w-full flex items-center gap-x-2">
|
||||||
|
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||||
|
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
|
</div>
|
||||||
|
<span>{methodLabel(i)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiAuthView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: "",
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const form = e.currentTarget as HTMLFormElement
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const apiKey = formData.get("apiKey") as string
|
||||||
|
|
||||||
|
if (!apiKey?.trim()) {
|
||||||
|
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormStore("error", undefined)
|
||||||
|
await globalSDK.client.auth.set({
|
||||||
|
providerID: props.provider,
|
||||||
|
auth: {
|
||||||
|
type: "api",
|
||||||
|
key: apiKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<Switch>
|
||||||
|
<Match when={provider().id === "opencode"}>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||||
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
|
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
|
</Link>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
|
name="apiKey"
|
||||||
|
value={formStore.value}
|
||||||
|
onChange={(v) => setFormStore("value", v)}
|
||||||
|
validationState={formStore.error ? "invalid" : undefined}
|
||||||
|
error={formStore.error}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
|
{language.t("common.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthCodeView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: "",
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||||
|
platform.openLink(store.authorization.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const form = e.currentTarget as HTMLFormElement
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const code = formData.get("code") as string
|
||||||
|
|
||||||
|
if (!code?.trim()) {
|
||||||
|
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormStore("error", undefined)
|
||||||
|
const result = await globalSDK.client.provider.oauth
|
||||||
|
.callback({
|
||||||
|
providerID: props.provider,
|
||||||
|
method: store.methodIndex,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
if (result.ok) {
|
||||||
|
await complete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||||
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||||
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
|
name="code"
|
||||||
|
value={formStore.value}
|
||||||
|
onChange={(v) => setFormStore("value", v)}
|
||||||
|
validationState={formStore.error ? "invalid" : undefined}
|
||||||
|
error={formStore.error}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
|
{language.t("common.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthAutoView() {
|
||||||
|
const code = createMemo(() => {
|
||||||
|
const instructions = store.authorization?.instructions
|
||||||
|
if (instructions?.includes(":")) {
|
||||||
|
return instructions.split(":")[1]?.trim()
|
||||||
|
}
|
||||||
|
return instructions
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (store.authorization?.url) {
|
||||||
|
platform.openLink(store.authorization.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await globalSDK.client.provider.oauth
|
||||||
|
.callback({
|
||||||
|
providerID: props.provider,
|
||||||
|
method: store.methodIndex,
|
||||||
|
})
|
||||||
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
|
if (!alive.value) return
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = formatError(result.error, language.t("common.requestFailed"))
|
||||||
|
dispatch({ type: "auth.error", error: message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await complete()
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||||
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||||
|
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||||
|
class="font-mono"
|
||||||
|
value={code()}
|
||||||
|
readOnly
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
<div class="text-14-regular text-text-base flex items-center gap-4">
|
||||||
|
<Spinner />
|
||||||
|
<span>{language.t("provider.connect.status.waiting")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={
|
title={
|
||||||
@@ -188,267 +458,42 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||||
<Switch>
|
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||||
<Match when={store.methodIndex === undefined}>
|
<Switch>
|
||||||
<div class="text-14-regular text-text-base">
|
<Match when={store.methodIndex === undefined}>
|
||||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
<MethodSelection />
|
||||||
</div>
|
</Match>
|
||||||
<div class="">
|
<Match when={store.state === "pending"}>
|
||||||
<List
|
<div class="text-14-regular text-text-base">
|
||||||
ref={(ref) => {
|
<div class="flex items-center gap-x-2">
|
||||||
listRef = ref
|
<Spinner />
|
||||||
}}
|
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||||
items={methods}
|
|
||||||
key={(m) => m?.label}
|
|
||||||
onSelect={async (method, index) => {
|
|
||||||
if (!method) return
|
|
||||||
selectMethod(index)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(i) => (
|
|
||||||
<div class="w-full flex items-center gap-x-2">
|
|
||||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
|
||||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
|
||||||
</div>
|
|
||||||
<span>{methodLabel(i)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={store.state === "pending"}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
<div class="flex items-center gap-x-2">
|
|
||||||
<Spinner />
|
|
||||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={store.state === "error"}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
<div class="flex items-center gap-x-2">
|
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
|
||||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={method()?.type === "api"}>
|
|
||||||
{iife(() => {
|
|
||||||
const [formStore, setFormStore] = createStore({
|
|
||||||
value: "",
|
|
||||||
error: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const form = e.currentTarget as HTMLFormElement
|
|
||||||
const formData = new FormData(form)
|
|
||||||
const apiKey = formData.get("apiKey") as string
|
|
||||||
|
|
||||||
if (!apiKey?.trim()) {
|
|
||||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
|
||||||
await globalSDK.client.auth.set({
|
|
||||||
providerID: props.provider,
|
|
||||||
auth: {
|
|
||||||
type: "api",
|
|
||||||
key: apiKey,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await complete()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<Switch>
|
|
||||||
<Match when={provider().id === "opencode"}>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.line1")}
|
|
||||||
</div>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.line2")}
|
|
||||||
</div>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
|
||||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus
|
|
||||||
type="text"
|
|
||||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
||||||
name="apiKey"
|
|
||||||
value={formStore.value}
|
|
||||||
onChange={setFormStore.bind(null, "value")}
|
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
|
||||||
error={formStore.error}
|
|
||||||
/>
|
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
|
||||||
{language.t("common.submit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})}
|
</Match>
|
||||||
</Match>
|
<Match when={store.state === "error"}>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<div class="text-14-regular text-text-base">
|
||||||
<Switch>
|
<div class="flex items-center gap-x-2">
|
||||||
<Match when={store.authorization?.method === "code"}>
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
{iife(() => {
|
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||||
const [formStore, setFormStore] = createStore({
|
</div>
|
||||||
value: "",
|
</div>
|
||||||
error: undefined as string | undefined,
|
</Match>
|
||||||
})
|
<Match when={method()?.type === "api"}>
|
||||||
|
<ApiAuthView />
|
||||||
onMount(() => {
|
</Match>
|
||||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
<Match when={method()?.type === "oauth"}>
|
||||||
platform.openLink(store.authorization.url)
|
<Switch>
|
||||||
}
|
<Match when={store.authorization?.method === "code"}>
|
||||||
})
|
<OAuthCodeView />
|
||||||
|
</Match>
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
<Match when={store.authorization?.method === "auto"}>
|
||||||
e.preventDefault()
|
<OAuthAutoView />
|
||||||
|
</Match>
|
||||||
const form = e.currentTarget as HTMLFormElement
|
</Switch>
|
||||||
const formData = new FormData(form)
|
</Match>
|
||||||
const code = formData.get("code") as string
|
</Switch>
|
||||||
|
</div>
|
||||||
if (!code?.trim()) {
|
|
||||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
|
||||||
.callback({
|
|
||||||
providerID: props.provider,
|
|
||||||
method: store.methodIndex,
|
|
||||||
code,
|
|
||||||
})
|
|
||||||
.then((value) =>
|
|
||||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
|
||||||
if (result.ok) {
|
|
||||||
await complete()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
||||||
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
|
||||||
<Link href={store.authorization!.url}>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus
|
|
||||||
type="text"
|
|
||||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
|
||||||
name="code"
|
|
||||||
value={formStore.value}
|
|
||||||
onChange={setFormStore.bind(null, "value")}
|
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
|
||||||
error={formStore.error}
|
|
||||||
/>
|
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
|
||||||
{language.t("common.submit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Match>
|
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
|
||||||
{iife(() => {
|
|
||||||
const code = createMemo(() => {
|
|
||||||
const instructions = store.authorization?.instructions
|
|
||||||
if (instructions?.includes(":")) {
|
|
||||||
return instructions?.split(":")[1]?.trim()
|
|
||||||
}
|
|
||||||
return instructions
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void (async () => {
|
|
||||||
if (store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
|
||||||
.callback({
|
|
||||||
providerID: props.provider,
|
|
||||||
method: store.methodIndex,
|
|
||||||
})
|
|
||||||
.then((value) =>
|
|
||||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
|
||||||
|
|
||||||
if (!alive.value) return
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
||||||
setStore("state", "error")
|
|
||||||
setStore("error", message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await complete()
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
|
||||||
<Link href={store.authorization!.url}>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
|
||||||
class="font-mono"
|
|
||||||
value={code()}
|
|
||||||
readOnly
|
|
||||||
copyable
|
|
||||||
/>
|
|
||||||
<div class="text-14-regular text-text-base flex items-center gap-4">
|
|
||||||
<Spinner />
|
|
||||||
<span>{language.t("provider.connect.status.waiting")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
@@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
|||||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||||
|
|
||||||
|
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||||
|
|
||||||
|
type ModelRow = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderRow = {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
providerID: string
|
||||||
|
name: string
|
||||||
|
baseURL: string
|
||||||
|
apiKey: string
|
||||||
|
models: ModelRow[]
|
||||||
|
headers: HeaderRow[]
|
||||||
|
saving: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormErrors = {
|
||||||
|
providerID: string | undefined
|
||||||
|
name: string | undefined
|
||||||
|
baseURL: string | undefined
|
||||||
|
models: Array<{ id?: string; name?: string }>
|
||||||
|
headers: Array<{ key?: string; value?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateArgs = {
|
||||||
|
form: FormState
|
||||||
|
t: Translator
|
||||||
|
disabledProviders: string[]
|
||||||
|
existingProviderIDs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCustomProvider(input: ValidateArgs) {
|
||||||
|
const providerID = input.form.providerID.trim()
|
||||||
|
const name = input.form.name.trim()
|
||||||
|
const baseURL = input.form.baseURL.trim()
|
||||||
|
const apiKey = input.form.apiKey.trim()
|
||||||
|
|
||||||
|
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||||
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
|
const idError = !providerID
|
||||||
|
? input.t("provider.custom.error.providerID.required")
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? input.t("provider.custom.error.providerID.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? input.t("provider.custom.error.baseURL.required")
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? input.t("provider.custom.error.baseURL.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = input.disabledProviders.includes(providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: input.existingProviderIDs.has(providerID) && !disabled
|
||||||
|
? input.t("provider.custom.error.providerID.exists")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const modelErrors = input.form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const modelIdError = !id
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenModels.has(id)
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { id: modelIdError, name: modelNameError }
|
||||||
|
})
|
||||||
|
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||||
|
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headerErrors = input.form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
input.form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const errors: FormErrors = {
|
||||||
|
providerID: idError ?? existsError,
|
||||||
|
name: nameError,
|
||||||
|
baseURL: urlError,
|
||||||
|
models: modelErrors,
|
||||||
|
headers: headerErrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return { errors }
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
result: {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options,
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
}
|
}
|
||||||
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore({
|
const [form, setForm] = createStore<FormState>({
|
||||||
providerID: "",
|
providerID: "",
|
||||||
name: "",
|
name: "",
|
||||||
baseURL: "",
|
baseURL: "",
|
||||||
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
saving: false,
|
saving: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [errors, setErrors] = createStore({
|
const [errors, setErrors] = createStore<FormErrors>({
|
||||||
providerID: undefined as string | undefined,
|
providerID: undefined,
|
||||||
name: undefined as string | undefined,
|
name: undefined,
|
||||||
baseURL: undefined as string | undefined,
|
baseURL: undefined,
|
||||||
models: [{} as { id?: string; name?: string }],
|
models: [{}],
|
||||||
headers: [{} as { key?: string; value?: string }],
|
headers: [{}],
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm(
|
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||||
"models",
|
setErrors("models", (v) => [...v, {}])
|
||||||
produce((draft) => {
|
|
||||||
draft.push({ id: "", name: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"models",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.push({})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeModel = (index: number) => {
|
const removeModel = (index: number) => {
|
||||||
if (form.models.length <= 1) return
|
if (form.models.length <= 1) return
|
||||||
setForm(
|
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||||
"models",
|
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"models",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
setForm(
|
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||||
"headers",
|
setErrors("headers", (v) => [...v, {}])
|
||||||
produce((draft) => {
|
|
||||||
draft.push({ key: "", value: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"headers",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.push({})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeHeader = (index: number) => {
|
const removeHeader = (index: number) => {
|
||||||
if (form.headers.length <= 1) return
|
if (form.headers.length <= 1) return
|
||||||
setForm(
|
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||||
"headers",
|
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"headers",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const providerID = form.providerID.trim()
|
const output = validateCustomProvider({
|
||||||
const name = form.name.trim()
|
form,
|
||||||
const baseURL = form.baseURL.trim()
|
t: language.t,
|
||||||
const apiKey = form.apiKey.trim()
|
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||||
|
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
|
||||||
const key = apiKey && !env ? apiKey : undefined
|
|
||||||
|
|
||||||
const idError = !providerID
|
|
||||||
? language.t("provider.custom.error.providerID.required")
|
|
||||||
: !PROVIDER_ID.test(providerID)
|
|
||||||
? language.t("provider.custom.error.providerID.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
|
|
||||||
const urlError = !baseURL
|
|
||||||
? language.t("provider.custom.error.baseURL.required")
|
|
||||||
: !/^https?:\/\//.test(baseURL)
|
|
||||||
? language.t("provider.custom.error.baseURL.format")
|
|
||||||
: 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
|
|
||||||
? language.t("provider.custom.error.providerID.exists")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const seenModels = new Set<string>()
|
|
||||||
const modelErrors = form.models.map((m) => {
|
|
||||||
const id = m.id.trim()
|
|
||||||
const modelIdError = !id
|
|
||||||
? language.t("provider.custom.error.required")
|
|
||||||
: seenModels.has(id)
|
|
||||||
? language.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenModels.add(id)
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
|
|
||||||
return { id: modelIdError, name: modelNameError }
|
|
||||||
})
|
})
|
||||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
setErrors(output.errors)
|
||||||
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
return output.result
|
||||||
|
|
||||||
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
|
|
||||||
? language.t("provider.custom.error.required")
|
|
||||||
: seenHeaders.has(key.toLowerCase())
|
|
||||||
? language.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenHeaders.add(key.toLowerCase())
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const valueError = !value ? language.t("provider.custom.error.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) => {
|
const save = async (e: SubmitEvent) => {
|
||||||
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={setForm.bind(null, "providerID")}
|
onChange={(v) => setForm("providerID", v)}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={errors.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={errors.providerID}
|
||||||
/>
|
/>
|
||||||
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
label={language.t("provider.custom.field.name.label")}
|
label={language.t("provider.custom.field.name.label")}
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={setForm.bind(null, "name")}
|
onChange={(v) => setForm("name", v)}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={errors.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
/>
|
/>
|
||||||
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
label={language.t("provider.custom.field.baseURL.label")}
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={setForm.bind(null, "baseURL")}
|
onChange={(v) => setForm("baseURL", v)}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={errors.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={errors.baseURL}
|
||||||
/>
|
/>
|
||||||
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={setForm.bind(null, "apiKey")}
|
onChange={(v) => setForm("apiKey", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let iconInput: HTMLInputElement | undefined
|
||||||
|
|
||||||
function handleFileSelect(file: File) {
|
function handleFileSelect(file: File) {
|
||||||
if (!file.type.startsWith("image/")) return
|
if (!file.type.startsWith("image/")) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
setStore("saving", true)
|
await Promise.resolve()
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
.then(async () => {
|
||||||
const start = store.startup.trim()
|
setStore("saving", true)
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await globalSDK.client.project.update({
|
await globalSDK.client.project.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
directory: props.project.worktree,
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color, override: store.iconUrl },
|
icon: { color: store.color, override: store.iconUrl },
|
||||||
commands: { start },
|
commands: { start },
|
||||||
|
})
|
||||||
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("saving", false)
|
||||||
})
|
})
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
|
||||||
setStore("saving", false)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
setStore("saving", false)
|
|
||||||
dialog.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
if (store.iconUrl && store.iconHover) {
|
if (store.iconUrl && store.iconHover) {
|
||||||
clearIcon()
|
clearIcon()
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("icon-upload")?.click()
|
iconInput?.click()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
<input
|
||||||
|
id="icon-upload"
|
||||||
|
ref={(el) => {
|
||||||
|
iconInput = el
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
|
|||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
|
|
||||||
dialog.close()
|
sdk.client.session
|
||||||
|
.fork({ sessionID, messageID: item.id })
|
||||||
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) return
|
if (!forked.data) {
|
||||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
showToast({ title: language.t("common.requestFailed") })
|
||||||
requestAnimationFrame(() => {
|
return
|
||||||
prompt.set(restored)
|
}
|
||||||
|
dialog.close()
|
||||||
|
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
prompt.set(restored)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import type { Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -17,6 +18,15 @@ export const DialogManageModels: Component = () => {
|
|||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
|
const providerVisible = (providerID: string) =>
|
||||||
|
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||||
|
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||||
|
providerList(providerID).forEach((x) => {
|
||||||
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -35,21 +45,41 @@ export const DialogManageModels: Component = () => {
|
|||||||
items={local.model.list()}
|
items={local.model.list()}
|
||||||
filterKeys={["provider.name", "name", "id"]}
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
groupBy={(x) => x.provider.name}
|
groupBy={(x) => x.provider.id}
|
||||||
|
groupHeader={(group) => {
|
||||||
|
const provider = group.items[0].provider
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span>{provider.name}</span>
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
class="-mr-1"
|
||||||
|
checked={providerVisible(provider.id)}
|
||||||
|
onChange={(checked) => setProviderVisibility(provider.id, checked)}
|
||||||
|
hideLabel
|
||||||
|
>
|
||||||
|
{provider.name}
|
||||||
|
</Switch>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
sortGroupsBy={(a, b) => {
|
sortGroupsBy={(a, b) => {
|
||||||
const aProvider = a.items[0].provider.id
|
const aRank = providerRank(a.items[0].provider.id)
|
||||||
const bProvider = b.items[0].provider.id
|
const bRank = providerRank(b.items[0].provider.id)
|
||||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
const aPopular = aRank >= 0
|
||||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
const bPopular = bRank >= 0
|
||||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
return aRank - bRank
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
const visible = local.model.visible({
|
const key = { modelID: x.id, providerID: x.provider.id }
|
||||||
modelID: x.id,
|
local.model.setVisibility(key, !local.model.visible(key))
|
||||||
providerID: x.provider.id,
|
|
||||||
})
|
|
||||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
@@ -57,12 +87,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={
|
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
||||||
!!local.model.visible({
|
|
||||||
modelID: i.id,
|
|
||||||
providerID: i.provider.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusTrap: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
focusTrap?.focus()
|
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
|
||||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Refocus the trap when index changes to ensure escape always works
|
|
||||||
createEffect(() => {
|
|
||||||
index() // track index
|
|
||||||
focusTrap?.focus()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
size="large"
|
size="large"
|
||||||
fit
|
fit
|
||||||
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Hidden element to capture initial focus and handle escape */}
|
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
|
||||||
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
|
||||||
<div class="flex flex-1 min-w-0 min-h-0">
|
|
||||||
{/* Left side - Text content */}
|
{/* Left side - Text content */}
|
||||||
<div class="flex flex-col flex-1 min-w-0 p-8">
|
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||||
{/* Top section - feature content (fixed position from top) */}
|
{/* Top section - feature content (fixed position from top) */}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import type { ListRef } from "@opencode-ai/ui/list"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { ListRef } from "@opencode-ai/ui/list"
|
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -21,157 +21,131 @@ type Row = {
|
|||||||
search: string
|
search: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
function cleanInput(value: string) {
|
||||||
const sync = useGlobalSync()
|
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||||
const sdk = useGlobalSDK()
|
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||||
const dialog = useDialog()
|
}
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const [filter, setFilter] = createSignal("")
|
function normalizePath(input: string) {
|
||||||
|
const v = input.replaceAll("\\", "/")
|
||||||
|
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||||
|
return v.replace(/\/+/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
let list: ListRef | undefined
|
function normalizeDriveRoot(input: string) {
|
||||||
|
const v = normalizePath(input)
|
||||||
|
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
function trimTrailing(input: string) {
|
||||||
|
const v = normalizeDriveRoot(input)
|
||||||
|
if (v === "/") return v
|
||||||
|
if (v === "//") return v
|
||||||
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
|
return v.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
const [fallbackPath] = createResource(
|
function joinPath(base: string | undefined, rel: string) {
|
||||||
() => (missingBase() ? true : undefined),
|
const b = trimTrailing(base ?? "")
|
||||||
async () => {
|
const r = trimTrailing(rel).replace(/^\/+/, "")
|
||||||
return sdk.client.path
|
if (!b) return r
|
||||||
.get()
|
if (!r) return b
|
||||||
.then((x) => x.data)
|
if (b.endsWith("/")) return b + r
|
||||||
.catch(() => undefined)
|
return b + "/" + r
|
||||||
},
|
}
|
||||||
{ initialValue: undefined },
|
|
||||||
)
|
|
||||||
|
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
function rootOf(input: string) {
|
||||||
|
const v = normalizeDriveRoot(input)
|
||||||
|
if (v.startsWith("//")) return "//"
|
||||||
|
if (v.startsWith("/")) return "/"
|
||||||
|
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
const start = createMemo(
|
function parentOf(input: string) {
|
||||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
const v = trimTrailing(input)
|
||||||
)
|
if (v === "/") return v
|
||||||
|
if (v === "//") return v
|
||||||
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
|
|
||||||
|
const i = v.lastIndexOf("/")
|
||||||
|
if (i <= 0) return "/"
|
||||||
|
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||||
|
return v.slice(0, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeOf(input: string) {
|
||||||
|
const raw = normalizeDriveRoot(input.trim())
|
||||||
|
if (!raw) return "relative" as const
|
||||||
|
if (raw.startsWith("~")) return "tilde" as const
|
||||||
|
if (rootOf(raw)) return "absolute" as const
|
||||||
|
return "relative" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function tildeOf(absolute: string, home: string) {
|
||||||
|
const full = trimTrailing(absolute)
|
||||||
|
if (!home) return ""
|
||||||
|
|
||||||
|
const hn = trimTrailing(home)
|
||||||
|
const lc = full.toLowerCase()
|
||||||
|
const hc = hn.toLowerCase()
|
||||||
|
if (lc === hc) return "~"
|
||||||
|
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPath(path: string, input: string, home: string) {
|
||||||
|
const full = trimTrailing(path)
|
||||||
|
if (modeOf(input) === "absolute") return full
|
||||||
|
return tildeOf(full, home) || full
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(absolute: string, home: string): Row {
|
||||||
|
const full = trimTrailing(absolute)
|
||||||
|
const tilde = tildeOf(full, home)
|
||||||
|
const withSlash = (value: string) => {
|
||||||
|
if (!value) return ""
|
||||||
|
if (value.endsWith("/")) return value
|
||||||
|
return value + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = Array.from(
|
||||||
|
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||||
|
).join("\n")
|
||||||
|
return { absolute: full, search }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDirectorySearch(args: {
|
||||||
|
sdk: ReturnType<typeof useGlobalSDK>
|
||||||
|
start: () => string | undefined
|
||||||
|
home: () => string
|
||||||
|
}) {
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
|
let current = 0
|
||||||
|
|
||||||
const clean = (value: string) => {
|
const scoped = (value: string) => {
|
||||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
const base = args.start()
|
||||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(input: string) {
|
|
||||||
const v = input.replaceAll("\\", "/")
|
|
||||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
|
||||||
return v.replace(/\/+/g, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDriveRoot(input: string) {
|
|
||||||
const v = normalize(input)
|
|
||||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimTrailing(input: string) {
|
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
return v.replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function join(base: string | undefined, rel: string) {
|
|
||||||
const b = trimTrailing(base ?? "")
|
|
||||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
|
||||||
if (!b) return r
|
|
||||||
if (!r) return b
|
|
||||||
if (b.endsWith("/")) return b + r
|
|
||||||
return b + "/" + r
|
|
||||||
}
|
|
||||||
|
|
||||||
function rootOf(input: string) {
|
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v.startsWith("//")) return "//"
|
|
||||||
if (v.startsWith("/")) return "/"
|
|
||||||
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentOf(input: string) {
|
|
||||||
const v = trimTrailing(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
|
|
||||||
const i = v.lastIndexOf("/")
|
|
||||||
if (i <= 0) return "/"
|
|
||||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
|
||||||
return v.slice(0, i)
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeOf(input: string) {
|
|
||||||
const raw = normalizeDriveRoot(input.trim())
|
|
||||||
if (!raw) return "relative" as const
|
|
||||||
if (raw.startsWith("~")) return "tilde" as const
|
|
||||||
if (rootOf(raw)) return "absolute" as const
|
|
||||||
return "relative" as const
|
|
||||||
}
|
|
||||||
|
|
||||||
function display(path: string, input: string) {
|
|
||||||
const full = trimTrailing(path)
|
|
||||||
if (modeOf(input) === "absolute") return full
|
|
||||||
|
|
||||||
return tildeOf(full) || full
|
|
||||||
}
|
|
||||||
|
|
||||||
function tildeOf(absolute: string) {
|
|
||||||
const full = trimTrailing(absolute)
|
|
||||||
const h = home()
|
|
||||||
if (!h) return ""
|
|
||||||
|
|
||||||
const hn = trimTrailing(h)
|
|
||||||
const lc = full.toLowerCase()
|
|
||||||
const hc = hn.toLowerCase()
|
|
||||||
if (lc === hc) return "~"
|
|
||||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(absolute: string): Row {
|
|
||||||
const full = trimTrailing(absolute)
|
|
||||||
const tilde = tildeOf(full)
|
|
||||||
|
|
||||||
const withSlash = (value: string) => {
|
|
||||||
if (!value) return ""
|
|
||||||
if (value.endsWith("/")) return value
|
|
||||||
return value + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = Array.from(
|
|
||||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
|
||||||
).join("\n")
|
|
||||||
return { absolute: full, search }
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoped(value: string) {
|
|
||||||
const base = start()
|
|
||||||
if (!base) return
|
if (!base) return
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
const raw = normalizeDriveRoot(value)
|
||||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||||
|
|
||||||
const h = home()
|
const h = args.home()
|
||||||
if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
|
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
|
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
|
||||||
|
|
||||||
const root = rootOf(raw)
|
const root = rootOf(raw)
|
||||||
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
||||||
return { directory: trimTrailing(base), path: raw }
|
return { directory: trimTrailing(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dirs(dir: string) {
|
const dirs = async (dir: string) => {
|
||||||
const key = trimTrailing(dir)
|
const key = trimTrailing(dir)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const request = sdk.client.file
|
const request = args.sdk.client.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ directory: key, path: "" })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
async function match(dir: string, query: string, limit: number) {
|
const match = async (dir: string, query: string, limit: number) => {
|
||||||
const items = await dirs(dir)
|
const items = await dirs(dir)
|
||||||
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
||||||
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
||||||
}
|
}
|
||||||
|
|
||||||
const directories = async (filter: string) => {
|
return async (filter: string) => {
|
||||||
const value = clean(filter)
|
const token = ++current
|
||||||
|
const active = () => token === current
|
||||||
|
|
||||||
|
const value = cleanInput(filter)
|
||||||
const scopedInput = scoped(value)
|
const scopedInput = scoped(value)
|
||||||
if (!scopedInput) return [] as string[]
|
if (!scopedInput) return [] as string[]
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
const raw = normalizeDriveRoot(value)
|
||||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||||
|
|
||||||
const query = normalizeDriveRoot(scopedInput.path)
|
const query = normalizeDriveRoot(scopedInput.path)
|
||||||
|
|
||||||
const find = () =>
|
const find = () =>
|
||||||
sdk.client.find
|
args.sdk.client.find
|
||||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
|
|
||||||
if (!isPath) {
|
if (!isPath) {
|
||||||
const results = await find()
|
const results = await find()
|
||||||
|
if (!active()) return []
|
||||||
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
|
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = query.replace(/^\/+/, "").split("/")
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const branch = 4
|
const branch = 4
|
||||||
let paths = [scopedInput.directory]
|
let paths = [scopedInput.directory]
|
||||||
for (const part of head) {
|
for (const part of head) {
|
||||||
|
if (!active()) return []
|
||||||
if (part === "..") {
|
if (part === "..") {
|
||||||
paths = paths.map(parentOf)
|
paths = paths.map(parentOf)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
paths = Array.from(new Set(next)).slice(0, cap)
|
paths = Array.from(new Set(next)).slice(0, cap)
|
||||||
if (paths.length === 0) return [] as string[]
|
if (paths.length === 0) return [] as string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
const deduped = Array.from(new Set(out))
|
const deduped = Array.from(new Set(out))
|
||||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||||
const expand = !raw.endsWith("/")
|
const expand = !raw.endsWith("/")
|
||||||
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
if (!target) return deduped.slice(0, 50)
|
if (!target) return deduped.slice(0, 50)
|
||||||
|
|
||||||
const children = await match(target, "", 30)
|
const children = await match(target, "", 30)
|
||||||
|
if (!active()) return []
|
||||||
const items = Array.from(new Set([...deduped, ...children]))
|
const items = Array.from(new Set([...deduped, ...children]))
|
||||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
|
const sync = useGlobalSync()
|
||||||
|
const sdk = useGlobalSDK()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const [filter, setFilter] = createSignal("")
|
||||||
|
let list: ListRef | undefined
|
||||||
|
|
||||||
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
|
const [fallbackPath] = createResource(
|
||||||
|
() => (missingBase() ? true : undefined),
|
||||||
|
async () => {
|
||||||
|
return sdk.client.path
|
||||||
|
.get()
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
|
{ initialValue: undefined },
|
||||||
|
)
|
||||||
|
|
||||||
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
|
const start = createMemo(
|
||||||
|
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
const directories = useDirectorySearch({
|
||||||
|
sdk,
|
||||||
|
home,
|
||||||
|
start,
|
||||||
|
})
|
||||||
|
|
||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
return results.map(row)
|
return results.map((absolute) => toRow(absolute, home()))
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
key={(x) => x.absolute}
|
key={(x) => x.absolute}
|
||||||
filterKeys={["search"]}
|
filterKeys={["search"]}
|
||||||
ref={(r) => (list = r)}
|
ref={(r) => (list = r)}
|
||||||
onFilter={(value) => setFilter(clean(value))}
|
onFilter={(value) => setFilter(cleanInput(value))}
|
||||||
onKeyEvent={(e, item) => {
|
onKeyEvent={(e, item) => {
|
||||||
if (e.key !== "Tab") return
|
if (e.key !== "Tab") return
|
||||||
if (e.shiftKey) return
|
if (e.shiftKey) return
|
||||||
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const value = display(item.absolute, filter())
|
const value = displayPath(item.absolute, filter(), home())
|
||||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||||
}}
|
}}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const path = display(item.absolute, filter())
|
const path = displayPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
|
|||||||
@@ -36,6 +36,223 @@ type Entry = {
|
|||||||
|
|
||||||
type DialogSelectFileMode = "all" | "files"
|
type DialogSelectFileMode = "all" | "files"
|
||||||
|
|
||||||
|
const ENTRY_LIMIT = 5
|
||||||
|
const COMMON_COMMAND_IDS = [
|
||||||
|
"session.new",
|
||||||
|
"workspace.new",
|
||||||
|
"session.previous",
|
||||||
|
"session.next",
|
||||||
|
"terminal.toggle",
|
||||||
|
"review.toggle",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const uniqueEntries = (items: Entry[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: Entry[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.id)) continue
|
||||||
|
seen.add(item.id)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
|
||||||
|
id: "command:" + option.id,
|
||||||
|
type: "command",
|
||||||
|
title: option.title,
|
||||||
|
description: option.description,
|
||||||
|
keybind: option.keybind,
|
||||||
|
category,
|
||||||
|
option,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFileEntry = (path: string, category: string): Entry => ({
|
||||||
|
id: "file:" + path,
|
||||||
|
type: "file",
|
||||||
|
title: path,
|
||||||
|
category,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSessionEntry = (
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
},
|
||||||
|
category: string,
|
||||||
|
): Entry => ({
|
||||||
|
id: `session:${input.directory}:${input.id}`,
|
||||||
|
type: "session",
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
category,
|
||||||
|
directory: input.directory,
|
||||||
|
sessionID: input.id,
|
||||||
|
archived: input.archived,
|
||||||
|
updated: input.updated,
|
||||||
|
})
|
||||||
|
|
||||||
|
function createCommandEntries(props: {
|
||||||
|
filesOnly: () => boolean
|
||||||
|
command: ReturnType<typeof useCommand>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const allowed = createMemo(() => {
|
||||||
|
if (props.filesOnly()) return []
|
||||||
|
return props.command.options.filter(
|
||||||
|
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = createMemo(() => {
|
||||||
|
const category = props.language.t("palette.group.commands")
|
||||||
|
return allowed().map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
const picks = createMemo(() => {
|
||||||
|
const all = allowed()
|
||||||
|
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||||
|
const picked = all.filter((option) => order.has(option.id))
|
||||||
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
|
const category = props.language.t("palette.group.commands")
|
||||||
|
return sorted.map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { allowed, list, picks }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFileEntries(props: {
|
||||||
|
file: ReturnType<typeof useFile>
|
||||||
|
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const recent = createMemo(() => {
|
||||||
|
const all = props.tabs().all()
|
||||||
|
const active = props.tabs().active()
|
||||||
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = props.language.t("palette.group.files")
|
||||||
|
const items: Entry[] = []
|
||||||
|
|
||||||
|
for (const item of order) {
|
||||||
|
const path = props.file.pathFromTab(item)
|
||||||
|
if (!path) continue
|
||||||
|
if (seen.has(path)) continue
|
||||||
|
seen.add(path)
|
||||||
|
items.push(createFileEntry(path, category))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, ENTRY_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = createMemo(() => {
|
||||||
|
const category = props.language.t("palette.group.files")
|
||||||
|
const nodes = props.file.tree.children("")
|
||||||
|
const paths = nodes
|
||||||
|
.filter((node) => node.type === "file")
|
||||||
|
.map((node) => node.path)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { recent, root }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionEntries(props: {
|
||||||
|
workspaces: () => string[]
|
||||||
|
label: (directory: string) => string
|
||||||
|
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const state: {
|
||||||
|
token: number
|
||||||
|
inflight: Promise<Entry[]> | undefined
|
||||||
|
cached: Entry[] | undefined
|
||||||
|
} = {
|
||||||
|
token: 0,
|
||||||
|
inflight: undefined,
|
||||||
|
cached: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = (text: string) => {
|
||||||
|
const query = text.trim()
|
||||||
|
if (!query) {
|
||||||
|
state.token += 1
|
||||||
|
state.inflight = undefined
|
||||||
|
state.cached = undefined
|
||||||
|
return [] as Entry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.cached) return state.cached
|
||||||
|
if (state.inflight) return state.inflight
|
||||||
|
|
||||||
|
const current = state.token
|
||||||
|
const dirs = props.workspaces()
|
||||||
|
if (dirs.length === 0) return [] as Entry[]
|
||||||
|
|
||||||
|
state.inflight = Promise.all(
|
||||||
|
dirs.map((directory) => {
|
||||||
|
const description = props.label(directory)
|
||||||
|
return props.globalSDK.client.session
|
||||||
|
.list({ directory, roots: true })
|
||||||
|
.then((x) =>
|
||||||
|
(x.data ?? [])
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title ?? props.language.t("command.session.new"),
|
||||||
|
description,
|
||||||
|
directory,
|
||||||
|
archived: s.time?.archived,
|
||||||
|
updated: s.time?.updated,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.catch(
|
||||||
|
() =>
|
||||||
|
[] as {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
directory: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}[],
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((results) => {
|
||||||
|
if (state.token !== current) return [] as Entry[]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = props.language.t("command.category.session")
|
||||||
|
const next = results
|
||||||
|
.flat()
|
||||||
|
.filter((item) => {
|
||||||
|
const key = `${item.directory}:${item.id}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((item) => createSessionEntry(item, category))
|
||||||
|
state.cached = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
.catch(() => [] as Entry[])
|
||||||
|
.finally(() => {
|
||||||
|
state.inflight = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return state.inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessions }
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -52,40 +269,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
const common = [
|
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||||
"session.new",
|
const fileEntries = createFileEntries({ file, tabs, language })
|
||||||
"workspace.new",
|
|
||||||
"session.previous",
|
|
||||||
"session.next",
|
|
||||||
"terminal.toggle",
|
|
||||||
"review.toggle",
|
|
||||||
]
|
|
||||||
const limit = 5
|
|
||||||
|
|
||||||
const allowed = createMemo(() => {
|
|
||||||
if (filesOnly()) return []
|
|
||||||
return command.options.filter(
|
|
||||||
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const commandItem = (option: CommandOption): Entry => ({
|
|
||||||
id: "command:" + option.id,
|
|
||||||
type: "command",
|
|
||||||
title: option.title,
|
|
||||||
description: option.description,
|
|
||||||
keybind: option.keybind,
|
|
||||||
category: language.t("palette.group.commands"),
|
|
||||||
option,
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileItem = (path: string): Entry => ({
|
|
||||||
id: "file:" + path,
|
|
||||||
type: "file",
|
|
||||||
title: path,
|
|
||||||
category: language.t("palette.group.files"),
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -116,136 +301,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionItem = (input: {
|
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
|
||||||
directory: string
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}): Entry => ({
|
|
||||||
id: `session:${input.directory}:${input.id}`,
|
|
||||||
type: "session",
|
|
||||||
title: input.title,
|
|
||||||
description: input.description,
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
directory: input.directory,
|
|
||||||
sessionID: input.id,
|
|
||||||
archived: input.archived,
|
|
||||||
updated: input.updated,
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = createMemo(() => allowed().map(commandItem))
|
|
||||||
|
|
||||||
const picks = createMemo(() => {
|
|
||||||
const all = allowed()
|
|
||||||
const order = new Map(common.map((id, index) => [id, index]))
|
|
||||||
const picked = all.filter((option) => order.has(option.id))
|
|
||||||
const base = picked.length ? picked : all.slice(0, limit)
|
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
|
||||||
return sorted.map(commandItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
const recent = createMemo(() => {
|
|
||||||
const all = tabs().all()
|
|
||||||
const active = tabs().active()
|
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const items: Entry[] = []
|
|
||||||
|
|
||||||
for (const item of order) {
|
|
||||||
const path = file.pathFromTab(item)
|
|
||||||
if (!path) continue
|
|
||||||
if (seen.has(path)) continue
|
|
||||||
seen.add(path)
|
|
||||||
items.push(fileItem(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.slice(0, limit)
|
|
||||||
})
|
|
||||||
|
|
||||||
const root = createMemo(() => {
|
|
||||||
const nodes = file.tree.children("")
|
|
||||||
const paths = nodes
|
|
||||||
.filter((node) => node.type === "file")
|
|
||||||
.map((node) => node.path)
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
return paths.slice(0, limit).map(fileItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
const unique = (items: Entry[]) => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const out: Entry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (seen.has(item.id)) continue
|
|
||||||
seen.add(item.id)
|
|
||||||
out.push(item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionToken = { value: 0 }
|
|
||||||
let sessionInflight: Promise<Entry[]> | undefined
|
|
||||||
let sessionAll: Entry[] | undefined
|
|
||||||
|
|
||||||
const sessions = (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
if (!query) {
|
|
||||||
sessionToken.value += 1
|
|
||||||
sessionInflight = undefined
|
|
||||||
sessionAll = undefined
|
|
||||||
return [] as Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionAll) return sessionAll
|
|
||||||
if (sessionInflight) return sessionInflight
|
|
||||||
|
|
||||||
const current = sessionToken.value
|
|
||||||
const dirs = workspaces()
|
|
||||||
if (dirs.length === 0) return [] as Entry[]
|
|
||||||
|
|
||||||
sessionInflight = Promise.all(
|
|
||||||
dirs.map((directory) => {
|
|
||||||
const description = label(directory)
|
|
||||||
return globalSDK.client.session
|
|
||||||
.list({ directory, roots: true })
|
|
||||||
.then((x) =>
|
|
||||||
(x.data ?? [])
|
|
||||||
.filter((s) => !!s?.id)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title ?? language.t("command.session.new"),
|
|
||||||
description,
|
|
||||||
directory,
|
|
||||||
archived: s.time?.archived,
|
|
||||||
updated: s.time?.updated,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((results) => {
|
|
||||||
if (sessionToken.value !== current) return [] as Entry[]
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const next = results
|
|
||||||
.flat()
|
|
||||||
.filter((item) => {
|
|
||||||
const key = `${item.directory}:${item.id}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map(sessionItem)
|
|
||||||
sessionAll = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
.catch(() => [] as Entry[])
|
|
||||||
.finally(() => {
|
|
||||||
sessionInflight = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return sessionInflight
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = async (text: string) => {
|
const items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
if (!query && filesOnly()) {
|
if (!query && filesOnly()) {
|
||||||
const loaded = file.tree.state("")?.loaded
|
const loaded = file.tree.state("")?.loaded
|
||||||
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
||||||
const next = unique([...recent(), ...root()])
|
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||||
|
|
||||||
if (loaded || next.length > 0) {
|
if (loaded || next.length > 0) {
|
||||||
void pending
|
void pending
|
||||||
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
return unique([...recent(), ...root()])
|
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query) return [...picks(), ...recent()]
|
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
||||||
|
|
||||||
if (filesOnly()) {
|
if (filesOnly()) {
|
||||||
const files = await file.searchFiles(query)
|
const files = await file.searchFiles(query)
|
||||||
return files.map(fileItem)
|
const category = language.t("palette.group.files")
|
||||||
|
return files.map((path) => createFileEntry(path, category))
|
||||||
}
|
}
|
||||||
|
|
||||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
||||||
const entries = files.map(fileItem)
|
const category = language.t("palette.group.files")
|
||||||
return [...list(), ...nextSessions, ...entries]
|
const entries = files.map((path) => createFileEntry(path, category))
|
||||||
|
return [...commandEntries.list(), ...nextSessions, ...entries]
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (item: Entry | undefined) => {
|
const handleMove = (item: Entry | undefined) => {
|
||||||
@@ -289,9 +347,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.open()
|
|
||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
props.onOpenFile?.(path)
|
props.onOpenFile?.(path)
|
||||||
|
tabs().setActive(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelect = (item: Entry | undefined) => {
|
const handleSelect = (item: Entry | undefined) => {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
connected: "mcp.status.connected",
|
||||||
|
failed: "mcp.status.failed",
|
||||||
|
needs_auth: "mcp.status.needs_auth",
|
||||||
|
disabled: "mcp.status.disabled",
|
||||||
|
} as const
|
||||||
|
|
||||||
export const DialogSelectMcp: Component = () => {
|
export const DialogSelectMcp: Component = () => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const toggle = async (name: string) => {
|
const toggle = async (name: string) => {
|
||||||
if (loading()) return
|
if (loading()) return
|
||||||
setLoading(name)
|
setLoading(name)
|
||||||
const status = sync.data.mcp[name]
|
try {
|
||||||
if (status?.status === "connected") {
|
const status = sync.data.mcp[name]
|
||||||
await sdk.client.mcp.disconnect({ name })
|
if (status?.status === "connected") {
|
||||||
} else {
|
await sdk.client.mcp.disconnect({ name })
|
||||||
await sdk.client.mcp.connect({ name })
|
} else {
|
||||||
|
await sdk.client.mcp.connect({ name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sdk.client.mcp.status()
|
||||||
|
if (result.data) sync.set("mcp", result.data)
|
||||||
|
} finally {
|
||||||
|
setLoading(null)
|
||||||
}
|
}
|
||||||
const result = await sdk.client.mcp.status()
|
|
||||||
if (result.data) sync.set("mcp", result.data)
|
|
||||||
setLoading(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () => sync.data.mcp[i.name]
|
const mcpStatus = () => sync.data.mcp[i.name]
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
|
const statusLabel = () => {
|
||||||
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
if (!key) return
|
||||||
|
return language.t(key)
|
||||||
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
return s?.status === "failed" ? s.error : undefined
|
return s?.status === "failed" ? s.error : undefined
|
||||||
@@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="truncate">{i.name}</span>
|
<span class="truncate">{i.name}</span>
|
||||||
<Show when={status() === "connected"}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
|
||||||
<Show when={status() === "failed"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={status() === "needs_auth"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={status() === "disabled"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={loading() === i.name}>
|
<Show when={loading() === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
|
|||||||
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 { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") return
|
if (e.key === "Escape") return
|
||||||
listRef?.onKeyDown(e)
|
listRef?.onKeyDown(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
document.addEventListener("keydown", handleKey)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKey)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={language.t("dialog.model.select.title")}
|
title={language.t("dialog.model.select.title")}
|
||||||
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
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="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
||||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||||
<List
|
<List
|
||||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
|
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
|
|||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||||
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
provider?: string
|
provider?: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -54,13 +57,7 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
value={
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||||
<ModelTooltip
|
|
||||||
model={item}
|
|
||||||
latest={item.latest}
|
|
||||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -75,7 +72,7 @@ const ModelList: Component<{
|
|||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||||
<span class="truncate">{i.name}</span>
|
<span class="truncate">{i.name}</span>
|
||||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
<Show when={isFree(i.provider.id, i.cost)}>
|
||||||
<Tag>{language.t("model.tag.free")}</Tag>
|
<Tag>{language.t("model.tag.free")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.latest}>
|
<Show when={i.latest}>
|
||||||
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
dismiss: "escape" | "outside" | null
|
dismiss: "escape" | "outside" | null
|
||||||
trigger?: HTMLElement
|
|
||||||
content?: HTMLElement
|
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
trigger: undefined,
|
|
||||||
content: undefined,
|
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
|
|||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!store.open) return
|
|
||||||
|
|
||||||
const inside = (node: Node | null | undefined) => {
|
|
||||||
if (!node) return false
|
|
||||||
const el = store.content
|
|
||||||
if (el && el.contains(node)) return true
|
|
||||||
const anchor = store.trigger
|
|
||||||
if (anchor && anchor.contains(node)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
setStore("dismiss", "escape")
|
|
||||||
setStore("open", false)
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof Node)) return
|
|
||||||
if (inside(target)) return
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFocusIn = (event: FocusEvent) => {
|
|
||||||
if (!store.content) return
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof Node)) return
|
|
||||||
if (inside(target)) return
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown, true)
|
|
||||||
window.addEventListener("pointerdown", onPointerDown, true)
|
|
||||||
window.addEventListener("focusin", onFocusIn, true)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown, true)
|
|
||||||
window.removeEventListener("pointerdown", onPointerDown, true)
|
|
||||||
window.removeEventListener("focusin", onFocusIn, true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Kobalte
|
<Kobalte
|
||||||
open={store.open}
|
open={store.open}
|
||||||
@@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={8}
|
gutter={8}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
ref={(el) => setStore("content", el)}
|
|
||||||
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"
|
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")
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
|
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
|
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||||
|
const note = (id: string) => {
|
||||||
|
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||||
|
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||||
|
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("command.provider.connect")} transition>
|
<Dialog title={language.t("command.provider.connect")} transition>
|
||||||
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
|
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
<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>
|
||||||
<Show when={i.id === "anthropic"}>
|
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id === "openai"}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id.startsWith("github-copilot")}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -38,6 +38,64 @@ interface EditRowProps {
|
|||||||
onBlur: () => void
|
onBlur: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||||
|
const [defaultUrl, defaultUrlActions] = createResource(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const url = await platform.getDefaultServerUrl?.()
|
||||||
|
if (!url) return null
|
||||||
|
return normalizeServerUrl(url) ?? null
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||||
|
const setDefault = async (url: string | null) => {
|
||||||
|
try {
|
||||||
|
await platform.setDefaultServerUrl?.(url)
|
||||||
|
defaultUrlActions.mutate(url)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultUrl, canDefault, setDefault }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServerPreview(fetcher: typeof fetch) {
|
||||||
|
const looksComplete = (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return false
|
||||||
|
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||||
|
if (!host) return false
|
||||||
|
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||||
|
return host.includes(".") || host.includes(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||||
|
setStatus(undefined)
|
||||||
|
if (!looksComplete(value)) return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return
|
||||||
|
const result = await checkServerHealth(normalized, fetcher)
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previewStatus }
|
||||||
|
}
|
||||||
|
|
||||||
function AddRow(props: AddRowProps) {
|
function AddRow(props: AddRowProps) {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||||
@@ -115,6 +173,10 @@ export function DialogSelectServer() {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||||
|
const { previewStatus } = useServerPreview(fetcher)
|
||||||
|
let listRoot: HTMLDivElement | undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<string, ServerHealth | undefined>,
|
status: {} as Record<string, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
@@ -132,43 +194,6 @@ export function DialogSelectServer() {
|
|||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
|
||||||
if (!url) return null
|
|
||||||
return normalizeServerUrl(url) ?? null
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ initialValue: null },
|
|
||||||
)
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return false
|
|
||||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
|
||||||
if (!host) return false
|
|
||||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
|
||||||
return host.includes(".") || host.includes(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
|
||||||
setStatus(undefined)
|
|
||||||
if (!looksComplete(value)) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
|
||||||
setStatus(result.healthy)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetAdd = () => {
|
const resetAdd = () => {
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
@@ -263,7 +288,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollListToBottom = () => {
|
const scrollListToBottom = () => {
|
||||||
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
|
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
||||||
if (!scroll) return
|
if (!scroll) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scroll.scrollTop = scroll.scrollHeight
|
scroll.scrollTop = scroll.scrollHeight
|
||||||
@@ -363,158 +388,134 @@ export function DialogSelectServer() {
|
|||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.server.title")}>
|
<Dialog title={language.t("dialog.server.title")}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<List
|
<div ref={(el) => (listRoot = el)}>
|
||||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
<List
|
||||||
noInitialSelection
|
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
noInitialSelection
|
||||||
items={sortedItems}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
key={(x) => x}
|
items={sortedItems}
|
||||||
onSelect={(x) => {
|
key={(x) => x}
|
||||||
if (x) select(x)
|
onSelect={(x) => {
|
||||||
}}
|
if (x) select(x)
|
||||||
onFilter={(value) => {
|
}}
|
||||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
onFilter={(value) => {
|
||||||
resetAdd()
|
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||||
}
|
resetAdd()
|
||||||
}}
|
}
|
||||||
divider={true}
|
}}
|
||||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
divider={true}
|
||||||
add={
|
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
||||||
store.addServer.showForm
|
add={
|
||||||
? {
|
store.addServer.showForm
|
||||||
render: () => (
|
? {
|
||||||
<AddRow
|
render: () => (
|
||||||
value={store.addServer.url}
|
<AddRow
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
value={store.addServer.url}
|
||||||
adding={store.addServer.adding}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
error={store.addServer.error}
|
adding={store.addServer.adding}
|
||||||
status={store.addServer.status}
|
error={store.addServer.error}
|
||||||
onChange={handleAddChange}
|
status={store.addServer.status}
|
||||||
onKeyDown={handleAddKey}
|
onChange={handleAddChange}
|
||||||
onBlur={blurAdd}
|
onKeyDown={handleAddKey}
|
||||||
/>
|
onBlur={blurAdd}
|
||||||
),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(i) => {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
|
||||||
<Show
|
|
||||||
when={store.editServer.id !== i}
|
|
||||||
fallback={
|
|
||||||
<EditRow
|
|
||||||
value={store.editServer.value}
|
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
|
||||||
busy={store.editServer.busy}
|
|
||||||
error={store.editServer.error}
|
|
||||||
status={store.editServer.status}
|
|
||||||
onChange={handleEditChange}
|
|
||||||
onKeyDown={(event) => handleEditKey(event, i)}
|
|
||||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ServerRow
|
|
||||||
url={i}
|
|
||||||
status={store.status[i]}
|
|
||||||
dimmed={store.status[i]?.healthy === false}
|
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
|
||||||
badge={
|
|
||||||
<Show when={defaultUrl() === i}>
|
|
||||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
|
||||||
{language.t("dialog.server.status.default")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={store.editServer.id !== i}>
|
|
||||||
<div class="flex items-center justify-center gap-5 pl-4">
|
|
||||||
<Show when={current() === i}>
|
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
),
|
||||||
<DropdownMenu.Content class="mt-1">
|
}
|
||||||
<DropdownMenu.Item
|
: undefined
|
||||||
onSelect={() => {
|
}
|
||||||
setStore("editServer", {
|
>
|
||||||
id: i,
|
{(i) => {
|
||||||
value: i,
|
return (
|
||||||
error: "",
|
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||||
status: store.status[i]?.healthy,
|
<Show
|
||||||
})
|
when={store.editServer.id !== i}
|
||||||
}}
|
fallback={
|
||||||
>
|
<EditRow
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
value={store.editServer.value}
|
||||||
</DropdownMenu.Item>
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
busy={store.editServer.busy}
|
||||||
|
error={store.editServer.error}
|
||||||
|
status={store.editServer.status}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
onKeyDown={(event) => handleEditKey(event, i)}
|
||||||
|
onBlur={() => handleEdit(i, store.editServer.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ServerRow
|
||||||
|
url={i}
|
||||||
|
status={store.status[i]}
|
||||||
|
dimmed={store.status[i]?.healthy === false}
|
||||||
|
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||||
|
badge={
|
||||||
|
<Show when={defaultUrl() === i}>
|
||||||
|
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
|
{language.t("dialog.server.status.default")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={store.editServer.id !== i}>
|
||||||
|
<div class="flex items-center justify-center gap-5 pl-4">
|
||||||
|
<Show when={current() === i}>
|
||||||
|
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="dot-grid"
|
||||||
|
variant="ghost"
|
||||||
|
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||||
|
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="mt-1">
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={() => {
|
||||||
try {
|
setStore("editServer", {
|
||||||
await platform.setDefaultServerUrl?.(i)
|
id: i,
|
||||||
defaultUrlActions.mutate(i)
|
value: i,
|
||||||
} catch (err) {
|
error: "",
|
||||||
showToast({
|
status: store.status[i]?.healthy,
|
||||||
variant: "error",
|
})
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
<Show when={canDefault() && defaultUrl() !== i}>
|
||||||
<Show when={canDefault() && defaultUrl() === i}>
|
<DropdownMenu.Item onSelect={() => setDefault(i)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.default")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<Show when={canDefault() && defaultUrl() === i}>
|
||||||
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={() => handleRemove(i)}
|
||||||
try {
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
await platform.setDefaultServerUrl?.(null)
|
|
||||||
defaultUrlActions.mutate(null)
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</DropdownMenu.Content>
|
||||||
<DropdownMenu.Separator />
|
</DropdownMenu.Portal>
|
||||||
<DropdownMenu.Item
|
</DropdownMenu>
|
||||||
onSelect={() => handleRemove(i)}
|
</div>
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
</Show>
|
||||||
>
|
</div>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
)
|
||||||
</DropdownMenu.Item>
|
}}
|
||||||
</DropdownMenu.Content>
|
</List>
|
||||||
</DropdownMenu.Portal>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<div class="px-5 pb-5">
|
<div class="px-5 pb-5">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
|
|||||||
<Tabs.Content value="models" class="no-scrollbar">
|
<Tabs.Content value="models" class="no-scrollbar">
|
||||||
<SettingsModels />
|
<SettingsModels />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsAgents /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsCommands /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsMcp /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ let dirsToExpand: typeof import("./file-tree").dirsToExpand
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
|
useNavigate: () => () => undefined,
|
||||||
useParams: () => ({}),
|
useParams: () => ({}),
|
||||||
}))
|
}))
|
||||||
mock.module("@/context/file", () => ({
|
mock.module("@/context/file", () => ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
|
import { encodeFilePath } from "@/context/file/path"
|
||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -14,17 +15,16 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
untrack,
|
untrack,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
type JSXElement,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
const MAX_DEPTH = 128
|
||||||
|
|
||||||
function pathToFileUrl(filepath: string): string {
|
function pathToFileUrl(filepath: string): string {
|
||||||
const encodedPath = filepath
|
return `file://${encodeFilePath(filepath)}`
|
||||||
.split("/")
|
|
||||||
.map((segment) => encodeURIComponent(segment))
|
|
||||||
.join("/")
|
|
||||||
return `file://${encodedPath}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Kind = "add" | "del" | "mix"
|
type Kind = "add" | "del" | "mix"
|
||||||
@@ -62,6 +62,189 @@ export function dirsToExpand(input: {
|
|||||||
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindLabel = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "A"
|
||||||
|
if (kind === "del") return "D"
|
||||||
|
return "M"
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindTextColor = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||||
|
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||||
|
return "color: var(--icon-warning-active)"
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindDotColor = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||||
|
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||||
|
return "background-color: var(--icon-warning-active)"
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||||
|
const kind = kinds?.get(node.path)
|
||||||
|
if (!kind) return
|
||||||
|
if (!marks?.has(node.path)) return
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDragImage = (target: HTMLElement) => {
|
||||||
|
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
|
||||||
|
const text = target.querySelector("span")
|
||||||
|
if (!icon || !text) return
|
||||||
|
|
||||||
|
const image = document.createElement("div")
|
||||||
|
image.className =
|
||||||
|
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
||||||
|
image.style.position = "absolute"
|
||||||
|
image.style.top = "-1000px"
|
||||||
|
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
const withFileDragImage = (event: DragEvent) => {
|
||||||
|
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||||
|
if (!image) return
|
||||||
|
document.body.appendChild(image)
|
||||||
|
event.dataTransfer?.setDragImage(image, 0, 12)
|
||||||
|
setTimeout(() => document.body.removeChild(image), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTreeNode = (
|
||||||
|
p: ParentProps &
|
||||||
|
ComponentProps<"div"> &
|
||||||
|
ComponentProps<"button"> & {
|
||||||
|
node: FileNode
|
||||||
|
level: number
|
||||||
|
active?: string
|
||||||
|
nodeClass?: string
|
||||||
|
draggable: boolean
|
||||||
|
kinds?: ReadonlyMap<string, Kind>
|
||||||
|
marks?: Set<string>
|
||||||
|
as?: "div" | "button"
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const [local, rest] = splitProps(p, [
|
||||||
|
"node",
|
||||||
|
"level",
|
||||||
|
"active",
|
||||||
|
"nodeClass",
|
||||||
|
"draggable",
|
||||||
|
"kinds",
|
||||||
|
"marks",
|
||||||
|
"as",
|
||||||
|
"children",
|
||||||
|
"class",
|
||||||
|
"classList",
|
||||||
|
])
|
||||||
|
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||||
|
const active = () => !!kind() && !local.node.ignored
|
||||||
|
const color = () => {
|
||||||
|
const value = kind()
|
||||||
|
if (!value) return
|
||||||
|
return kindTextColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
component={local.as ?? "div"}
|
||||||
|
classList={{
|
||||||
|
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||||
|
"bg-surface-base-active": local.node.path === local.active,
|
||||||
|
...(local.classList ?? {}),
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||||
|
}}
|
||||||
|
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||||
|
draggable={local.draggable}
|
||||||
|
onDragStart={(event: DragEvent) => {
|
||||||
|
if (!local.draggable) return
|
||||||
|
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||||
|
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||||
|
withFileDragImage(event)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
||||||
|
"text-text-weaker": local.node.ignored,
|
||||||
|
"text-text-weak": !local.node.ignored && !active(),
|
||||||
|
}}
|
||||||
|
style={active() ? color() : undefined}
|
||||||
|
>
|
||||||
|
{local.node.name}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const value = kind()
|
||||||
|
if (!value) return null
|
||||||
|
if (local.node.type === "file") {
|
||||||
|
return (
|
||||||
|
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
|
||||||
|
{kindLabel(value)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
|
||||||
|
})()}
|
||||||
|
</Dynamic>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
|
||||||
|
if (!props.enabled) return props.children
|
||||||
|
|
||||||
|
const parts = props.node.path.split("/")
|
||||||
|
const leaf = parts[parts.length - 1] ?? props.node.path
|
||||||
|
const head = parts.slice(0, -1).join("/")
|
||||||
|
const prefix = head ? `${head}/` : ""
|
||||||
|
const label =
|
||||||
|
props.kind === "add"
|
||||||
|
? "Additions"
|
||||||
|
: props.kind === "del"
|
||||||
|
? "Deletions"
|
||||||
|
: props.kind === "mix"
|
||||||
|
? "Modifications"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
openDelay={2000}
|
||||||
|
placement="bottom-start"
|
||||||
|
class="w-full"
|
||||||
|
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||||
|
value={
|
||||||
|
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||||
|
<span
|
||||||
|
class="min-w-0 truncate text-text-invert-base"
|
||||||
|
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||||
|
>
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||||
|
<Show when={label}>
|
||||||
|
{(text) => (
|
||||||
|
<>
|
||||||
|
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">{text()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={props.node.type === "directory" && props.node.ignored}>
|
||||||
|
<>
|
||||||
|
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
path: string
|
path: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -79,12 +262,20 @@ export default function FileTree(props: {
|
|||||||
_marks?: Set<string>
|
_marks?: Set<string>
|
||||||
_deeps?: Map<string, number>
|
_deeps?: Map<string, number>
|
||||||
_kinds?: ReadonlyMap<string, Kind>
|
_kinds?: ReadonlyMap<string, Kind>
|
||||||
|
_chain?: readonly string[]
|
||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const level = props.level ?? 0
|
const level = props.level ?? 0
|
||||||
const draggable = () => props.draggable ?? true
|
const draggable = () => props.draggable ?? true
|
||||||
const tooltip = () => props.tooltip ?? true
|
const tooltip = () => props.tooltip ?? true
|
||||||
|
|
||||||
|
const key = (p: string) =>
|
||||||
|
file
|
||||||
|
.normalize(p)
|
||||||
|
.replace(/[\\/]+$/, "")
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||||
|
|
||||||
const filter = createMemo(() => {
|
const filter = createMemo(() => {
|
||||||
if (props._filter) return props._filter
|
if (props._filter) return props._filter
|
||||||
|
|
||||||
@@ -126,23 +317,45 @@ export default function FileTree(props: {
|
|||||||
|
|
||||||
const out = new Map<string, number>()
|
const out = new Map<string, number>()
|
||||||
|
|
||||||
const visit = (dir: string, lvl: number): number => {
|
const root = props.path
|
||||||
const expanded = file.tree.state(dir)?.expanded ?? false
|
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||||
if (!expanded) return -1
|
|
||||||
|
|
||||||
const nodes = file.tree.children(dir)
|
const seen = new Set<string>()
|
||||||
const max = nodes.reduce((max, node) => {
|
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||||
if (node.type !== "directory") return max
|
|
||||||
const open = file.tree.state(node.path)?.expanded ?? false
|
|
||||||
if (!open) return max
|
|
||||||
return Math.max(max, visit(node.path, lvl + 1))
|
|
||||||
}, lvl)
|
|
||||||
|
|
||||||
out.set(dir, max)
|
const push = (dir: string, lvl: number) => {
|
||||||
return max
|
const id = key(dir)
|
||||||
|
if (seen.has(id)) return
|
||||||
|
seen.add(id)
|
||||||
|
|
||||||
|
const kids = file.tree
|
||||||
|
.children(dir)
|
||||||
|
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||||
|
.map((node) => node.path)
|
||||||
|
|
||||||
|
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||||
|
}
|
||||||
|
|
||||||
|
push(root, level - 1)
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const top = stack[stack.length - 1]!
|
||||||
|
|
||||||
|
if (top.i < top.kids.length) {
|
||||||
|
const next = top.kids[top.i]!
|
||||||
|
top.i++
|
||||||
|
push(next, top.lvl + 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out.set(top.dir, top.max)
|
||||||
|
stack.pop()
|
||||||
|
|
||||||
|
const parent = stack[stack.length - 1]
|
||||||
|
if (!parent) continue
|
||||||
|
parent.max = Math.max(parent.max, top.max)
|
||||||
}
|
}
|
||||||
|
|
||||||
visit(props.path, level - 1)
|
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,186 +436,23 @@ export default function FileTree(props: {
|
|||||||
seen.add(item)
|
seen.add(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.toSorted((a, b) => {
|
out.sort((a, b) => {
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) {
|
||||||
return a.type === "directory" ? -1 : 1
|
return a.type === "directory" ? -1 : 1
|
||||||
}
|
}
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
const Node = (
|
|
||||||
p: ParentProps &
|
|
||||||
ComponentProps<"div"> &
|
|
||||||
ComponentProps<"button"> & {
|
|
||||||
node: FileNode
|
|
||||||
as?: "div" | "button"
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
|
|
||||||
return (
|
|
||||||
<Dynamic
|
|
||||||
component={local.as ?? "div"}
|
|
||||||
classList={{
|
|
||||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
|
||||||
"bg-surface-base-active": local.node.path === props.active,
|
|
||||||
...(local.classList ?? {}),
|
|
||||||
[local.class ?? ""]: !!local.class,
|
|
||||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
|
||||||
}}
|
|
||||||
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
|
||||||
draggable={draggable()}
|
|
||||||
onDragStart={(e: DragEvent) => {
|
|
||||||
if (!draggable()) return
|
|
||||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
|
||||||
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
|
||||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
|
||||||
|
|
||||||
const dragImage = document.createElement("div")
|
|
||||||
dragImage.className =
|
|
||||||
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
|
||||||
dragImage.style.position = "absolute"
|
|
||||||
dragImage.style.top = "-1000px"
|
|
||||||
|
|
||||||
const icon =
|
|
||||||
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
|
|
||||||
(e.currentTarget as HTMLElement).querySelector("svg")
|
|
||||||
const text = (e.currentTarget as HTMLElement).querySelector("span")
|
|
||||||
if (icon && text) {
|
|
||||||
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(dragImage)
|
|
||||||
e.dataTransfer?.setDragImage(dragImage, 0, 12)
|
|
||||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
|
||||||
}}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
{(() => {
|
|
||||||
const kind = kinds()?.get(local.node.path)
|
|
||||||
const marked = marks()?.has(local.node.path) ?? false
|
|
||||||
const active = !!kind && marked && !local.node.ignored
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "color: var(--icon-diff-delete-base)"
|
|
||||||
: kind === "mix"
|
|
||||||
? "color: var(--icon-warning-active)"
|
|
||||||
: undefined
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
|
||||||
"text-text-weaker": local.node.ignored,
|
|
||||||
"text-text-weak": !local.node.ignored && !active,
|
|
||||||
}}
|
|
||||||
style={active ? color : undefined}
|
|
||||||
>
|
|
||||||
{local.node.name}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const kind = kinds()?.get(local.node.path)
|
|
||||||
if (!kind) return null
|
|
||||||
if (!marks()?.has(local.node.path)) return null
|
|
||||||
|
|
||||||
if (local.node.type === "file") {
|
|
||||||
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "color: var(--icon-diff-delete-base)"
|
|
||||||
: "color: var(--icon-warning-active)"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (local.node.type === "directory") {
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "background-color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "background-color: var(--icon-diff-delete-base)"
|
|
||||||
: "background-color: var(--icon-warning-active)"
|
|
||||||
|
|
||||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
</Dynamic>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||||
<For each={nodes()}>
|
<For each={nodes()}>
|
||||||
{(node) => {
|
{(node) => {
|
||||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||||
const deep = () => deeps().get(node.path) ?? -1
|
const deep = () => deeps().get(node.path) ?? -1
|
||||||
const Wrapper = (p: ParentProps) => {
|
const kind = () => visibleKind(node, kinds(), marks())
|
||||||
if (!tooltip()) return p.children
|
|
||||||
|
|
||||||
const parts = node.path.split("/")
|
|
||||||
const leaf = parts[parts.length - 1] ?? node.path
|
|
||||||
const head = parts.slice(0, -1).join("/")
|
|
||||||
const prefix = head ? `${head}/` : ""
|
|
||||||
|
|
||||||
const kind = () => kinds()?.get(node.path)
|
|
||||||
const label = () => {
|
|
||||||
const k = kind()
|
|
||||||
if (!k) return
|
|
||||||
if (k === "add") return "Additions"
|
|
||||||
if (k === "del") return "Deletions"
|
|
||||||
return "Modifications"
|
|
||||||
}
|
|
||||||
|
|
||||||
const ignored = () => node.type === "directory" && node.ignored
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
openDelay={2000}
|
|
||||||
placement="bottom-start"
|
|
||||||
class="w-full"
|
|
||||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
|
||||||
value={
|
|
||||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
|
||||||
<span
|
|
||||||
class="min-w-0 truncate text-text-invert-base"
|
|
||||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
|
||||||
>
|
|
||||||
{prefix}
|
|
||||||
</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
|
||||||
<Show when={label()}>
|
|
||||||
{(t: () => string) => (
|
|
||||||
<>
|
|
||||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">{t()}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={ignored()}>
|
|
||||||
<>
|
|
||||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{p.children}
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -416,13 +466,21 @@ export default function FileTree(props: {
|
|||||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<Wrapper>
|
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||||
<Node node={node}>
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
active={props.active}
|
||||||
|
nodeClass={props.nodeClass}
|
||||||
|
draggable={draggable()}
|
||||||
|
kinds={kinds()}
|
||||||
|
marks={marks()}
|
||||||
|
>
|
||||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||||
</div>
|
</div>
|
||||||
</Node>
|
</FileTreeNode>
|
||||||
</Wrapper>
|
</FileTreeNodeTooltip>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content class="relative pt-0.5">
|
<Collapsible.Content class="relative pt-0.5">
|
||||||
<div
|
<div
|
||||||
@@ -433,31 +491,48 @@ export default function FileTree(props: {
|
|||||||
}}
|
}}
|
||||||
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
|
||||||
/>
|
/>
|
||||||
<FileTree
|
<Show
|
||||||
path={node.path}
|
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||||
level={level + 1}
|
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||||
allowed={props.allowed}
|
>
|
||||||
modified={props.modified}
|
<FileTree
|
||||||
kinds={props.kinds}
|
path={node.path}
|
||||||
active={props.active}
|
level={level + 1}
|
||||||
draggable={props.draggable}
|
allowed={props.allowed}
|
||||||
tooltip={props.tooltip}
|
modified={props.modified}
|
||||||
onFileClick={props.onFileClick}
|
kinds={props.kinds}
|
||||||
_filter={filter()}
|
active={props.active}
|
||||||
_marks={marks()}
|
draggable={props.draggable}
|
||||||
_deeps={deeps()}
|
tooltip={props.tooltip}
|
||||||
_kinds={kinds()}
|
onFileClick={props.onFileClick}
|
||||||
/>
|
_filter={filter()}
|
||||||
|
_marks={marks()}
|
||||||
|
_deeps={deeps()}
|
||||||
|
_kinds={kinds()}
|
||||||
|
_chain={chain}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={node.type === "file"}>
|
<Match when={node.type === "file"}>
|
||||||
<Wrapper>
|
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||||
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
active={props.active}
|
||||||
|
nodeClass={props.nodeClass}
|
||||||
|
draggable={draggable()}
|
||||||
|
kinds={kinds()}
|
||||||
|
marks={marks()}
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onFileClick?.(node)}
|
||||||
|
>
|
||||||
<div class="w-4 shrink-0" />
|
<div class="w-4 shrink-0" />
|
||||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
<FileIcon node={node} class="text-icon-weak size-4" />
|
||||||
</Node>
|
</FileTreeNode>
|
||||||
</Wrapper>
|
</FileTreeNodeTooltip>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { ComponentProps, splitProps } from "solid-js"
|
import { ComponentProps, splitProps } from "solid-js"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
export interface LinkProps extends ComponentProps<"button"> {
|
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||||
href: string
|
href: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Link(props: LinkProps) {
|
export function Link(props: LinkProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [local, rest] = splitProps(props, ["href", "children"])
|
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
|
<a
|
||||||
|
href={local.href}
|
||||||
|
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!local.href) return
|
||||||
|
event.preventDefault()
|
||||||
|
platform.openLink(local.href)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{local.children}
|
{local.children}
|
||||||
</button>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
||||||
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
|
import {
|
||||||
|
canNavigateHistoryAtCursor,
|
||||||
|
navigatePromptHistory,
|
||||||
|
prependHistoryEntry,
|
||||||
|
promptLength,
|
||||||
|
} from "./prompt-input/history"
|
||||||
import { createPromptSubmit } from "./prompt-input/submit"
|
import { createPromptSubmit } from "./prompt-input/submit"
|
||||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||||
import { PromptContextItems } from "./prompt-input/context-items"
|
import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
@@ -158,14 +163,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) {
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.open()
|
|
||||||
layout.fileTree.setTab("changes")
|
layout.fileTree.setTab("changes")
|
||||||
|
tabs().setActive("review")
|
||||||
requestAnimationFrame(() => comments.setFocus(focus))
|
requestAnimationFrame(() => comments.setFocus(focus))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
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)
|
||||||
@@ -277,6 +281,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
const isFocused = createFocusSignal(() => editorRef)
|
||||||
|
|
||||||
|
const closePopover = () => setStore("popover", null)
|
||||||
|
|
||||||
|
const resetHistoryNavigation = (force = false) => {
|
||||||
|
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
|
||||||
|
setStore("historyIndex", -1)
|
||||||
|
setStore("savedPrompt", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearEditor = () => {
|
||||||
|
editorRef.innerHTML = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const setEditorText = (text: string) => {
|
||||||
|
clearEditor()
|
||||||
|
editorRef.textContent = text
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusEditorEnd = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editorRef.focus()
|
||||||
|
const range = document.createRange()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
range.selectNodeContents(editorRef)
|
||||||
|
range.collapse(false)
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCursor = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null
|
||||||
|
return getCursorPosition(editorRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEditorWithCursor = (parts: Prompt) => {
|
||||||
|
const cursor = currentCursor()
|
||||||
|
renderEditor(parts)
|
||||||
|
if (cursor !== null) setCursorPosition(editorRef, cursor)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.id
|
params.id
|
||||||
if (params.id) return
|
if (params.id) return
|
||||||
@@ -290,7 +335,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!isFocused()) setStore("popover", null)
|
if (!isFocused()) closePopover()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Safety: reset composing state on focus change to prevent stuck state
|
// Safety: reset composing state on focus change to prevent stuck state
|
||||||
@@ -304,6 +349,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
|
||||||
)
|
)
|
||||||
|
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
|
||||||
|
|
||||||
const handleAtSelect = (option: AtOption | undefined) => {
|
const handleAtSelect = (option: AtOption | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
@@ -381,26 +427,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
|
|
||||||
if (cmd.type === "custom") {
|
if (cmd.type === "custom") {
|
||||||
const text = `/${cmd.trigger} `
|
const text = `/${cmd.trigger} `
|
||||||
editorRef.innerHTML = ""
|
setEditorText(text)
|
||||||
editorRef.textContent = text
|
|
||||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||||
requestAnimationFrame(() => {
|
focusEditorEnd()
|
||||||
editorRef.focus()
|
|
||||||
const range = document.createRange()
|
|
||||||
const sel = window.getSelection()
|
|
||||||
range.selectNodeContents(editorRef)
|
|
||||||
range.collapse(false)
|
|
||||||
sel?.removeAllRanges()
|
|
||||||
sel?.addRange(range)
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
editorRef.innerHTML = ""
|
clearEditor()
|
||||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||||
command.trigger(cmd.id, "slash")
|
command.trigger(cmd.id, "slash")
|
||||||
}
|
}
|
||||||
@@ -441,10 +478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const prev = node.previousSibling
|
const prev = node.previousSibling
|
||||||
const next = node.nextSibling
|
const next = node.nextSibling
|
||||||
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
|
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
|
||||||
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
|
return !!prevIsBr && !next
|
||||||
if (!prevIsBr && !nextIsBr) return false
|
|
||||||
if (nextIsBr && !prevIsBr && prev) return false
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
if (node.nodeType !== Node.ELEMENT_NODE) return false
|
||||||
const el = node as HTMLElement
|
const el = node as HTMLElement
|
||||||
@@ -454,7 +488,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const renderEditor = (parts: Prompt) => {
|
const renderEditor = (parts: Prompt) => {
|
||||||
editorRef.innerHTML = ""
|
clearEditor()
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
editorRef.appendChild(createTextFragment(part.content))
|
editorRef.appendChild(createTextFragment(part.content))
|
||||||
@@ -464,6 +498,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
editorRef.appendChild(createPill(part))
|
editorRef.appendChild(createPill(part))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const last = editorRef.lastChild
|
||||||
|
if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") {
|
||||||
|
editorRef.appendChild(document.createTextNode("\u200B"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -514,34 +553,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
mirror.input = false
|
mirror.input = false
|
||||||
if (isNormalizedEditor()) return
|
if (isNormalizedEditor()) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
renderEditorWithCursor(inputParts)
|
||||||
let cursorPosition: number | null = null
|
|
||||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
|
||||||
cursorPosition = getCursorPosition(editorRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEditor(inputParts)
|
|
||||||
|
|
||||||
if (cursorPosition !== null) {
|
|
||||||
setCursorPosition(editorRef, cursorPosition)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const domParts = parseFromDOM()
|
const domParts = parseFromDOM()
|
||||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
renderEditorWithCursor(inputParts)
|
||||||
let cursorPosition: number | null = null
|
|
||||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
|
||||||
cursorPosition = getCursorPosition(editorRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEditor(inputParts)
|
|
||||||
|
|
||||||
if (cursorPosition !== null) {
|
|
||||||
setCursorPosition(editorRef, cursorPosition)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -636,11 +655,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
||||||
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
resetHistoryNavigation()
|
||||||
setStore("historyIndex", -1)
|
|
||||||
setStore("savedPrompt", null)
|
|
||||||
}
|
|
||||||
if (prompt.dirty()) {
|
if (prompt.dirty()) {
|
||||||
mirror.input = true
|
mirror.input = true
|
||||||
prompt.set(DEFAULT_PROMPT, 0)
|
prompt.set(DEFAULT_PROMPT, 0)
|
||||||
@@ -662,16 +678,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
slashOnInput(slashMatch[1])
|
slashOnInput(slashMatch[1])
|
||||||
setStore("popover", "slash")
|
setStore("popover", "slash")
|
||||||
} else {
|
} else {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
resetHistoryNavigation()
|
||||||
setStore("historyIndex", -1)
|
|
||||||
setStore("savedPrompt", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
mirror.input = true
|
mirror.input = true
|
||||||
prompt.set([...rawParts, ...images], cursorPosition)
|
prompt.set([...rawParts, ...images], cursorPosition)
|
||||||
@@ -723,7 +736,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (last.nodeType !== Node.TEXT_NODE) {
|
if (last.nodeType !== Node.TEXT_NODE) {
|
||||||
range.setStartAfter(last)
|
const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR"
|
||||||
|
const next = last.nextSibling
|
||||||
|
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
|
||||||
|
if (isBreak && (!next || emptyText)) {
|
||||||
|
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
|
||||||
|
if (!next) last.parentNode?.insertBefore(placeholder, null)
|
||||||
|
placeholder.textContent = "\u200B"
|
||||||
|
range.setStart(placeholder, 0)
|
||||||
|
} else {
|
||||||
|
range.setStartAfter(last)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
@@ -732,7 +755,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleInput()
|
handleInput()
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
@@ -782,12 +805,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
promptLength,
|
promptLength,
|
||||||
addToHistory,
|
addToHistory,
|
||||||
resetHistoryNavigation: () => {
|
resetHistoryNavigation: () => {
|
||||||
setStore("historyIndex", -1)
|
resetHistoryNavigation(true)
|
||||||
setStore("savedPrompt", null)
|
|
||||||
},
|
},
|
||||||
setMode: (mode) => setStore("mode", mode),
|
setMode: (mode) => setStore("mode", mode),
|
||||||
setPopover: (popover) => setStore("popover", popover),
|
setPopover: (popover) => setStore("popover", popover),
|
||||||
newSessionWorktree: props.newSessionWorktree,
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
onSubmit: props.onSubmit,
|
onSubmit: props.onSubmit,
|
||||||
})
|
})
|
||||||
@@ -872,7 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
if (ctrl && event.code === "KeyG") {
|
if (ctrl && event.code === "KeyG") {
|
||||||
if (store.popover) {
|
if (store.popover) {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -894,6 +916,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
.current()
|
.current()
|
||||||
.map((part) => ("content" in part ? part.content : ""))
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
.join("")
|
.join("")
|
||||||
|
const direction = event.key === "ArrowUp" ? "up" : "down"
|
||||||
|
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
|
||||||
const isEmpty = textContent.trim() === "" || textLength <= 1
|
const isEmpty = textContent.trim() === "" || textLength <= 1
|
||||||
const hasNewlines = textContent.includes("\n")
|
const hasNewlines = textContent.includes("\n")
|
||||||
const inHistory = store.historyIndex >= 0
|
const inHistory = store.historyIndex >= 0
|
||||||
@@ -902,7 +926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
|
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
|
||||||
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
|
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
|
||||||
|
|
||||||
if (event.key === "ArrowUp") {
|
if (direction === "up") {
|
||||||
if (!allowUp) return
|
if (!allowUp) return
|
||||||
if (navigateHistory("up")) {
|
if (navigateHistory("up")) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -923,7 +947,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
if (store.popover) {
|
if (store.popover) {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
} else if (working()) {
|
} else if (working()) {
|
||||||
abort()
|
abort()
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1057,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
keybind={command.keybind("agent.cycle")}
|
keybind={command.keybind("agent.cycle")}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
options={local.agent.list().map((agent) => agent.name)}
|
options={agentNames()}
|
||||||
current={local.agent.current()?.name ?? ""}
|
current={local.agent.current()?.name ?? ""}
|
||||||
onSelect={local.agent.set}
|
onSelect={local.agent.set}
|
||||||
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
|
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { onCleanup, onMount } from "solid-js"
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
|
|
||||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
@@ -31,7 +32,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
const dataUrl = reader.result as string
|
const dataUrl = reader.result as string
|
||||||
const attachment: ImageAttachmentPart = {
|
const attachment: ImageAttachmentPart = {
|
||||||
type: "image",
|
type: "image",
|
||||||
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
|
id: uuid(),
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mime: file.type,
|
mime: file.type,
|
||||||
dataUrl,
|
dataUrl,
|
||||||
@@ -88,6 +89,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
|
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
||||||
|
if (inserted) return
|
||||||
|
|
||||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ describe("buildRequestParts", () => {
|
|||||||
// Special chars should be encoded
|
// Special chars should be encoded
|
||||||
expect(filePart.url).toContain("file%23name.txt")
|
expect(filePart.url).toContain("file%23name.txt")
|
||||||
// Should have Windows drive letter properly encoded
|
// Should have Windows drive letter properly encoded
|
||||||
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]%3A/)
|
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ describe("buildRequestParts", () => {
|
|||||||
if (filePart?.type === "file") {
|
if (filePart?.type === "file") {
|
||||||
// Should handle absolute path that differs from sessionDirectory
|
// Should handle absolute path that differs from sessionDirectory
|
||||||
expect(() => new URL(filePart.url)).not.toThrow()
|
expect(() => new URL(filePart.url)).not.toThrow()
|
||||||
expect(filePart.url).toContain("/D%3A/other/project/file.ts")
|
expect(filePart.url).toContain("/D:/other/project/file.ts")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
|
import { encodeFilePath } from "@/context/file/path"
|
||||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
|
|
||||||
@@ -27,23 +28,11 @@ type BuildRequestPartsInput = {
|
|||||||
sessionDirectory: string
|
sessionDirectory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolute = (directory: string, path: string) =>
|
const absolute = (directory: string, path: string) => {
|
||||||
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
|
if (path.startsWith("/")) return path
|
||||||
|
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||||
const encodeFilePath = (filepath: string): string => {
|
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||||
// Normalize Windows paths: convert backslashes to forward slashes
|
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||||
let normalized = filepath.replace(/\\/g, "/")
|
|
||||||
|
|
||||||
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
|
|
||||||
if (/^[A-Za-z]:/.test(normalized)) {
|
|
||||||
normalized = "/" + normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode each path segment (preserving forward slashes as path separators)
|
|
||||||
return normalized
|
|
||||||
.split("/")
|
|
||||||
.map((segment) => encodeURIComponent(segment))
|
|
||||||
.join("/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileQuery = (selection: FileSelection | undefined) =>
|
const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
|
|||||||
@@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
<Show when={props.items.length > 0}>
|
<Show when={props.items.length > 0}>
|
||||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||||
<For each={props.items}>
|
<For each={props.items}>
|
||||||
{(item) => (
|
{(item) => {
|
||||||
<Tooltip
|
const directory = getDirectory(item.path)
|
||||||
value={
|
const filename = getFilename(item.path)
|
||||||
<span class="flex max-w-[300px]">
|
const label = getFilenameTruncated(item.path, 14)
|
||||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
const selected = props.active(item)
|
||||||
{getDirectory(item.path)}
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<span class="flex max-w-[300px]">
|
||||||
|
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||||
|
{directory}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0">{filename}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
}
|
||||||
</span>
|
placement="top"
|
||||||
}
|
openDelay={2000}
|
||||||
placement="top"
|
|
||||||
openDelay={2000}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
|
||||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
|
|
||||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
|
||||||
props.active(item),
|
|
||||||
"bg-background-stronger": !props.active(item),
|
|
||||||
}}
|
|
||||||
onClick={() => props.openComment(item)}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
classList={{
|
||||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||||
<Show when={item.selection}>
|
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||||
{(sel) => (
|
selected,
|
||||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
"bg-background-stronger": !selected,
|
||||||
{sel().startLine === sel().endLine
|
}}
|
||||||
? `:${sel().startLine}`
|
onClick={() => props.openComment(item)}
|
||||||
: `:${sel().startLine}-${sel().endLine}`}
|
>
|
||||||
</span>
|
<div class="flex items-center gap-1.5">
|
||||||
)}
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
</Show>
|
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||||
|
<Show when={item.selection}>
|
||||||
|
{(sel) => (
|
||||||
|
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||||
|
{sel().startLine === sel().endLine
|
||||||
|
? `:${sel().startLine}`
|
||||||
|
: `:${sel().startLine}-${sel().endLine}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.remove(item)
|
||||||
|
}}
|
||||||
|
aria-label={props.t("prompt.context.removeFile")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<Show when={item.comment}>
|
||||||
type="button"
|
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||||
icon="close-small"
|
</Show>
|
||||||
variant="ghost"
|
|
||||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
props.remove(item)
|
|
||||||
}}
|
|
||||||
aria-label={props.t("prompt.context.removeFile")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.comment}>
|
</Tooltip>
|
||||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
)
|
||||||
</Show>
|
}}
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindToIcon = {
|
||||||
|
image: "photo",
|
||||||
|
"@mention": "link",
|
||||||
|
} as const
|
||||||
|
|
||||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.type !== null}>
|
<Show when={props.type !== null}>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||||
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
|
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
|
||||||
<span class="text-14-regular">{props.label}</span>
|
<span class="text-14-regular">{props.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
|
||||||
|
|
||||||
describe("prompt-input editor dom", () => {
|
describe("prompt-input editor dom", () => {
|
||||||
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
|
test("createTextFragment preserves newlines with consecutive br nodes", () => {
|
||||||
const fragment = createTextFragment("foo\n\nbar")
|
const fragment = createTextFragment("foo\n\nbar")
|
||||||
const container = document.createElement("div")
|
const container = document.createElement("div")
|
||||||
container.appendChild(fragment)
|
container.appendChild(fragment)
|
||||||
|
|
||||||
expect(container.childNodes.length).toBe(5)
|
expect(container.childNodes.length).toBe(4)
|
||||||
|
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||||
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
|
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
|
||||||
|
expect(container.childNodes[3]?.textContent).toBe("bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("createTextFragment keeps trailing newline as terminal break", () => {
|
||||||
|
const fragment = createTextFragment("foo\n")
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(fragment)
|
||||||
|
|
||||||
|
expect(container.childNodes.length).toBe(2)
|
||||||
expect(container.childNodes[0]?.textContent).toBe("foo")
|
expect(container.childNodes[0]?.textContent).toBe("foo")
|
||||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||||
expect(container.childNodes[2]?.textContent).toBe("\u200B")
|
|
||||||
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
|
|
||||||
expect(container.childNodes[4]?.textContent).toBe("bar")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||||
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
|
|||||||
|
|
||||||
container.remove()
|
container.remove()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
|
||||||
|
const container = document.createElement("div")
|
||||||
|
container.appendChild(document.createTextNode("a"))
|
||||||
|
container.appendChild(document.createElement("br"))
|
||||||
|
container.appendChild(document.createElement("br"))
|
||||||
|
container.appendChild(document.createTextNode("b"))
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
setCursorPosition(container, 2)
|
||||||
|
expect(getCursorPosition(container)).toBe(2)
|
||||||
|
|
||||||
|
setCursorPosition(container, 3)
|
||||||
|
expect(getCursorPosition(container)).toBe(3)
|
||||||
|
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
|
|||||||
segments.forEach((segment, index) => {
|
segments.forEach((segment, index) => {
|
||||||
if (segment) {
|
if (segment) {
|
||||||
fragment.appendChild(document.createTextNode(segment))
|
fragment.appendChild(document.createTextNode(segment))
|
||||||
} else if (segments.length > 1) {
|
|
||||||
fragment.appendChild(document.createTextNode("\u200B"))
|
|
||||||
}
|
}
|
||||||
if (index < segments.length - 1) {
|
if (index < segments.length - 1) {
|
||||||
fragment.appendChild(document.createElement("br"))
|
fragment.appendChild(document.createElement("br"))
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Prompt } from "@/context/prompt"
|
import type { Prompt } from "@/context/prompt"
|
||||||
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
|
import {
|
||||||
|
canNavigateHistoryAtCursor,
|
||||||
|
clonePromptParts,
|
||||||
|
navigatePromptHistory,
|
||||||
|
prependHistoryEntry,
|
||||||
|
promptLength,
|
||||||
|
} from "./history"
|
||||||
|
|
||||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
@@ -66,4 +72,20 @@ describe("prompt-input history", () => {
|
|||||||
if (original[1]?.type !== "file") throw new Error("expected file")
|
if (original[1]?.type !== "file") throw new Error("expected file")
|
||||||
expect(original[1].selection?.startLine).toBe(1)
|
expect(original[1].selection?.startLine).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
|
||||||
|
const value = "a\nb\nc"
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
|||||||
|
|
||||||
export const MAX_HISTORY = 100
|
export const MAX_HISTORY = 100
|
||||||
|
|
||||||
|
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
|
||||||
|
if (!text.includes("\n")) return true
|
||||||
|
const position = Math.max(0, Math.min(cursor, text.length))
|
||||||
|
if (direction === "up") return !text.slice(0, position).includes("\n")
|
||||||
|
return !text.slice(position).includes("\n")
|
||||||
|
}
|
||||||
|
|
||||||
export function clonePromptParts(prompt: Prompt): Prompt {
|
export function clonePromptParts(prompt: Prompt): Prompt {
|
||||||
return prompt.map((part) => {
|
return prompt.map((part) => {
|
||||||
if (part.type === "text") return { ...part }
|
if (part.type === "text") return { ...part }
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
|
|||||||
removeLabel: string
|
removeLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||||
|
const imageClass =
|
||||||
|
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||||
|
const removeClass =
|
||||||
|
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||||
|
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||||
|
|
||||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.attachments.length > 0}>
|
<Show when={props.attachments.length > 0}>
|
||||||
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<Show
|
<Show
|
||||||
when={attachment.mime.startsWith("image/")}
|
when={attachment.mime.startsWith("image/")}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
<div class={fallbackClass}>
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<img
|
<img
|
||||||
src={attachment.dataUrl}
|
src={attachment.dataUrl}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
class={imageClass}
|
||||||
onClick={() => props.onOpen(attachment)}
|
onClick={() => props.onOpen(attachment)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
onClick={() => props.onRemove(attachment.id)}
|
||||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
class={removeClass}
|
||||||
aria-label={props.removeLabel}
|
aria-label={props.removeLabel}
|
||||||
>
|
>
|
||||||
<Icon name="close" class="size-3 text-text-weak" />
|
<Icon name="close" class="size-3 text-text-weak" />
|
||||||
</button>
|
</button>
|
||||||
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
<div class={nameClass}>
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,47 +52,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||||
>
|
>
|
||||||
<For each={props.atFlat.slice(0, 10)}>
|
<For each={props.atFlat.slice(0, 10)}>
|
||||||
{(item) => (
|
{(item) => {
|
||||||
<button
|
const key = props.atKey(item)
|
||||||
classList={{
|
|
||||||
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
if (item.type === "agent") {
|
||||||
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
|
return (
|
||||||
}}
|
<button
|
||||||
onClick={() => props.onAtSelect(item)}
|
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||||
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||||
>
|
onClick={() => props.onAtSelect(item)}
|
||||||
<Show
|
onMouseEnter={() => props.setAtActive(key)}
|
||||||
when={item.type === "agent"}
|
>
|
||||||
fallback={
|
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||||
<>
|
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
||||||
<FileIcon
|
</button>
|
||||||
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
|
)
|
||||||
class="shrink-0 size-4"
|
}
|
||||||
/>
|
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
const isDirectory = item.path.endsWith("/")
|
||||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
|
const directory = isDirectory ? item.path : getDirectory(item.path)
|
||||||
{item.type === "file"
|
const filename = isDirectory ? "" : getFilename(item.path)
|
||||||
? item.path.endsWith("/")
|
|
||||||
? item.path
|
return (
|
||||||
: getDirectory(item.path)
|
<button
|
||||||
: ""}
|
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||||
</span>
|
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||||
<Show when={item.type === "file" && !item.path.endsWith("/")}>
|
onClick={() => props.onAtSelect(item)}
|
||||||
<span class="text-text-strong whitespace-nowrap">
|
onMouseEnter={() => props.setAtActive(key)}
|
||||||
{item.type === "file" ? getFilename(item.path) : ""}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
@{item.type === "agent" ? item.name : ""}
|
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
||||||
</span>
|
<Show when={!isDirectory}>
|
||||||
</Show>
|
<span class="text-text-strong whitespace-nowrap">{filename}</span>
|
||||||
</button>
|
</Show>
|
||||||
)}
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
|
||||||
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
|
const createdClients: string[] = []
|
||||||
|
const createdSessions: string[] = []
|
||||||
|
const sentShell: string[] = []
|
||||||
|
const syncedDirectories: string[] = []
|
||||||
|
|
||||||
|
let selected = "/repo/worktree-a"
|
||||||
|
|
||||||
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
|
||||||
|
const clientFor = (directory: string) => ({
|
||||||
|
session: {
|
||||||
|
create: async () => {
|
||||||
|
createdSessions.push(directory)
|
||||||
|
return { data: { id: `session-${createdSessions.length}` } }
|
||||||
|
},
|
||||||
|
shell: async () => {
|
||||||
|
sentShell.push(directory)
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
prompt: async () => ({ data: undefined }),
|
||||||
|
command: async () => ({ data: undefined }),
|
||||||
|
abort: async () => ({ data: undefined }),
|
||||||
|
},
|
||||||
|
worktree: {
|
||||||
|
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const rootClient = clientFor("/repo/main")
|
||||||
|
|
||||||
|
mock.module("@solidjs/router", () => ({
|
||||||
|
useNavigate: () => () => undefined,
|
||||||
|
useParams: () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||||
|
createOpencodeClient: (input: { directory: string }) => {
|
||||||
|
createdClients.push(input.directory)
|
||||||
|
return clientFor(input.directory)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
|
showToast: () => 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@opencode-ai/util/encode", () => ({
|
||||||
|
base64Encode: (value: string) => value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/local", () => ({
|
||||||
|
useLocal: () => ({
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||||
|
variant: { current: () => undefined },
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
current: () => ({ name: "agent" }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/prompt", () => ({
|
||||||
|
usePrompt: () => ({
|
||||||
|
current: () => promptValue,
|
||||||
|
reset: () => undefined,
|
||||||
|
set: () => undefined,
|
||||||
|
context: {
|
||||||
|
add: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
items: () => [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/layout", () => ({
|
||||||
|
useLayout: () => ({
|
||||||
|
handoff: {
|
||||||
|
setTabs: () => undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/sdk", () => ({
|
||||||
|
useSDK: () => ({
|
||||||
|
directory: "/repo/main",
|
||||||
|
client: rootClient,
|
||||||
|
url: "http://localhost:4096",
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/sync", () => ({
|
||||||
|
useSync: () => ({
|
||||||
|
data: { command: [] },
|
||||||
|
session: {
|
||||||
|
optimistic: {
|
||||||
|
add: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
set: () => undefined,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/global-sync", () => ({
|
||||||
|
useGlobalSync: () => ({
|
||||||
|
child: (directory: string) => {
|
||||||
|
syncedDirectories.push(directory)
|
||||||
|
return [{}, () => undefined]
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/platform", () => ({
|
||||||
|
usePlatform: () => ({
|
||||||
|
fetch: fetch,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/language", () => ({
|
||||||
|
useLanguage: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mod = await import("./submit")
|
||||||
|
createPromptSubmit = mod.createPromptSubmit
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createdClients.length = 0
|
||||||
|
createdSessions.length = 0
|
||||||
|
sentShell.length = 0
|
||||||
|
syncedDirectories.length = 0
|
||||||
|
selected = "/repo/worktree-a"
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("prompt submit worktree selection", () => {
|
||||||
|
test("reads the latest worktree accessor value per submit", async () => {
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
mode: () => "shell",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
selected = "/repo/worktree-b"
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
|
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
|
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
|
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user