mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2622254c9 |
@@ -1,4 +1,4 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: 💬 Discord Community
|
- name: 💬 Discord Community
|
||||||
url: https://discord.gg/opencode
|
url: https://discord.gg/opencode
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -6,7 +6,7 @@ runs:
|
|||||||
- name: Mount Bun Cache
|
- name: Mount Bun Cache
|
||||||
uses: useblacksmith/stickydisk@v1
|
uses: useblacksmith/stickydisk@v1
|
||||||
with:
|
with:
|
||||||
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
key: ${{ github.repository }}-bun-cache
|
||||||
path: ~/.bun
|
path: ~/.bun
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
|||||||
@@ -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!**
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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,12 +48,8 @@ jobs:
|
|||||||
TODAY'S DATE: ${TODAY}
|
TODAY'S DATE: ${TODAY}
|
||||||
|
|
||||||
STEP 1: Gather today's issues
|
STEP 1: Gather today's issues
|
||||||
Search for all OPEN issues created today (${TODAY}) using:
|
Search for all issues created today (${TODAY}) using:
|
||||||
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
|
gh issue list --repo ${{ github.repository }} --state all --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,18 +47,14 @@ 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 OPEN PRs created or updated TODAY (${TODAY}):
|
Run these commands to gather PR information. ONLY include PRs created or updated TODAY (${TODAY}):
|
||||||
|
|
||||||
# Open PRs created today
|
# PRs created today
|
||||||
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
gh pr list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
|
||||||
|
|
||||||
# Open PRs with activity today (updated today)
|
# 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
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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 duplicates and compliance
|
- name: Check for duplicate issues
|
||||||
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,84 +34,30 @@ 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: ${{ github.event.issue.number }}
|
Issue number:
|
||||||
|
${{ github.event.issue.number }}
|
||||||
|
|
||||||
Lookup this issue with gh issue view ${{ 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.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
|
If you find any potential duplicates, please comment on the new issue with:
|
||||||
|
- 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]
|
||||||
|
|
||||||
[If keybind-related, add:]
|
Feel free to ignore if none of these address your specific case.'
|
||||||
For keybind-related issues, please also check our pinned keybinds documentation: #4997
|
|
||||||
|
|
||||||
[End with if not compliant:]
|
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, please add a comment mentioning the pinned keybinds issue #4997:
|
||||||
If you believe this was flagged incorrectly, please let a maintainer know.
|
'For keybind-related issues, please also check our pinned keybinds documentation: #4997'
|
||||||
|
|
||||||
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
If no clear duplicates are found, do not comment."
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ 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,11 +60,9 @@ 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")
|
||||||
|
|
||||||
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:_
|
||||||
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
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
+89
-46
@@ -7,32 +7,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
test:
|
||||||
name: unit (linux)
|
name: test (${{ matrix.settings.name }})
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: ./.github/actions/setup-bun
|
|
||||||
|
|
||||||
- name: Configure git identity
|
|
||||||
run: |
|
|
||||||
git config --global user.email "bot@opencode.ai"
|
|
||||||
git config --global user.name "opencode"
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: bun turbo test
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: e2e (${{ matrix.settings.name }})
|
|
||||||
needs: unit
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -40,12 +16,17 @@ jobs:
|
|||||||
- name: linux
|
- name: linux
|
||||||
host: blacksmith-4vcpu-ubuntu-2404
|
host: blacksmith-4vcpu-ubuntu-2404
|
||||||
playwright: bunx playwright install --with-deps
|
playwright: bunx playwright install --with-deps
|
||||||
|
workdir: .
|
||||||
|
command: |
|
||||||
|
git config --global user.email "bot@opencode.ai"
|
||||||
|
git config --global user.name "opencode"
|
||||||
|
bun turbo test
|
||||||
- name: windows
|
- name: windows
|
||||||
host: blacksmith-4vcpu-windows-2025
|
host: windows-latest
|
||||||
playwright: bunx playwright install
|
playwright: bunx playwright install
|
||||||
|
workdir: packages/app
|
||||||
|
command: bun test:e2e:local
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
env:
|
|
||||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -62,10 +43,87 @@ jobs:
|
|||||||
working-directory: packages/app
|
working-directory: packages/app
|
||||||
run: ${{ matrix.settings.playwright }}
|
run: ${{ matrix.settings.playwright }}
|
||||||
|
|
||||||
- name: Run app e2e tests
|
- name: Set OS-specific paths
|
||||||
run: bun --cwd packages/app test:e2e:local
|
run: |
|
||||||
|
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||||
|
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
|
||||||
|
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Seed opencode data
|
||||||
|
if: matrix.settings.name != 'windows'
|
||||||
|
working-directory: packages/opencode
|
||||||
|
run: bun script/seed-e2e.ts
|
||||||
|
env:
|
||||||
|
OPENCODE_DISABLE_SHARE: "true"
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||||
|
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||||
|
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||||
|
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||||
|
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||||
|
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||||
|
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
|
||||||
|
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
|
||||||
|
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
|
||||||
|
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
|
||||||
|
|
||||||
|
- name: Run opencode server
|
||||||
|
if: matrix.settings.name != 'windows'
|
||||||
|
working-directory: packages/opencode
|
||||||
|
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
|
||||||
|
env:
|
||||||
|
OPENCODE_DISABLE_SHARE: "true"
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||||
|
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||||
|
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||||
|
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||||
|
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||||
|
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||||
|
OPENCODE_CLIENT: "app"
|
||||||
|
|
||||||
|
- name: Wait for opencode server
|
||||||
|
if: matrix.settings.name != 'windows'
|
||||||
|
run: |
|
||||||
|
for i in {1..120}; do
|
||||||
|
curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: run
|
||||||
|
working-directory: ${{ matrix.settings.workdir }}
|
||||||
|
run: ${{ matrix.settings.command }}
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
OPENCODE_DISABLE_SHARE: "true"
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||||
|
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||||
|
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||||
|
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||||
|
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||||
|
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||||
|
PLAYWRIGHT_SERVER_HOST: "127.0.0.1"
|
||||||
|
PLAYWRIGHT_SERVER_PORT: "4096"
|
||||||
|
VITE_OPENCODE_SERVER_HOST: "127.0.0.1"
|
||||||
|
VITE_OPENCODE_SERVER_PORT: "4096"
|
||||||
|
OPENCODE_CLIENT: "app"
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
@@ -78,18 +136,3 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|
||||||
required:
|
|
||||||
name: test (linux)
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
needs:
|
|
||||||
- unit
|
|
||||||
- e2e
|
|
||||||
if: always()
|
|
||||||
steps:
|
|
||||||
- name: Verify upstream test jobs passed
|
|
||||||
run: |
|
|
||||||
echo "unit=${{ needs.unit.result }}"
|
|
||||||
echo "e2e=${{ needs.e2e.result }}"
|
|
||||||
test "${{ needs.unit.result }}" = "success"
|
|
||||||
test "${{ needs.e2e.result }}" = "success"
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
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}`);
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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}`);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -1,885 +0,0 @@
|
|||||||
---
|
|
||||||
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 session delete <sessionID>
|
|
||||||
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_ENABLE_QUESTION_TOOL
|
|
||||||
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,12 +16,15 @@ 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
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
// "enterprise": {
|
||||||
|
// "url": "https://enterprise.dev.opencode.ai",
|
||||||
|
// },
|
||||||
"provider": {
|
"provider": {
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"options": {},
|
"options": {},
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ 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
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
sst-env.d.ts
|
sst-env.d.ts
|
||||||
packages/desktop/src/bindings.ts
|
desktop/src/bindings.ts
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
github-policies:
|
|
||||||
runners:
|
|
||||||
allowed_groups:
|
|
||||||
- "GitHub Actions"
|
|
||||||
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||||
- The default branch in this repo is `dev`.
|
- The default branch in this repo is `dev`.
|
||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
|
||||||
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
|
||||||
|
|
||||||
## Style Guide
|
## Style Guide
|
||||||
@@ -110,4 +109,3 @@ 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,49 +258,3 @@ 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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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 الرسمية، تحديث اقل)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|||||||
-138
@@ -1,138 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<a href="https://opencode.ai">
|
|
||||||
<picture>
|
|
||||||
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
|
||||||
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
|
||||||
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p align="center">OpenCode je open source AI agent za programiranje.</p>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
|
||||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
|
||||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="README.md">English</a> |
|
|
||||||
<a href="README.zh.md">简体中文</a> |
|
|
||||||
<a href="README.zht.md">繁體中文</a> |
|
|
||||||
<a href="README.ko.md">한국어</a> |
|
|
||||||
<a href="README.de.md">Deutsch</a> |
|
|
||||||
<a href="README.es.md">Español</a> |
|
|
||||||
<a href="README.fr.md">Français</a> |
|
|
||||||
<a href="README.it.md">Italiano</a> |
|
|
||||||
<a href="README.da.md">Dansk</a> |
|
|
||||||
<a href="README.ja.md">日本語</a> |
|
|
||||||
<a href="README.pl.md">Polski</a> |
|
|
||||||
<a href="README.ru.md">Русский</a> |
|
|
||||||
<a href="README.bs.md">Bosanski</a> |
|
|
||||||
<a href="README.ar.md">العربية</a> |
|
|
||||||
<a href="README.no.md">Norsk</a> |
|
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
|
||||||
<a href="README.th.md">ไทย</a> |
|
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[](https://opencode.ai)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Instalacija
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# YOLO
|
|
||||||
curl -fsSL https://opencode.ai/install | bash
|
|
||||||
|
|
||||||
# Package manageri
|
|
||||||
npm i -g opencode-ai@latest # ili bun/pnpm/yarn
|
|
||||||
scoop install opencode # Windows
|
|
||||||
choco install opencode # Windows
|
|
||||||
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)
|
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
|
||||||
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
|
||||||
mise use -g opencode # Bilo koji OS
|
|
||||||
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> Ukloni verzije starije od 0.1.x prije instalacije.
|
|
||||||
|
|
||||||
### Desktop aplikacija (BETA)
|
|
||||||
|
|
||||||
OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice izdanja](https://github.com/anomalyco/opencode/releases) ili sa [opencode.ai/download](https://opencode.ai/download).
|
|
||||||
|
|
||||||
| Platforma | Preuzimanje |
|
|
||||||
| --------------------- | ------------------------------------- |
|
|
||||||
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
|
||||||
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
|
||||||
| Windows | `opencode-desktop-windows-x64.exe` |
|
|
||||||
| Linux | `.deb`, `.rpm`, ili AppImage |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS (Homebrew)
|
|
||||||
brew install --cask opencode-desktop
|
|
||||||
# Windows (Scoop)
|
|
||||||
scoop bucket add extras; scoop install extras/opencode-desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Instalacijski direktorij
|
|
||||||
|
|
||||||
Instalacijska skripta koristi sljedeći redoslijed prioriteta za putanju instalacije:
|
|
||||||
|
|
||||||
1. `$OPENCODE_INSTALL_DIR` - Prilagođeni instalacijski direktorij
|
|
||||||
2. `$XDG_BIN_DIR` - Putanja usklađena sa XDG Base Directory specifikacijom
|
|
||||||
3. `$HOME/bin` - Standardni korisnički bin direktorij (ako postoji ili se može kreirati)
|
|
||||||
4. `$HOME/.opencode/bin` - Podrazumijevana rezervna lokacija
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Primjeri
|
|
||||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
|
||||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agenti
|
|
||||||
|
|
||||||
OpenCode uključuje dva ugrađena agenta između kojih možeš prebacivati tasterom `Tab`.
|
|
||||||
|
|
||||||
- **build** - Podrazumijevani agent sa punim pristupom za razvoj
|
|
||||||
- **plan** - Agent samo za čitanje za analizu i istraživanje koda
|
|
||||||
- Podrazumijevano zabranjuje izmjene datoteka
|
|
||||||
- Traži dozvolu prije pokretanja bash komandi
|
|
||||||
- Idealan za istraživanje nepoznatih codebase-ova ili planiranje izmjena
|
|
||||||
|
|
||||||
Uključen je i **general** pod-agent za složene pretrage i višekoračne zadatke.
|
|
||||||
Koristi se interno i može se pozvati pomoću `@general` u porukama.
|
|
||||||
|
|
||||||
Saznaj više o [agentima](https://opencode.ai/docs/agents).
|
|
||||||
|
|
||||||
### Dokumentacija
|
|
||||||
|
|
||||||
Za više informacija o konfiguraciji OpenCode-a, [**pogledaj dokumentaciju**](https://opencode.ai/docs).
|
|
||||||
|
|
||||||
### Doprinosi
|
|
||||||
|
|
||||||
Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIBUTING.md) prije slanja pull requesta.
|
|
||||||
|
|
||||||
### Gradnja na OpenCode-u
|
|
||||||
|
|
||||||
Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama.
|
|
||||||
|
|
||||||
### FAQ
|
|
||||||
|
|
||||||
#### Po čemu se razlikuje od Claude Code-a?
|
|
||||||
|
|
||||||
Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su:
|
|
||||||
|
|
||||||
- 100% open source
|
|
||||||
- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna.
|
|
||||||
- LSP podrška odmah po instalaciji
|
|
||||||
- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu.
|
|
||||||
- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
|
||||||
+2
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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。更新頻度は低め)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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, 업데이트 빈도 낮음)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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 브랜치
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -27,13 +27,11 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
<a href="README.bs.md">Bosanski</a> |
|
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -52,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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, обновляется реже)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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 อย่างเป็นทางการ อัปเดตน้อยกว่า)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|||||||
-139
@@ -1,139 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<a href="https://opencode.ai">
|
|
||||||
<picture>
|
|
||||||
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
|
||||||
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
|
||||||
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p align="center">AI-агент для програмування з відкритим кодом.</p>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
|
||||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
|
||||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="README.md">English</a> |
|
|
||||||
<a href="README.zh.md">简体中文</a> |
|
|
||||||
<a href="README.zht.md">繁體中文</a> |
|
|
||||||
<a href="README.ko.md">한국어</a> |
|
|
||||||
<a href="README.de.md">Deutsch</a> |
|
|
||||||
<a href="README.es.md">Español</a> |
|
|
||||||
<a href="README.fr.md">Français</a> |
|
|
||||||
<a href="README.it.md">Italiano</a> |
|
|
||||||
<a href="README.da.md">Dansk</a> |
|
|
||||||
<a href="README.ja.md">日本語</a> |
|
|
||||||
<a href="README.pl.md">Polski</a> |
|
|
||||||
<a href="README.ru.md">Русский</a> |
|
|
||||||
<a href="README.bs.md">Bosanski</a> |
|
|
||||||
<a href="README.ar.md">العربية</a> |
|
|
||||||
<a href="README.no.md">Norsk</a> |
|
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
|
||||||
<a href="README.th.md">ไทย</a> |
|
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[](https://opencode.ai)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Встановлення
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# YOLO
|
|
||||||
curl -fsSL https://opencode.ai/install | bash
|
|
||||||
|
|
||||||
# Менеджери пакетів
|
|
||||||
npm i -g opencode-ai@latest # або bun/pnpm/yarn
|
|
||||||
scoop install opencode # Windows
|
|
||||||
choco install opencode # Windows
|
|
||||||
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
|
|
||||||
brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше)
|
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
|
||||||
paru -S opencode-bin # Arch Linux (Latest from AUR)
|
|
||||||
mise use -g opencode # Будь-яка ОС
|
|
||||||
nix run nixpkgs#opencode # або github:anomalyco/opencode для найновішої dev-гілки
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> Перед встановленням видаліть версії старші за 0.1.x.
|
|
||||||
|
|
||||||
### Десктопний застосунок (BETA)
|
|
||||||
|
|
||||||
OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download).
|
|
||||||
|
|
||||||
| Платформа | Завантаження |
|
|
||||||
| --------------------- | ------------------------------------- |
|
|
||||||
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
|
||||||
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
|
||||||
| Windows | `opencode-desktop-windows-x64.exe` |
|
|
||||||
| Linux | `.deb`, `.rpm` або AppImage |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS (Homebrew)
|
|
||||||
brew install --cask opencode-desktop
|
|
||||||
# Windows (Scoop)
|
|
||||||
scoop bucket add extras; scoop install extras/opencode-desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Каталог встановлення
|
|
||||||
|
|
||||||
Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення:
|
|
||||||
|
|
||||||
1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення
|
|
||||||
2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory
|
|
||||||
3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити)
|
|
||||||
4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Приклади
|
|
||||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
|
||||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
### Агенти
|
|
||||||
|
|
||||||
OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`.
|
|
||||||
|
|
||||||
- **build** - Агент за замовчуванням із повним доступом для завдань розробки
|
|
||||||
- **plan** - Агент лише для читання для аналізу та дослідження коду
|
|
||||||
- За замовчуванням забороняє редагування файлів
|
|
||||||
- Запитує дозвіл перед запуском bash-команд
|
|
||||||
- Ідеально підходить для дослідження незнайомих кодових баз або планування змін
|
|
||||||
|
|
||||||
Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань.
|
|
||||||
Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`.
|
|
||||||
|
|
||||||
Дізнайтеся більше про [agents](https://opencode.ai/docs/agents).
|
|
||||||
|
|
||||||
### Документація
|
|
||||||
|
|
||||||
Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs).
|
|
||||||
|
|
||||||
### Внесок
|
|
||||||
|
|
||||||
Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request.
|
|
||||||
|
|
||||||
### Проєкти на базі OpenCode
|
|
||||||
|
|
||||||
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
|
|
||||||
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
|
|
||||||
|
|
||||||
### FAQ
|
|
||||||
|
|
||||||
#### Чим це відрізняється від Claude Code?
|
|
||||||
|
|
||||||
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
|
|
||||||
|
|
||||||
- 100% open source
|
|
||||||
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
|
|
||||||
- Підтримка LSP з коробки
|
|
||||||
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
|
|
||||||
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
|
||||||
+2
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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,更新频率较低)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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
-4
@@ -31,8 +31,7 @@
|
|||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a>
|
||||||
<a href="README.uk.md">Українська</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -51,8 +50,7 @@ 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,更新頻率較低)
|
||||||
sudo pacman -S opencode # Arch Linux (Stable)
|
paru -S opencode-bin # Arch Linux
|
||||||
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": 1770812194,
|
"lastModified": 1768393167,
|
||||||
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
"narHash": "sha256-n2063BRjHde6DqAz2zavhOOiLUwA3qXt7jQYHyETjX8=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
"rev": "2f594d5af95d4fdac67fba60376ec11e482041cb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -30,26 +30,6 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
overlays = {
|
|
||||||
default =
|
|
||||||
final: _prev:
|
|
||||||
let
|
|
||||||
node_modules = final.callPackage ./nix/node_modules.nix {
|
|
||||||
inherit rev;
|
|
||||||
};
|
|
||||||
opencode = final.callPackage ./nix/opencode.nix {
|
|
||||||
inherit node_modules;
|
|
||||||
};
|
|
||||||
desktop = final.callPackage ./nix/desktop.nix {
|
|
||||||
inherit opencode;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
inherit opencode;
|
|
||||||
opencode-desktop = desktop;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
packages = forEachSystem (
|
packages = forEachSystem (
|
||||||
pkgs:
|
pkgs:
|
||||||
let
|
let
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+9
-15
@@ -135,16 +135,6 @@ const ZEN_MODELS = [
|
|||||||
new sst.Secret("ZEN_MODELS8"),
|
new sst.Secret("ZEN_MODELS8"),
|
||||||
new sst.Secret("ZEN_MODELS9"),
|
new sst.Secret("ZEN_MODELS9"),
|
||||||
new sst.Secret("ZEN_MODELS10"),
|
new sst.Secret("ZEN_MODELS10"),
|
||||||
new sst.Secret("ZEN_MODELS11"),
|
|
||||||
new sst.Secret("ZEN_MODELS12"),
|
|
||||||
new sst.Secret("ZEN_MODELS13"),
|
|
||||||
new sst.Secret("ZEN_MODELS14"),
|
|
||||||
new sst.Secret("ZEN_MODELS15"),
|
|
||||||
new sst.Secret("ZEN_MODELS16"),
|
|
||||||
new sst.Secret("ZEN_MODELS17"),
|
|
||||||
new sst.Secret("ZEN_MODELS18"),
|
|
||||||
new sst.Secret("ZEN_MODELS19"),
|
|
||||||
new sst.Secret("ZEN_MODELS20"),
|
|
||||||
]
|
]
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||||
@@ -166,10 +156,14 @@ 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")
|
||||||
|
|
||||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
let logProcessor
|
||||||
handler: "packages/console/function/src/log-processor.ts",
|
if ($app.stage === "production" || $app.stage === "frank") {
|
||||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
const HONEYCOMB_API_KEY = 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,
|
||||||
@@ -207,7 +201,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
transform: {
|
transform: {
|
||||||
worker: {
|
worker: {
|
||||||
placement: { mode: "smart" },
|
placement: { mode: "smart" },
|
||||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
tailConsumers: logProcessor ? [{ 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 -qwi avx2 /proc/cpuinfo 2>/dev/null; then
|
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||||
needs_baseline=true
|
needs_baseline=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -141,20 +141,6 @@ 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"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{"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-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
|
"x86_64-linux": "sha256-/wjmXpex5zfBZBfc2Zszh9UurRuKjm8S+gdAkMrWL98=",
|
||||||
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
|
"aarch64-linux": "sha256-c254hgVVyLFucacOQlpnJ+3eCw8xIK9+388EigiuKeM=",
|
||||||
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
|
"aarch64-darwin": "sha256-A71PII7Ue0uqsH970GPi0XfRKInpSFFom8jD3Q0wrgQ=",
|
||||||
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
|
"x86_64-darwin": "sha256-M9q8O0GkiNoDPMW4C3EVvLo+X9mjQYLJTtNZx5AuQCc="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ stdenvNoCC.mkDerivation {
|
|||||||
../bun.lock
|
../bun.lock
|
||||||
../package.json
|
../package.json
|
||||||
../patches
|
../patches
|
||||||
../install # required by desktop build (cli.rs include_str!)
|
../install
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-2
@@ -34,7 +34,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
'';
|
'';
|
||||||
|
|
||||||
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
|
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
|
||||||
env.OPENCODE_DISABLE_MODELS_FETCH = true;
|
|
||||||
env.OPENCODE_VERSION = finalAttrs.version;
|
env.OPENCODE_VERSION = finalAttrs.version;
|
||||||
env.OPENCODE_CHANNEL = "local";
|
env.OPENCODE_CHANNEL = "local";
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
writableTmpDirAsHomeHook
|
writableTmpDirAsHomeHook
|
||||||
];
|
];
|
||||||
doInstallCheck = true;
|
doInstallCheck = true;
|
||||||
versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ];
|
versionCheckKeepEnvironment = [ "HOME" ];
|
||||||
versionCheckProgramArg = "--version";
|
versionCheckProgramArg = "--version";
|
||||||
|
|
||||||
passthru = {
|
passthru = {
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
|
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
|
||||||
import { join, relative } from "path"
|
import { join, relative } from "path"
|
||||||
|
|
||||||
|
type SemverLike = {
|
||||||
|
valid: (value: string) => string | null
|
||||||
|
rcompare: (left: string, right: string) => number
|
||||||
|
}
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
dir: string
|
dir: string
|
||||||
version: string
|
version: string
|
||||||
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isDirectory(path: string) {
|
|
||||||
try {
|
|
||||||
const info = await lstat(path)
|
|
||||||
return info.isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidSemver = (v: string) => Bun.semver.satisfies(v, "x.x.x")
|
|
||||||
|
|
||||||
const root = process.cwd()
|
const root = process.cwd()
|
||||||
const bunRoot = join(root, "node_modules/.bun")
|
const bunRoot = join(root, "node_modules/.bun")
|
||||||
const linkRoot = join(bunRoot, "node_modules")
|
const linkRoot = join(bunRoot, "node_modules")
|
||||||
const directories = (await readdir(bunRoot)).sort()
|
const directories = (await readdir(bunRoot)).sort()
|
||||||
|
|
||||||
const versions = new Map<string, Entry[]>()
|
const versions = new Map<string, Entry[]>()
|
||||||
|
|
||||||
for (const entry of directories) {
|
for (const entry of directories) {
|
||||||
const full = join(bunRoot, entry)
|
const full = join(bunRoot, entry)
|
||||||
if (!(await isDirectory(full))) {
|
const info = await lstat(full)
|
||||||
|
if (!info.isDirectory()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const parsed = parseEntry(entry)
|
const parsed = parseEntry(entry)
|
||||||
@@ -34,23 +29,37 @@ for (const entry of directories) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const list = versions.get(parsed.name) ?? []
|
const list = versions.get(parsed.name) ?? []
|
||||||
list.push({ dir: full, version: parsed.version })
|
list.push({ dir: full, version: parsed.version, label: entry })
|
||||||
versions.set(parsed.name, list)
|
versions.set(parsed.name, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
|
||||||
|
| SemverLike
|
||||||
|
| {
|
||||||
|
default: SemverLike
|
||||||
|
}
|
||||||
|
const semver = "default" in semverModule ? semverModule.default : semverModule
|
||||||
const selections = new Map<string, Entry>()
|
const selections = new Map<string, Entry>()
|
||||||
|
|
||||||
for (const [slug, list] of versions) {
|
for (const [slug, list] of versions) {
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
const aValid = isValidSemver(a.version)
|
const left = semver.valid(a.version)
|
||||||
const bValid = isValidSemver(b.version)
|
const right = semver.valid(b.version)
|
||||||
if (aValid && bValid) return -Bun.semver.order(a.version, b.version)
|
if (left && right) {
|
||||||
if (aValid) return -1
|
const delta = semver.rcompare(left, right)
|
||||||
if (bValid) return 1
|
if (delta !== 0) {
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (left && !right) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (!left && right) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
return b.version.localeCompare(a.version)
|
return b.version.localeCompare(a.version)
|
||||||
})
|
})
|
||||||
const first = list[0]
|
selections.set(slug, list[0])
|
||||||
if (first) selections.set(slug, first)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await rm(linkRoot, { recursive: true, force: true })
|
await rm(linkRoot, { recursive: true, force: true })
|
||||||
@@ -68,7 +77,10 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0]
|
|||||||
await mkdir(parent, { recursive: true })
|
await mkdir(parent, { recursive: true })
|
||||||
const linkPath = join(parent, leaf)
|
const linkPath = join(parent, leaf)
|
||||||
const desired = join(entry.dir, "node_modules", slug)
|
const desired = join(entry.dir, "node_modules", slug)
|
||||||
if (!(await isDirectory(desired))) {
|
const exists = await lstat(desired)
|
||||||
|
.then((info) => info.isDirectory())
|
||||||
|
.catch(() => false)
|
||||||
|
if (!exists) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const relativeTarget = relative(parent, desired)
|
const relativeTarget = relative(parent, desired)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type PackageManifest = {
|
|||||||
|
|
||||||
const root = process.cwd()
|
const root = process.cwd()
|
||||||
const bunRoot = join(root, "node_modules/.bun")
|
const bunRoot = join(root, "node_modules/.bun")
|
||||||
const bunEntries = (await readdir(bunRoot)).sort()
|
const bunEntries = (await safeReadDir(bunRoot)).sort()
|
||||||
let rewritten = 0
|
let rewritten = 0
|
||||||
|
|
||||||
for (const entry of bunEntries) {
|
for (const entry of bunEntries) {
|
||||||
@@ -45,11 +45,11 @@ for (const entry of bunEntries) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[normalize-bun-binaries] rebuilt ${rewritten} links`)
|
console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`)
|
||||||
|
|
||||||
async function collectPackages(modulesRoot: string) {
|
async function collectPackages(modulesRoot: string) {
|
||||||
const found: string[] = []
|
const found: string[] = []
|
||||||
const topLevel = (await readdir(modulesRoot)).sort()
|
const topLevel = (await safeReadDir(modulesRoot)).sort()
|
||||||
for (const name of topLevel) {
|
for (const name of topLevel) {
|
||||||
if (name === ".bin" || name === ".bun") {
|
if (name === ".bin" || name === ".bun") {
|
||||||
continue
|
continue
|
||||||
@@ -59,7 +59,7 @@ async function collectPackages(modulesRoot: string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (name.startsWith("@")) {
|
if (name.startsWith("@")) {
|
||||||
const scoped = (await readdir(full)).sort()
|
const scoped = (await safeReadDir(full)).sort()
|
||||||
for (const child of scoped) {
|
for (const child of scoped) {
|
||||||
const scopedDir = join(full, child)
|
const scopedDir = join(full, child)
|
||||||
if (await isDirectory(scopedDir)) {
|
if (await isDirectory(scopedDir)) {
|
||||||
@@ -121,6 +121,14 @@ async function isDirectory(path: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function safeReadDir(path: string) {
|
||||||
|
try {
|
||||||
|
return await readdir(path)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBinName(name: string) {
|
function normalizeBinName(name: string) {
|
||||||
const slash = name.lastIndexOf("/")
|
const slash = name.lastIndexOf("/")
|
||||||
if (slash >= 0) {
|
if (slash >= 0) {
|
||||||
|
|||||||
+4
-9
@@ -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.9",
|
"packageManager": "bun@1.3.5",
|
||||||
"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.9",
|
"@types/bun": "1.3.5",
|
||||||
"@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,13 +35,11 @@
|
|||||||
"@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.1.0-beta.13",
|
"@pierre/diffs": "1.0.2",
|
||||||
"@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",
|
||||||
@@ -87,8 +85,6 @@
|
|||||||
"url": "https://github.com/anomalyco/opencode"
|
"url": "https://github.com/anomalyco/opencode"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"randomField": "hello-world-12345",
|
|
||||||
"anotherRandomField": "xyz-abc-789",
|
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"printWidth": 120
|
"printWidth": 120
|
||||||
@@ -105,7 +101,6 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
|
||||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
[test]
|
[test]
|
||||||
root = "./src"
|
|
||||||
preload = ["./happydom.ts"]
|
preload = ["./happydom.ts"]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { defocus, openSidebar, withSession } from "../actions"
|
import { 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 })
|
||||||
@@ -41,84 +40,3 @@ 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,28 +1,15 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { openPalette, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
const dialog = await openPalette(page)
|
||||||
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")
|
||||||
|
|
||||||
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
await clickListItem(dialog, { keyStartsWith: "file:" })
|
||||||
await expect(item).toBeVisible({ timeout: 30_000 })
|
|
||||||
await item.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,37 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const toggle = page.getByRole("button", { name: "Toggle file tree" })
|
const toggle = page.getByRole("button", { name: "Toggle file tree" })
|
||||||
const panel = page.locator("#file-tree-panel")
|
const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
|
||||||
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()
|
||||||
|
|
||||||
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
|
await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click()
|
||||||
await expect(allTab).toBeVisible()
|
|
||||||
await allTab.click()
|
|
||||||
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
|
const node = (name: string) => treeTabs.getByRole("button", { name, exact: true })
|
||||||
await expect(tree).toBeVisible()
|
|
||||||
|
|
||||||
const expand = async (name: string) => {
|
await expect(node("packages")).toBeVisible()
|
||||||
const folder = tree.getByRole("button", { name, exact: true }).first()
|
await node("packages").click()
|
||||||
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 expand("packages")
|
await expect(node("app")).toBeVisible()
|
||||||
await expand("app")
|
await node("app").click()
|
||||||
await expand("src")
|
|
||||||
await expand("components")
|
|
||||||
|
|
||||||
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
|
await expect(node("src")).toBeVisible()
|
||||||
await expect(file).toBeVisible()
|
await node("src").click()
|
||||||
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).toBeVisible()
|
await expect(code.getByText("export default function FileTree")).toBeVisible()
|
||||||
await expect(code).toContainText("export default function FileTree")
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { openPalette, clickListItem } from "../actions"
|
||||||
|
|
||||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
const sep = process.platform === "win32" ? "\\" : "/"
|
||||||
await page.keyboard.type("/open")
|
const file = ["packages", "app", "package.json"].join(sep)
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="file.open"]').first()
|
const dialog = await openPalette(page)
|
||||||
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(file)
|
||||||
|
|
||||||
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
||||||
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)
|
||||||
|
|
||||||
@@ -45,5 +22,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(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
await expect(code.getByText("@opencode-ai/app")).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||||
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
|
import { projectCloseHoverSelector, projectCloseMenuSelector, 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,15 +31,16 @@ test("can close a project via hover card close button", async ({ page, withProje
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
test("can close a project via project header more options menu", 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 ({ slug }) => {
|
async () => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
@@ -48,20 +49,21 @@ test("closing active project navigates to another open project", async ({ page,
|
|||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, otherSlug)
|
const header = page
|
||||||
|
.locator(".group\\/project")
|
||||||
|
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||||
|
.first()
|
||||||
|
await expect(header).toContainText(otherName)
|
||||||
|
|
||||||
|
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||||
|
await expect(trigger).toHaveCount(1)
|
||||||
|
await trigger.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await 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] },
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
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"
|
||||||
|
|
||||||
@@ -15,8 +14,7 @@ import {
|
|||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { 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] ?? ""
|
||||||
@@ -128,49 +126,6 @@ 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 })
|
||||||
|
|
||||||
@@ -259,45 +214,14 @@ 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 sdk = createSdk(project.directory)
|
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
||||||
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,95 +1,40 @@
|
|||||||
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 seedContextSession({ sessionID: session.id, sdk })
|
await sdk.session.promptAsync({
|
||||||
|
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 trigger = contextButton(page)
|
const contextButton = page
|
||||||
await expect(trigger).toBeVisible()
|
.locator('[data-component="button"]')
|
||||||
await trigger.click()
|
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||||
|
.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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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,6 +44,9 @@ 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,11 +10,8 @@ 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"]'
|
||||||
|
|
||||||
@@ -30,9 +27,6 @@ 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}"]`
|
||||||
|
|
||||||
@@ -54,9 +48,6 @@ 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}"]`
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
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,34 +34,21 @@ 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 renamedTitle = `e2e renamed ${stamp}`
|
const newTitle = `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 expect(input).toBeFocused()
|
await input.fill(newTitle)
|
||||||
await input.fill(renamedTitle)
|
|
||||||
await expect(input).toHaveValue(renamedTitle)
|
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
||||||
.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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -129,14 +116,8 @@ 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 shared = await openSharePopover(page)
|
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
||||||
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(
|
||||||
@@ -148,14 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
||||||
|
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const sharedPopover = await openSharePopover(page)
|
||||||
|
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
await 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 () => {
|
||||||
@@ -166,8 +147,10 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
const unshared = await openSharePopover(page)
|
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
||||||
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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSettings, closeDialog, withSession } from "../actions"
|
import { openSettings, closeDialog, withSession } from "../actions"
|
||||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
import { keybindButtonSelector } from "../selectors"
|
||||||
import { modKey } from "../utils"
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||||
@@ -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")).first()
|
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
||||||
await expect(keybindButton).toBeVisible()
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
const initialKeybind = await keybindButton.textContent()
|
||||||
@@ -51,40 +51,6 @@ 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" } }))
|
||||||
@@ -301,52 +267,11 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
|||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
await expect(terminal).not.toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
|
||||||
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 page.waitForTimeout(100)
|
||||||
|
|
||||||
await expect(keybindButton).toContainText("Y")
|
const pageStable = await page.evaluate(() => document.readyState === "complete")
|
||||||
await closeDialog(page, dialog)
|
expect(pageStable).toBe(true)
|
||||||
|
|
||||||
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 }) => {
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import {
|
|||||||
settingsNotificationsPermissionsSelector,
|
settingsNotificationsPermissionsSelector,
|
||||||
settingsReleaseNotesSelector,
|
settingsReleaseNotesSelector,
|
||||||
settingsSoundsAgentSelector,
|
settingsSoundsAgentSelector,
|
||||||
settingsSoundsAgentEnabledSelector,
|
|
||||||
settingsSoundsErrorsSelector,
|
|
||||||
settingsSoundsPermissionsSelector,
|
|
||||||
settingsThemeSelector,
|
settingsThemeSelector,
|
||||||
settingsUpdatesStartupSelector,
|
settingsUpdatesStartupSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
@@ -142,105 +139,6 @@ 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()
|
||||||
|
|
||||||
@@ -336,91 +234,6 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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, withSession } from "../actions"
|
import { openSidebar, toggleSidebar } 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,26 +12,3 @@ 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,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.6",
|
"version": "1.1.51",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./vite": "./vite.js",
|
"./vite": "./vite.js"
|
||||||
"./index.css": "./src/index.css"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsgo -b",
|
"typecheck": "tsgo -b",
|
||||||
@@ -14,9 +13,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit",
|
"test": "playwright test",
|
||||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "bun script/e2e-local.ts",
|
"test:e2e:local": "bun script/e2e-local.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
@@ -57,7 +54,7 @@
|
|||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "0.4.0",
|
"ghostty-web": "0.3.0",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
expect: {
|
expect: {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ const extraArgs = (() => {
|
|||||||
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
|
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
|
||||||
|
|
||||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||||
const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1"
|
|
||||||
|
|
||||||
const serverEnv = {
|
const serverEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -84,95 +83,58 @@ const runnerEnv = {
|
|||||||
PLAYWRIGHT_PORT: String(webPort),
|
PLAYWRIGHT_PORT: String(webPort),
|
||||||
} satisfies Record<string, string>
|
} satisfies Record<string, string>
|
||||||
|
|
||||||
let seed: ReturnType<typeof Bun.spawn> | undefined
|
const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
||||||
let runner: ReturnType<typeof Bun.spawn> | undefined
|
cwd: opencodeDir,
|
||||||
let server: { stop: () => Promise<void> | void } | undefined
|
env: serverEnv,
|
||||||
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
stdout: "inherit",
|
||||||
let cleaned = false
|
stderr: "inherit",
|
||||||
|
|
||||||
const cleanup = async () => {
|
|
||||||
if (cleaned) return
|
|
||||||
cleaned = true
|
|
||||||
|
|
||||||
if (seed && seed.exitCode === null) seed.kill("SIGTERM")
|
|
||||||
if (runner && runner.exitCode === null) runner.kill("SIGTERM")
|
|
||||||
|
|
||||||
const jobs = [
|
|
||||||
inst?.Instance.disposeAll(),
|
|
||||||
server?.stop(),
|
|
||||||
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
|
||||||
].filter(Boolean)
|
|
||||||
await Promise.allSettled(jobs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const shutdown = (code: number, reason: string) => {
|
|
||||||
process.exitCode = code
|
|
||||||
void cleanup().finally(() => {
|
|
||||||
console.error(`e2e-local shutdown: ${reason}`)
|
|
||||||
process.exit(code)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportInternalError = (reason: string, error: unknown) => {
|
|
||||||
console.warn(`e2e-local ignored server error: ${reason}`)
|
|
||||||
console.warn(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
process.once("SIGINT", () => shutdown(130, "SIGINT"))
|
|
||||||
process.once("SIGTERM", () => shutdown(143, "SIGTERM"))
|
|
||||||
process.once("SIGHUP", () => shutdown(129, "SIGHUP"))
|
|
||||||
process.once("uncaughtException", (error) => {
|
|
||||||
reportInternalError("uncaughtException", error)
|
|
||||||
})
|
|
||||||
process.once("unhandledRejection", (error) => {
|
|
||||||
reportInternalError("unhandledRejection", error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let code = 1
|
const seedExit = await seed.exited
|
||||||
|
if (seedExit !== 0) {
|
||||||
|
process.exit(seedExit)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
Object.assign(process.env, serverEnv)
|
||||||
seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
process.env.AGENT = "1"
|
||||||
cwd: opencodeDir,
|
process.env.OPENCODE = "1"
|
||||||
env: serverEnv,
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
})
|
|
||||||
|
|
||||||
const seedExit = await seed.exited
|
const log = await import("../../opencode/src/util/log")
|
||||||
if (seedExit !== 0) {
|
const install = await import("../../opencode/src/installation")
|
||||||
code = seedExit
|
await log.Log.init({
|
||||||
} else {
|
print: true,
|
||||||
Object.assign(process.env, serverEnv)
|
dev: install.Installation.isLocal(),
|
||||||
process.env.AGENT = "1"
|
level: "WARN",
|
||||||
process.env.OPENCODE = "1"
|
})
|
||||||
|
|
||||||
const log = await import("../../opencode/src/util/log")
|
const servermod = await import("../../opencode/src/server/server")
|
||||||
const install = await import("../../opencode/src/installation")
|
const inst = await import("../../opencode/src/project/instance")
|
||||||
await log.Log.init({
|
const server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||||
print: true,
|
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||||
dev: install.Installation.isLocal(),
|
|
||||||
level: "WARN",
|
|
||||||
})
|
|
||||||
|
|
||||||
const servermod = await import("../../opencode/src/server/server")
|
|
||||||
inst = await import("../../opencode/src/project/instance")
|
|
||||||
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
|
||||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
|
||||||
|
|
||||||
|
const result = await (async () => {
|
||||||
|
try {
|
||||||
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
||||||
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
|
||||||
|
const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
env: runnerEnv,
|
env: runnerEnv,
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
})
|
})
|
||||||
code = await runner.exited
|
|
||||||
|
return { code: await runner.exited }
|
||||||
|
} catch (error) {
|
||||||
|
return { error }
|
||||||
|
} finally {
|
||||||
|
await inst.Instance.disposeAll()
|
||||||
|
await server.stop()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})()
|
||||||
console.error(error)
|
|
||||||
code = 1
|
if ("error" in result) {
|
||||||
} finally {
|
console.error(result.error)
|
||||||
await cleanup()
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(code)
|
process.exit(result.code)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SerializeAddon", () => {
|
describe.skip("SerializeAddon", () => {
|
||||||
describe("ANSI color preservation", () => {
|
describe("ANSI color preservation", () => {
|
||||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||||
const { term, addon } = createTerminal()
|
const { term, addon } = createTerminal()
|
||||||
|
|||||||
@@ -56,39 +56,6 @@ interface IBufferCell {
|
|||||||
isDim(): boolean
|
isDim(): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalBuffers = {
|
|
||||||
active?: IBuffer
|
|
||||||
normal?: IBuffer
|
|
||||||
alternate?: IBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
||||||
return typeof value === "object" && value !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBuffer = (value: unknown): value is IBuffer => {
|
|
||||||
if (!isRecord(value)) return false
|
|
||||||
if (typeof value.length !== "number") return false
|
|
||||||
if (typeof value.cursorX !== "number") return false
|
|
||||||
if (typeof value.cursorY !== "number") return false
|
|
||||||
if (typeof value.baseY !== "number") return false
|
|
||||||
if (typeof value.viewportY !== "number") return false
|
|
||||||
if (typeof value.getLine !== "function") return false
|
|
||||||
if (typeof value.getNullCell !== "function") return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => {
|
|
||||||
if (!isRecord(value)) return
|
|
||||||
const raw = value.buffer
|
|
||||||
if (!isRecord(raw)) return
|
|
||||||
const active = isBuffer(raw.active) ? raw.active : undefined
|
|
||||||
const normal = isBuffer(raw.normal) ? raw.normal : undefined
|
|
||||||
const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined
|
|
||||||
if (!active && !normal) return
|
|
||||||
return { active, normal, alternate }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -531,13 +498,14 @@ export class SerializeAddon implements ITerminalAddon {
|
|||||||
throw new Error("Cannot use addon until it has been loaded")
|
throw new Error("Cannot use addon until it has been loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = getTerminalBuffers(this._terminal)
|
const terminal = this._terminal as any
|
||||||
|
const buffer = terminal.buffer
|
||||||
|
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalBuffer = buffer.normal ?? buffer.active
|
const normalBuffer = buffer.normal || buffer.active
|
||||||
const altBuffer = buffer.alternate
|
const altBuffer = buffer.alternate
|
||||||
|
|
||||||
if (!normalBuffer) {
|
if (!normalBuffer) {
|
||||||
@@ -565,13 +533,14 @@ export class SerializeAddon implements ITerminalAddon {
|
|||||||
throw new Error("Cannot use addon until it has been loaded")
|
throw new Error("Cannot use addon until it has been loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = getTerminalBuffers(this._terminal)
|
const terminal = this._terminal as any
|
||||||
|
const buffer = terminal.buffer
|
||||||
|
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeBuffer = buffer.active ?? buffer.normal
|
const activeBuffer = buffer.active || buffer.normal
|
||||||
if (!activeBuffer) {
|
if (!activeBuffer) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-99
@@ -1,5 +1,5 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
|
import { ErrorBoundary, Show, lazy, 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,26 +30,12 @@ import { HighlightsProvider } from "@/context/highlights"
|
|||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
|
import { Suspense } from "solid-js"
|
||||||
|
|
||||||
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>
|
||||||
@@ -57,7 +43,7 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
|
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,71 +52,6 @@ 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>
|
||||||
@@ -163,31 +84,79 @@ function ServerKey(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
export function AppInterface(props: { defaultUrl?: string }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
|
|
||||||
const defaultServerUrl = resolveDefaultServerUrl({
|
const stored = (() => {
|
||||||
defaultUrl: props.defaultUrl,
|
if (platform.platform !== "web") return
|
||||||
storedDefaultServerUrl,
|
const result = platform.getDefaultServerUrl?.()
|
||||||
hostname: location.hostname,
|
if (result instanceof Promise) return
|
||||||
origin: window.location.origin,
|
if (!result) return
|
||||||
isDev: import.meta.env.DEV,
|
return normalizeServerUrl(result)
|
||||||
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()}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Router
|
<Router
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
root={(props) => (
|
||||||
|
<SettingsProvider>
|
||||||
|
<PermissionProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<ModelsProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<HighlightsProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</HighlightsProvider>
|
||||||
|
</CommandProvider>
|
||||||
|
</ModelsProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Route path="/" component={HomeRoute} />
|
<Route
|
||||||
|
path="/"
|
||||||
|
component={() => (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Home />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={SessionIndexRoute} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route
|
||||||
|
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,6 +10,7 @@ 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"
|
||||||
@@ -54,47 +55,6 @@ 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 }) => {
|
||||||
@@ -103,24 +63,6 @@ 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)
|
||||||
@@ -128,10 +70,17 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
dispatch({ type: "method.select", index })
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.methodIndex = index
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
dispatch({ type: "auth.pending" })
|
setStore("state", "pending")
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await globalSDK.client.provider.oauth
|
||||||
.authorize(
|
.authorize(
|
||||||
@@ -151,15 +100,18 @@ 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
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
setStore("state", "complete")
|
||||||
|
setStore("authorization", x.data!)
|
||||||
}, delay)
|
}, delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.complete", authorization: x.data! })
|
setStore("state", "complete")
|
||||||
|
setStore("authorization", x.data!)
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
setStore("state", "error")
|
||||||
|
setStore("error", String(e))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +129,10 @@ 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() {
|
||||||
@@ -196,243 +152,17 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.authorization) {
|
if (store.authorization) {
|
||||||
dispatch({ type: "method.reset" })
|
setStore("authorization", undefined)
|
||||||
|
setStore("methodIndex", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.methodIndex !== undefined) {
|
if (store.methodIndex) {
|
||||||
dispatch({ type: "method.reset" })
|
setStore("methodIndex", undefined)
|
||||||
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={
|
||||||
@@ -458,42 +188,267 @@ 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">
|
||||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
<Switch>
|
||||||
<Switch>
|
<Match when={store.methodIndex === undefined}>
|
||||||
<Match when={store.methodIndex === undefined}>
|
<div class="text-14-regular text-text-base">
|
||||||
<MethodSelection />
|
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||||
</Match>
|
</div>
|
||||||
<Match when={store.state === "pending"}>
|
<div class="">
|
||||||
<div class="text-14-regular text-text-base">
|
<List
|
||||||
<div class="flex items-center gap-x-2">
|
ref={(ref) => {
|
||||||
<Spinner />
|
listRef = ref
|
||||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
}}
|
||||||
</div>
|
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>
|
</div>
|
||||||
<Match when={store.state === "error"}>
|
</Match>
|
||||||
<div class="text-14-regular text-text-base">
|
<Match when={store.state === "error"}>
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="text-14-regular text-text-base">
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
<div class="flex items-center gap-x-2">
|
||||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
</div>
|
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</div>
|
||||||
<Match when={method()?.type === "api"}>
|
</Match>
|
||||||
<ApiAuthView />
|
<Match when={method()?.type === "api"}>
|
||||||
</Match>
|
{iife(() => {
|
||||||
<Match when={method()?.type === "oauth"}>
|
const [formStore, setFormStore] = createStore({
|
||||||
<Switch>
|
value: "",
|
||||||
<Match when={store.authorization?.method === "code"}>
|
error: undefined as string | undefined,
|
||||||
<OAuthCodeView />
|
})
|
||||||
</Match>
|
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
<OAuthAutoView />
|
e.preventDefault()
|
||||||
</Match>
|
|
||||||
</Switch>
|
const form = e.currentTarget as HTMLFormElement
|
||||||
</Match>
|
const formData = new FormData(form)
|
||||||
</Switch>
|
const apiKey = formData.get("apiKey") as string
|
||||||
</div>
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Match>
|
||||||
|
<Match when={method()?.type === "oauth"}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={store.authorization?.method === "code"}>
|
||||||
|
{iife(() => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 } from "solid-js/store"
|
import { createStore, produce } 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,147 +16,6 @@ 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"
|
||||||
}
|
}
|
||||||
@@ -167,7 +26,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore<FormState>({
|
const [form, setForm] = createStore({
|
||||||
providerID: "",
|
providerID: "",
|
||||||
name: "",
|
name: "",
|
||||||
baseURL: "",
|
baseURL: "",
|
||||||
@@ -177,12 +36,12 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
saving: false,
|
saving: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [errors, setErrors] = createStore<FormErrors>({
|
const [errors, setErrors] = createStore({
|
||||||
providerID: undefined,
|
providerID: undefined as string | undefined,
|
||||||
name: undefined,
|
name: undefined as string | undefined,
|
||||||
baseURL: undefined,
|
baseURL: undefined as string | undefined,
|
||||||
models: [{}],
|
models: [{} as { id?: string; name?: string }],
|
||||||
headers: [{}],
|
headers: [{} as { key?: string; value?: string }],
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
@@ -194,36 +53,169 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
setForm(
|
||||||
setErrors("models", (v) => [...v, {}])
|
"models",
|
||||||
|
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("models", (v) => v.filter((_, i) => i !== index))
|
setForm(
|
||||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
"models",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setErrors(
|
||||||
|
"models",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
setForm(
|
||||||
setErrors("headers", (v) => [...v, {}])
|
"headers",
|
||||||
|
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("headers", (v) => v.filter((_, i) => i !== index))
|
setForm(
|
||||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
"headers",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setErrors(
|
||||||
|
"headers",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const output = validateCustomProvider({
|
const providerID = form.providerID.trim()
|
||||||
form,
|
const name = form.name.trim()
|
||||||
t: language.t,
|
const baseURL = form.baseURL.trim()
|
||||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
const apiKey = form.apiKey.trim()
|
||||||
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
|
||||||
|
? "Provider ID is required"
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? "Use lowercase letters, numbers, hyphens, or underscores"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? "Display name is required" : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? "Base URL is required"
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? "Must start with http:// or https://"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
||||||
|
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: existingProvider && !disabled
|
||||||
|
? "That provider ID already exists"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const modelErrors = form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const modelIdError = !id
|
||||||
|
? "Required"
|
||||||
|
: seenModels.has(id)
|
||||||
|
? "Duplicate"
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const modelNameError = !m.name.trim() ? "Required" : undefined
|
||||||
|
return { id: modelIdError, name: modelNameError }
|
||||||
})
|
})
|
||||||
setErrors(output.errors)
|
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||||
return output.result
|
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headerErrors = form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? "Required"
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? "Duplicate"
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? "Required" : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
setErrors(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.providerID = idError ?? existsError
|
||||||
|
draft.name = nameError
|
||||||
|
draft.baseURL = urlError
|
||||||
|
draft.models = modelErrors
|
||||||
|
draft.headers = headerErrors
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options,
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async (e: SubmitEvent) => {
|
const save = async (e: SubmitEvent) => {
|
||||||
@@ -286,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
<div class="text-16-medium text-text-strong">Custom provider</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||||
<p class="text-14-regular text-text-base">
|
<p class="text-14-regular text-text-base">
|
||||||
{language.t("provider.custom.description.prefix")}
|
Configure an OpenAI-compatible provider. See the{" "}
|
||||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||||
{language.t("provider.custom.description.link")}
|
provider config docs
|
||||||
</Link>
|
</Link>
|
||||||
{language.t("provider.custom.description.suffix")}
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus
|
||||||
label={language.t("provider.custom.field.providerID.label")}
|
label="Provider ID"
|
||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder="myprovider"
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description="Lowercase letters, numbers, hyphens, or underscores"
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={(v) => setForm("providerID", v)}
|
onChange={setForm.bind(null, "providerID")}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={errors.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={errors.providerID}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.name.label")}
|
label="Display name"
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
placeholder="My AI Provider"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(v) => setForm("name", v)}
|
onChange={setForm.bind(null, "name")}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={errors.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.baseURL.label")}
|
label="Base URL"
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
placeholder="https://api.myprovider.com/v1"
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={(v) => setForm("baseURL", v)}
|
onChange={setForm.bind(null, "baseURL")}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={errors.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={errors.baseURL}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.apiKey.label")}
|
label="API key"
|
||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
placeholder="API key"
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
description="Optional. Leave empty if you manage auth via headers."
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(v) => setForm("apiKey", v)}
|
onChange={setForm.bind(null, "apiKey")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
<label class="text-12-medium text-text-weak">Models</label>
|
||||||
<For each={form.models}>
|
<For each={form.models}>
|
||||||
{(m, i) => (
|
{(m, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.models.id.label")}
|
label="ID"
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
placeholder="model-id"
|
||||||
value={m.id}
|
value={m.id}
|
||||||
onChange={(v) => setForm("models", i(), "id", v)}
|
onChange={(v) => setForm("models", i(), "id", v)}
|
||||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||||
@@ -352,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.models.name.label")}
|
label="Name"
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
placeholder="Display Name"
|
||||||
value={m.name}
|
value={m.name}
|
||||||
onChange={(v) => setForm("models", i(), "name", v)}
|
onChange={(v) => setForm("models", i(), "name", v)}
|
||||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||||
@@ -368,26 +360,26 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
class="mt-1.5"
|
class="mt-1.5"
|
||||||
onClick={() => removeModel(i())}
|
onClick={() => removeModel(i())}
|
||||||
disabled={form.models.length <= 1}
|
disabled={form.models.length <= 1}
|
||||||
aria-label={language.t("provider.custom.models.remove")}
|
aria-label="Remove model"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||||
{language.t("provider.custom.models.add")}
|
Add model
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
<label class="text-12-medium text-text-weak">Headers (optional)</label>
|
||||||
<For each={form.headers}>
|
<For each={form.headers}>
|
||||||
{(h, i) => (
|
{(h, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.headers.key.label")}
|
label="Header"
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
placeholder="Header-Name"
|
||||||
value={h.key}
|
value={h.key}
|
||||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||||
@@ -396,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.headers.value.label")}
|
label="Value"
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
placeholder="value"
|
||||||
value={h.value}
|
value={h.value}
|
||||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||||
@@ -412,18 +404,18 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
class="mt-1.5"
|
class="mt-1.5"
|
||||||
onClick={() => removeHeader(i())}
|
onClick={() => removeHeader(i())}
|
||||||
disabled={form.headers.length <= 1}
|
disabled={form.headers.length <= 1}
|
||||||
aria-label={language.t("provider.custom.headers.remove")}
|
aria-label="Remove header"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||||
{language.t("provider.custom.headers.add")}
|
Add header
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
{form.saving ? "Saving..." : language.t("common.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ 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()
|
||||||
@@ -74,35 +72,31 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
await Promise.resolve()
|
setStore("saving", true)
|
||||||
.then(async () => {
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
setStore("saving", true)
|
const start = store.startup.trim()
|
||||||
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)
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
dialog.close()
|
setStore("saving", false)
|
||||||
return
|
dialog.close()
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
globalSync.project.meta(props.project.worktree, {
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
commands: { start: start || undefined },
|
commands: { start: start || undefined },
|
||||||
})
|
})
|
||||||
dialog.close()
|
setStore("saving", false)
|
||||||
})
|
dialog.close()
|
||||||
.finally(() => {
|
|
||||||
setStore("saving", false)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,7 +134,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
if (store.iconUrl && store.iconHover) {
|
if (store.iconUrl && store.iconHover) {
|
||||||
clearIcon()
|
clearIcon()
|
||||||
} else {
|
} else {
|
||||||
iconInput?.click()
|
document.getElementById("icon-upload")?.click()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -182,16 +176,7 @@ 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
|
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
||||||
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>
|
||||||
@@ -238,7 +223,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
value={store.startup}
|
value={store.startup}
|
||||||
onChange={(v) => setStore("startup", v)}
|
onChange={(v) => setStore("startup", v)}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
class="max-h-40 w-full font-mono text-xs no-scrollbar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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"
|
||||||
@@ -67,23 +66,15 @@ export const DialogFork: Component = () => {
|
|||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.client.session
|
dialog.close()
|
||||||
.fork({ sessionID, messageID: item.id })
|
|
||||||
.then((forked) => {
|
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
|
||||||
if (!forked.data) {
|
if (!forked.data) return
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||||
return
|
requestAnimationFrame(() => {
|
||||||
}
|
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,7 +1,6 @@
|
|||||||
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"
|
||||||
@@ -18,15 +17,6 @@ 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
|
||||||
@@ -45,41 +35,21 @@ 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.id}
|
groupBy={(x) => x.provider.name}
|
||||||
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 aRank = providerRank(a.items[0].provider.id)
|
const aProvider = a.items[0].provider.id
|
||||||
const bRank = providerRank(b.items[0].provider.id)
|
const bProvider = b.items[0].provider.id
|
||||||
const aPopular = aRank >= 0
|
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||||
const bPopular = bRank >= 0
|
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||||
if (aPopular && !bPopular) return -1
|
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||||
if (!aPopular && bPopular) return 1
|
|
||||||
return aRank - bRank
|
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
const key = { modelID: x.id, providerID: x.provider.id }
|
const visible = local.model.visible({
|
||||||
local.model.setVisibility(key, !local.model.visible(key))
|
modelID: x.id,
|
||||||
|
providerID: x.provider.id,
|
||||||
|
})
|
||||||
|
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
@@ -87,7 +57,12 @@ 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={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
checked={
|
||||||
|
!!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 } from "solid-js"
|
import { createSignal, createEffect, onMount, onCleanup } 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,6 +40,8 @@ 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()
|
||||||
@@ -58,13 +60,27 @@ 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"
|
||||||
>
|
>
|
||||||
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
|
{/* Hidden element to capture initial focus and handle escape */}
|
||||||
|
<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,131 +21,157 @@ type Row = {
|
|||||||
search: string
|
search: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanInput(value: string) {
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
const sync = useGlobalSync()
|
||||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
const sdk = useGlobalSDK()
|
||||||
}
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
function normalizePath(input: string) {
|
const [filter, setFilter] = createSignal("")
|
||||||
const v = input.replaceAll("\\", "/")
|
|
||||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
|
||||||
return v.replace(/\/+/g, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDriveRoot(input: string) {
|
let list: ListRef | undefined
|
||||||
const v = normalizePath(input)
|
|
||||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimTrailing(input: string) {
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
return v.replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinPath(base: string | undefined, rel: string) {
|
const [fallbackPath] = createResource(
|
||||||
const b = trimTrailing(base ?? "")
|
() => (missingBase() ? true : undefined),
|
||||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
async () => {
|
||||||
if (!b) return r
|
return sdk.client.path
|
||||||
if (!r) return b
|
.get()
|
||||||
if (b.endsWith("/")) return b + r
|
.then((x) => x.data)
|
||||||
return b + "/" + r
|
.catch(() => undefined)
|
||||||
}
|
},
|
||||||
|
{ initialValue: undefined },
|
||||||
|
)
|
||||||
|
|
||||||
function rootOf(input: string) {
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
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 start = createMemo(
|
||||||
const v = trimTrailing(input)
|
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||||
if (v === "/") return v
|
)
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
|
|
||||||
const i = v.lastIndexOf("/")
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
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 clean = (value: string) => {
|
||||||
const raw = normalizeDriveRoot(input.trim())
|
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||||
if (!raw) return "relative" as const
|
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||||
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(
|
function normalize(input: string) {
|
||||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
const v = input.replaceAll("\\", "/")
|
||||||
).join("\n")
|
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||||
return { absolute: full, search }
|
return v.replace(/\/+/g, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDirectorySearch(args: {
|
function normalizeDriveRoot(input: string) {
|
||||||
sdk: ReturnType<typeof useGlobalSDK>
|
const v = normalize(input)
|
||||||
start: () => string | undefined
|
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||||
home: () => string
|
return v
|
||||||
}) {
|
}
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
|
||||||
let current = 0
|
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
function trimTrailing(input: string) {
|
||||||
const base = args.start()
|
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 = args.home()
|
const h = 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirs = async (dir: string) => {
|
async function dirs(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 = args.sdk.client.file
|
const request = sdk.client.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ directory: key, path: "" })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -162,34 +188,32 @@ function useDirectorySearch(args: {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = async (dir: string, query: string, limit: number) => {
|
async function match(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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return async (filter: string) => {
|
const directories = async (filter: string) => {
|
||||||
const token = ++current
|
const value = clean(filter)
|
||||||
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 = () =>
|
||||||
args.sdk.client.find
|
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) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = query.replace(/^\/+/, "").split("/")
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
@@ -200,20 +224,17 @@ function useDirectorySearch(args: {
|
|||||||
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("/")
|
||||||
@@ -228,47 +249,13 @@ function useDirectorySearch(args: {
|
|||||||
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((absolute) => toRow(absolute, home()))
|
return results.map(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
@@ -286,7 +273,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(cleanInput(value))}
|
onFilter={(value) => setFilter(clean(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
|
||||||
@@ -295,7 +282,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const value = displayPath(item.absolute, filter(), home())
|
const value = display(item.absolute, filter())
|
||||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||||
}}
|
}}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
@@ -304,7 +291,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const path = displayPath(item.absolute, filter(), home())
|
const path = display(item.absolute, filter())
|
||||||
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">
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
|
||||||
|
|
||||||
type EntryType = "command" | "file" | "session"
|
type EntryType = "command" | "file" | "session"
|
||||||
|
|
||||||
@@ -31,228 +30,10 @@ type Entry = {
|
|||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
archived?: number
|
archived?: number
|
||||||
updated?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -266,11 +47,42 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
const tabs = createMemo(() => layout.tabs(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 commandEntries = createCommandEntries({ filesOnly, command, language })
|
const common = [
|
||||||
const fileEntries = createFileEntries({ file, tabs, language })
|
"session.new",
|
||||||
|
"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(() => {
|
||||||
@@ -301,7 +113,133 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
|
const sessionItem = (input: {
|
||||||
|
directory: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
archived?: 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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.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()
|
||||||
@@ -310,7 +248,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 = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
const next = unique([...recent(), ...root()])
|
||||||
|
|
||||||
if (loaded || next.length > 0) {
|
if (loaded || next.length > 0) {
|
||||||
void pending
|
void pending
|
||||||
@@ -318,21 +256,19 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
return unique([...recent(), ...root()])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
if (!query) return [...picks(), ...recent()]
|
||||||
|
|
||||||
if (filesOnly()) {
|
if (filesOnly()) {
|
||||||
const files = await file.searchFiles(query)
|
const files = await file.searchFiles(query)
|
||||||
const category = language.t("palette.group.files")
|
return files.map(fileItem)
|
||||||
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 category = language.t("palette.group.files")
|
const entries = files.map(fileItem)
|
||||||
const entries = files.map((path) => createFileEntry(path, category))
|
return [...list(), ...nextSessions, ...entries]
|
||||||
return [...commandEntries.list(), ...nextSessions, ...entries]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (item: Entry | undefined) => {
|
const handleMove = (item: Entry | undefined) => {
|
||||||
@@ -346,10 +282,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const value = file.tab(path)
|
const value = file.tab(path)
|
||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
file.load(path)
|
file.load(path)
|
||||||
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) => {
|
||||||
@@ -447,11 +382,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.updated}>
|
|
||||||
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
|
||||||
{getRelativeTime(new Date(item.updated!).toISOString())}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -6,13 +6,6 @@ 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()
|
||||||
@@ -28,19 +21,15 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const toggle = async (name: string) => {
|
const toggle = async (name: string) => {
|
||||||
if (loading()) return
|
if (loading()) return
|
||||||
setLoading(name)
|
setLoading(name)
|
||||||
try {
|
const status = sync.data.mcp[name]
|
||||||
const status = sync.data.mcp[name]
|
if (status?.status === "connected") {
|
||||||
if (status?.status === "connected") {
|
await sdk.client.mcp.disconnect({ name })
|
||||||
await sdk.client.mcp.disconnect({ name })
|
} else {
|
||||||
} else {
|
await sdk.client.mcp.connect({ name })
|
||||||
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)
|
||||||
@@ -65,11 +54,6 @@ 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
|
||||||
@@ -80,8 +64,17 @@ 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={statusLabel()}>
|
<Show when={status() === "connected"}>
|
||||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</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, Show } from "solid-js"
|
import { type Component, onCleanup, onMount, 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,17 +21,24 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKey = (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" onKeyDown={handleKeyDown}>
|
<div class="flex flex-col gap-3 px-2.5">
|
||||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||||
<List
|
<List
|
||||||
class="[&_[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, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, 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,9 +15,6 @@ 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
|
||||||
@@ -57,7 +54,13 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
value={
|
||||||
|
<ModelTooltip
|
||||||
|
model={item}
|
||||||
|
latest={item.latest}
|
||||||
|
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -72,7 +75,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={isFree(i.provider.id, i.cost)}>
|
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
||||||
<Tag>{language.t("model.tag.free")}</Tag>
|
<Tag>{language.t("model.tag.free")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.latest}>
|
<Show when={i.latest}>
|
||||||
@@ -84,20 +87,22 @@ const ModelList: Component<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
|
||||||
provider?: string
|
provider?: string
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
triggerAs?: ValidComponent
|
triggerAs?: T
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
triggerProps?: ComponentProps<T>
|
||||||
}) {
|
}) {
|
||||||
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()
|
||||||
|
|
||||||
@@ -112,6 +117,54 @@ 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}
|
||||||
@@ -123,11 +176,16 @@ export function ModelSelectorPopover(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={8}
|
gutter={8}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger
|
||||||
|
ref={(el) => setStore("trigger", el)}
|
||||||
|
as={props.triggerAs ?? "div"}
|
||||||
|
{...(props.triggerProps as any)}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<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,12 +24,6 @@ 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>
|
||||||
@@ -40,7 +34,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
@@ -76,7 +70,15 @@ 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={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
<Show when={i.id === "anthropic"}>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -6,15 +6,17 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { ServerRow } from "@/components/server/server-row"
|
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
type ServerStatus = { healthy: boolean; version?: string }
|
||||||
|
|
||||||
interface AddRowProps {
|
interface AddRowProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -38,62 +40,17 @@ interface EditRowProps {
|
|||||||
onBlur: () => void
|
onBlur: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
|
||||||
showToast({
|
const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
|
||||||
variant: "error",
|
const sdk = createOpencodeClient({
|
||||||
title: language.t("common.requestFailed"),
|
baseUrl: url,
|
||||||
description: err instanceof Error ? err.message : String(err),
|
fetch: platform.fetch,
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
}
|
return sdk.global
|
||||||
|
.health()
|
||||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
.catch(() => ({ healthy: false }))
|
||||||
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) {
|
||||||
@@ -173,12 +130,8 @@ 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, ServerStatus | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
adding: false,
|
adding: false,
|
||||||
@@ -194,6 +147,42 @@ 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 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 checkHealth(normalized, platform)
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
const resetAdd = () => {
|
const resetAdd = () => {
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
@@ -238,7 +227,7 @@ export function DialogSelectServer() {
|
|||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
const rank = (value?: ServerHealth) => {
|
const rank = (value?: ServerStatus) => {
|
||||||
if (value?.healthy === true) return 0
|
if (value?.healthy === true) return 0
|
||||||
if (value?.healthy === false) return 2
|
if (value?.healthy === false) return 2
|
||||||
return 1
|
return 1
|
||||||
@@ -253,10 +242,10 @@ export function DialogSelectServer() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function refreshHealth() {
|
async function refreshHealth() {
|
||||||
const results: Record<string, ServerHealth> = {}
|
const results: Record<string, ServerStatus> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items().map(async (url) => {
|
items().map(async (url) => {
|
||||||
results[url] = await checkServerHealth(url, fetcher)
|
results[url] = await checkHealth(url, platform)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
@@ -288,7 +277,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollListToBottom = () => {
|
const scrollListToBottom = () => {
|
||||||
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
|
||||||
if (!scroll) return
|
if (!scroll) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scroll.scrollTop = scroll.scrollHeight
|
scroll.scrollTop = scroll.scrollHeight
|
||||||
@@ -311,7 +300,7 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
setStore("addServer", { adding: true, error: "" })
|
||||||
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
const result = await checkHealth(normalized, platform)
|
||||||
setStore("addServer", { adding: false })
|
setStore("addServer", { adding: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
@@ -338,7 +327,7 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
setStore("editServer", { busy: true, error: "" })
|
||||||
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
const result = await checkHealth(normalized, platform)
|
||||||
setStore("editServer", { busy: false })
|
setStore("editServer", { busy: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
@@ -380,142 +369,207 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
async function handleRemove(url: string) {
|
async function handleRemove(url: string) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
|
||||||
platform.setDefaultServerUrl?.(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
||||||
<div ref={(el) => (listRoot = el)}>
|
<List
|
||||||
<List
|
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
noInitialSelection
|
||||||
noInitialSelection
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
items={sortedItems}
|
||||||
items={sortedItems}
|
key={(x) => x}
|
||||||
key={(x) => x}
|
onSelect={(x) => {
|
||||||
onSelect={(x) => {
|
if (x) select(x)
|
||||||
if (x) select(x)
|
}}
|
||||||
}}
|
onFilter={(value) => {
|
||||||
onFilter={(value) => {
|
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
resetAdd()
|
||||||
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"
|
|
||||||
add={
|
|
||||||
store.addServer.showForm
|
|
||||||
? {
|
|
||||||
render: () => (
|
|
||||||
<AddRow
|
|
||||||
value={store.addServer.url}
|
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
|
||||||
adding={store.addServer.adding}
|
|
||||||
error={store.addServer.error}
|
|
||||||
status={store.addServer.status}
|
|
||||||
onChange={handleAddChange}
|
|
||||||
onKeyDown={handleAddKey}
|
|
||||||
onBlur={blurAdd}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
>
|
}}
|
||||||
{(i) => {
|
divider={true}
|
||||||
return (
|
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"
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
add={
|
||||||
<Show
|
store.addServer.showForm
|
||||||
when={store.editServer.id !== i}
|
? {
|
||||||
fallback={
|
render: () => (
|
||||||
<EditRow
|
<AddRow
|
||||||
value={store.editServer.value}
|
value={store.addServer.url}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
busy={store.editServer.busy}
|
adding={store.addServer.adding}
|
||||||
error={store.editServer.error}
|
error={store.addServer.error}
|
||||||
status={store.editServer.status}
|
status={store.addServer.status}
|
||||||
onChange={handleEditChange}
|
onChange={handleAddChange}
|
||||||
onKeyDown={(event) => handleEditKey(event, i)}
|
onKeyDown={handleAddKey}
|
||||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
onBlur={blurAdd}
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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">
|
: undefined
|
||||||
<Show when={current() === i}>
|
}
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
>
|
||||||
</Show>
|
{(i) => {
|
||||||
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
|
let nameRef: HTMLSpanElement | undefined
|
||||||
|
let versionRef: HTMLSpanElement | undefined
|
||||||
|
|
||||||
<DropdownMenu>
|
const check = () => {
|
||||||
<DropdownMenu.Trigger
|
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||||
as={IconButton}
|
const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
|
||||||
icon="dot-grid"
|
setTruncated(nameTruncated || versionTruncated)
|
||||||
variant="ghost"
|
}
|
||||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
createEffect(() => {
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
check()
|
||||||
/>
|
window.addEventListener("resize", check)
|
||||||
<DropdownMenu.Portal>
|
onCleanup(() => window.removeEventListener("resize", check))
|
||||||
<DropdownMenu.Content class="mt-1">
|
})
|
||||||
|
|
||||||
|
const tooltipValue = () => {
|
||||||
|
const name = serverDisplayName(i)
|
||||||
|
const version = store.status[i]?.version
|
||||||
|
return (
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span>{name}</span>
|
||||||
|
<Show when={version}>
|
||||||
|
<span class="text-text-invert-base">{version}</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||||
|
classList={{ "opacity-50": store.status[i]?.healthy === false }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full shrink-0": true,
|
||||||
|
"bg-icon-success-base": store.status[i]?.healthy === true,
|
||||||
|
"bg-icon-critical-base": store.status[i]?.healthy === false,
|
||||||
|
"bg-border-weak-base": store.status[i] === undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span ref={nameRef} class="truncate">
|
||||||
|
{serverDisplayName(i)}
|
||||||
|
</span>
|
||||||
|
<Show when={store.status[i]?.version}>
|
||||||
|
<span ref={versionRef} class="text-text-weak text-14-regular truncate">
|
||||||
|
{store.status[i]?.version}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={defaultUrl() === i}>
|
||||||
|
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
|
{language.t("dialog.server.status.default")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</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
|
||||||
|
onSelect={() => {
|
||||||
|
setStore("editServer", {
|
||||||
|
id: i,
|
||||||
|
value: i,
|
||||||
|
error: "",
|
||||||
|
status: store.status[i]?.healthy,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<Show when={canDefault() && defaultUrl() !== i}>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={async () => {
|
||||||
setStore("editServer", {
|
try {
|
||||||
id: i,
|
await platform.setDefaultServerUrl?.(i)
|
||||||
value: i,
|
defaultUrlActions.mutate(i)
|
||||||
error: "",
|
} catch (err) {
|
||||||
status: store.status[i]?.healthy,
|
showToast({
|
||||||
})
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.default")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
</Show>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(i)}>
|
<Show when={canDefault() && defaultUrl() === 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={() => handleRemove(i)}
|
onSelect={async () => {
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
try {
|
||||||
|
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>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</Show>
|
||||||
</DropdownMenu.Portal>
|
<DropdownMenu.Separator />
|
||||||
</DropdownMenu>
|
<DropdownMenu.Item
|
||||||
</div>
|
onSelect={() => handleRemove(i)}
|
||||||
</Show>
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
</div>
|
>
|
||||||
)
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
}}
|
</DropdownMenu.Item>
|
||||||
</List>
|
</DropdownMenu.Content>
|
||||||
</div>
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</List>
|
||||||
|
|
||||||
<div class="px-5 pb-5">
|
<div class="px-5 pb-5">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
|
||||||
|
|
||||||
let shouldListRoot: typeof import("./file-tree").shouldListRoot
|
|
||||||
let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
|
|
||||||
let dirsToExpand: typeof import("./file-tree").dirsToExpand
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
mock.module("@solidjs/router", () => ({
|
|
||||||
useNavigate: () => () => undefined,
|
|
||||||
useParams: () => ({}),
|
|
||||||
}))
|
|
||||||
mock.module("@/context/file", () => ({
|
|
||||||
useFile: () => ({
|
|
||||||
tree: {
|
|
||||||
state: () => undefined,
|
|
||||||
list: () => Promise.resolve(),
|
|
||||||
children: () => [],
|
|
||||||
expand: () => {},
|
|
||||||
collapse: () => {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
mock.module("@opencode-ai/ui/collapsible", () => ({
|
|
||||||
Collapsible: {
|
|
||||||
Trigger: (props: { children?: unknown }) => props.children,
|
|
||||||
Content: (props: { children?: unknown }) => props.children,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
|
|
||||||
mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
|
|
||||||
mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
|
|
||||||
const mod = await import("./file-tree")
|
|
||||||
shouldListRoot = mod.shouldListRoot
|
|
||||||
shouldListExpanded = mod.shouldListExpanded
|
|
||||||
dirsToExpand = mod.dirsToExpand
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("file tree fetch discipline", () => {
|
|
||||||
test("root lists on mount unless already loaded or loading", () => {
|
|
||||||
expect(shouldListRoot({ level: 0 })).toBe(true)
|
|
||||||
expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
|
|
||||||
expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
|
|
||||||
expect(shouldListRoot({ level: 1 })).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("nested dirs list only when expanded and stale", () => {
|
|
||||||
expect(shouldListExpanded({ level: 1 })).toBe(false)
|
|
||||||
expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
|
|
||||||
expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
|
|
||||||
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
|
|
||||||
expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
|
|
||||||
expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("allowed auto-expand picks only collapsed dirs", () => {
|
|
||||||
const expanded = new Set<string>()
|
|
||||||
const filter = { dirs: new Set(["src", "src/components"]) }
|
|
||||||
|
|
||||||
const first = dirsToExpand({
|
|
||||||
level: 0,
|
|
||||||
filter,
|
|
||||||
expanded: (dir) => expanded.has(dir),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(first).toEqual(["src", "src/components"])
|
|
||||||
|
|
||||||
for (const dir of first) expanded.add(dir)
|
|
||||||
|
|
||||||
const second = dirsToExpand({
|
|
||||||
level: 0,
|
|
||||||
filter,
|
|
||||||
expanded: (dir) => expanded.has(dir),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(second).toEqual([])
|
|
||||||
expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
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"
|
||||||
@@ -9,24 +8,16 @@ import {
|
|||||||
createMemo,
|
createMemo,
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
on,
|
|
||||||
Show,
|
Show,
|
||||||
splitProps,
|
splitProps,
|
||||||
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 {
|
|
||||||
return `file://${encodeFilePath(filepath)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Kind = "add" | "del" | "mix"
|
type Kind = "add" | "del" | "mix"
|
||||||
|
|
||||||
type Filter = {
|
type Filter = {
|
||||||
@@ -34,217 +25,6 @@ type Filter = {
|
|||||||
dirs: Set<string>
|
dirs: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
|
|
||||||
if (input.level !== 0) return false
|
|
||||||
if (input.dir?.loaded) return false
|
|
||||||
if (input.dir?.loading) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldListExpanded(input: {
|
|
||||||
level: number
|
|
||||||
dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
|
|
||||||
}) {
|
|
||||||
if (input.level === 0) return false
|
|
||||||
if (!input.dir?.expanded) return false
|
|
||||||
if (input.dir.loaded) return false
|
|
||||||
if (input.dir.loading) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dirsToExpand(input: {
|
|
||||||
level: number
|
|
||||||
filter?: { dirs: Set<string> }
|
|
||||||
expanded: (dir: string) => boolean
|
|
||||||
}) {
|
|
||||||
if (input.level !== 0) return []
|
|
||||||
if (!input.filter) return []
|
|
||||||
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
@@ -262,20 +42,12 @@ 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
|
||||||
|
|
||||||
@@ -317,74 +89,41 @@ export default function FileTree(props: {
|
|||||||
|
|
||||||
const out = new Map<string, number>()
|
const out = new Map<string, number>()
|
||||||
|
|
||||||
const root = props.path
|
const visit = (dir: string, lvl: number): number => {
|
||||||
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
const expanded = file.tree.state(dir)?.expanded ?? false
|
||||||
|
if (!expanded) return -1
|
||||||
|
|
||||||
const seen = new Set<string>()
|
const nodes = file.tree.children(dir)
|
||||||
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
const max = nodes.reduce((max, node) => {
|
||||||
|
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)
|
||||||
|
|
||||||
const push = (dir: string, lvl: number) => {
|
out.set(dir, max)
|
||||||
const id = key(dir)
|
return max
|
||||||
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
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = filter()
|
const current = filter()
|
||||||
const dirs = dirsToExpand({
|
if (!current) return
|
||||||
level,
|
if (level !== 0) return
|
||||||
filter: current,
|
|
||||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
for (const dir of current.dirs) {
|
||||||
})
|
const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
|
||||||
for (const dir of dirs) file.tree.expand(dir)
|
if (expanded) continue
|
||||||
|
file.tree.expand(dir)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.path,
|
|
||||||
(path) => {
|
|
||||||
const dir = untrack(() => file.tree.state(path))
|
|
||||||
if (!shouldListRoot({ level, dir })) return
|
|
||||||
void file.tree.list(path)
|
|
||||||
},
|
|
||||||
{ defer: false },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const dir = file.tree.state(props.path)
|
const path = props.path
|
||||||
if (!shouldListExpanded({ level, dir })) return
|
untrack(() => void file.tree.list(path))
|
||||||
void file.tree.list(props.path)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodes = createMemo(() => {
|
const nodes = createMemo(() => {
|
||||||
@@ -436,23 +175,186 @@ export default function FileTree(props: {
|
|||||||
seen.add(item)
|
seen.add(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
out.sort((a, b) => {
|
return out.toSorted((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", `file://${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-diff-modified-base)"
|
||||||
|
: 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-diff-modified-base)"
|
||||||
|
|
||||||
|
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-diff-modified-base)"
|
||||||
|
|
||||||
|
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 kind = () => visibleKind(node, kinds(), marks())
|
const Wrapper = (p: ParentProps) => {
|
||||||
|
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>
|
||||||
@@ -466,21 +368,13 @@ 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>
|
||||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
<Wrapper>
|
||||||
<FileTreeNode
|
<Node node={node}>
|
||||||
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>
|
||||||
</FileTreeNode>
|
</Node>
|
||||||
</FileTreeNodeTooltip>
|
</Wrapper>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content class="relative pt-0.5">
|
<Collapsible.Content class="relative pt-0.5">
|
||||||
<div
|
<div
|
||||||
@@ -491,48 +385,31 @@ 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`}
|
||||||
/>
|
/>
|
||||||
<Show
|
<FileTree
|
||||||
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
path={node.path}
|
||||||
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
level={level + 1}
|
||||||
>
|
allowed={props.allowed}
|
||||||
<FileTree
|
modified={props.modified}
|
||||||
path={node.path}
|
kinds={props.kinds}
|
||||||
level={level + 1}
|
active={props.active}
|
||||||
allowed={props.allowed}
|
draggable={props.draggable}
|
||||||
modified={props.modified}
|
tooltip={props.tooltip}
|
||||||
kinds={props.kinds}
|
onFileClick={props.onFileClick}
|
||||||
active={props.active}
|
_filter={filter()}
|
||||||
draggable={props.draggable}
|
_marks={marks()}
|
||||||
tooltip={props.tooltip}
|
_deeps={deeps()}
|
||||||
onFileClick={props.onFileClick}
|
_kinds={kinds()}
|
||||||
_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"}>
|
||||||
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
<Wrapper>
|
||||||
<FileTreeNode
|
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
||||||
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" />
|
||||||
</FileTreeNode>
|
</Node>
|
||||||
</FileTreeNodeTooltip>
|
</Wrapper>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
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 Omit<ComponentProps<"a">, "href"> {
|
export interface LinkProps extends ComponentProps<"button"> {
|
||||||
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", "class"])
|
const [local, rest] = splitProps(props, ["href", "children"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
|
||||||
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}
|
||||||
</a>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user