Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6f89d705 | ||
|
|
3871578db6 | ||
|
|
f380c757ff | ||
|
|
8fd4568071 |
@@ -17,6 +17,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
@@ -26,44 +28,94 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENCODE_PERMISSION: |
|
OPENCODE_PERMISSION: |
|
||||||
{
|
{
|
||||||
"bash": "deny",
|
"bash": {
|
||||||
"webfetch": "deny",
|
"*": "deny",
|
||||||
"edit": "deny",
|
"gh issue*": "allow"
|
||||||
"write": "deny"
|
},
|
||||||
|
"webfetch": "deny"
|
||||||
}
|
}
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: |
|
run: |
|
||||||
ISSUE_TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq .title)
|
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||||
ISSUE_BODY=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq .body)
|
|
||||||
|
|
||||||
PROMPT=$(cat <<EOF
|
Issue number: ${{ github.event.issue.number }}
|
||||||
Check this new issue for compliance and duplicates:
|
|
||||||
|
|
||||||
CURRENT_ISSUE_NUMBER: $ISSUE_NUMBER
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
|
|
||||||
Title: $ISSUE_TITLE
|
You have TWO tasks. Perform both, then post a SINGLE comment (if needed).
|
||||||
|
|
||||||
Description:
|
---
|
||||||
$ISSUE_BODY
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
COMMENT=$(opencode run --agent duplicate-issue "$PROMPT")
|
TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
|
||||||
|
|
||||||
if [ "$COMMENT" = "No action required" ]; then
|
Check whether the issue follows our contributing guidelines and issue templates.
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
BODY="_The following comment was made by an LLM, it may be inaccurate:_
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|
||||||
$COMMENT"
|
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
|
||||||
|
|
||||||
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$BODY"
|
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
|
||||||
|
|
||||||
if [[ "$COMMENT" == *"<!-- issue-compliance -->"* ]]; then
|
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.
|
||||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label needs:compliance
|
|
||||||
fi
|
---
|
||||||
|
|
||||||
|
TASK 2: DUPLICATE CHECK
|
||||||
|
|
||||||
|
Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
|
||||||
|
Consider:
|
||||||
|
1. Similar titles or descriptions
|
||||||
|
2. Same error messages or symptoms
|
||||||
|
3. Related functionality or components
|
||||||
|
4. Similar feature requests
|
||||||
|
|
||||||
|
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
[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]
|
||||||
|
|
||||||
|
[If keybind-related, add:]
|
||||||
|
For keybind-related issues, please also check our pinned keybinds documentation: #4997
|
||||||
|
|
||||||
|
[End with if not compliant:]
|
||||||
|
If you believe this was flagged incorrectly, please let a maintainer know.
|
||||||
|
|
||||||
|
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||||
|
|
||||||
recheck-compliance:
|
recheck-compliance:
|
||||||
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
|
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||||
@@ -77,6 +129,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install opencode
|
- name: Install opencode
|
||||||
run: curl -fsSL https://opencode.ai/install | bash
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
@@ -86,54 +140,38 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENCODE_PERMISSION: |
|
OPENCODE_PERMISSION: |
|
||||||
{
|
{
|
||||||
"bash": "deny",
|
"bash": {
|
||||||
"webfetch": "deny",
|
"*": "deny",
|
||||||
"edit": "deny",
|
"gh issue*": "allow"
|
||||||
"write": "deny"
|
},
|
||||||
|
"webfetch": "deny"
|
||||||
}
|
}
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: |
|
run: |
|
||||||
ISSUE_TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq .title)
|
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||||
ISSUE_BODY=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq .body)
|
|
||||||
|
|
||||||
PROMPT=$(cat <<EOF
|
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||||
Recheck this edited issue for compliance:
|
|
||||||
|
|
||||||
MODE: recheck-compliance
|
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
||||||
CURRENT_ISSUE_NUMBER: $ISSUE_NUMBER
|
|
||||||
|
|
||||||
Title: $ISSUE_TITLE
|
This project has three issue templates that every issue MUST use one of:
|
||||||
|
|
||||||
Description:
|
1. Bug Report - requires a Description field with real content
|
||||||
$ISSUE_BODY
|
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
|
||||||
EOF
|
3. Question - requires the Question field with real content
|
||||||
)
|
|
||||||
|
|
||||||
COMMENT=$(opencode run --agent duplicate-issue "$PROMPT")
|
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
|
||||||
|
|
||||||
if [ "$COMMENT" = "No action required" ]; then
|
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.
|
||||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label needs:compliance || true
|
|
||||||
|
|
||||||
IDS=$(gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments" --jq '.[] | select(.body | contains("<!-- issue-compliance -->")) | .id')
|
If the issue is NOW compliant:
|
||||||
for id in $IDS; do
|
1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance
|
||||||
gh api -X DELETE "repos/$REPO/issues/comments/$id"
|
2. Find and delete the previous compliance comment (the one containing <!-- issue-compliance -->) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"<!-- issue-compliance -->\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id}
|
||||||
done
|
3. Post a short comment thanking them for updating the issue.
|
||||||
|
|
||||||
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "Thanks for updating your issue. It now meets our contributing guidelines. :+1:"
|
If the issue is STILL not compliant:
|
||||||
exit 0
|
Post a comment explaining what still needs to be fixed. Keep the needs:compliance label."
|
||||||
fi
|
|
||||||
|
|
||||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label needs:compliance
|
|
||||||
|
|
||||||
BODY="_The following comment was made by an LLM, it may be inaccurate:_
|
|
||||||
|
|
||||||
$COMMENT"
|
|
||||||
|
|
||||||
EXISTING=$(gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments" --jq '[.[] | select(.body | contains("<!-- issue-compliance -->")) | .id] | last // empty')
|
|
||||||
if [ -n "$EXISTING" ]; then
|
|
||||||
gh api -X PATCH "repos/$REPO/issues/comments/$EXISTING" -f body="$BODY"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body "$BODY"
|
|
||||||
|
|||||||
@@ -6,18 +6,6 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-standards:
|
check-standards:
|
||||||
if: |
|
|
||||||
github.event.pull_request.user.login != 'actions-user' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode' &&
|
|
||||||
github.event.pull_request.user.login != 'rekram1-node' &&
|
|
||||||
github.event.pull_request.user.login != 'thdxr' &&
|
|
||||||
github.event.pull_request.user.login != 'kommander' &&
|
|
||||||
github.event.pull_request.user.login != 'jayair' &&
|
|
||||||
github.event.pull_request.user.login != 'fwang' &&
|
|
||||||
github.event.pull_request.user.login != 'adamdotdevin' &&
|
|
||||||
github.event.pull_request.user.login != 'iamdavidhill' &&
|
|
||||||
github.event.pull_request.user.login != 'R44VC0RP' &&
|
|
||||||
github.event.pull_request.user.login != 'opencode-agent[bot]'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -30,14 +18,6 @@ jobs:
|
|||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
const login = pr.user.login;
|
const login = pr.user.login;
|
||||||
|
|
||||||
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
|
||||||
const cutoff = new Date('2026-02-19T00:00:00Z');
|
|
||||||
const prCreated = new Date(pr.created_at);
|
|
||||||
if (prCreated < cutoff) {
|
|
||||||
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if author is a team member or bot
|
// Check if author is a team member or bot
|
||||||
if (login === 'opencode-agent[bot]') return;
|
if (login === 'opencode-agent[bot]') return;
|
||||||
const { data: file } = await github.rest.repos.getContent({
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
@@ -177,14 +157,6 @@ jobs:
|
|||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
const login = pr.user.login;
|
const login = pr.user.login;
|
||||||
|
|
||||||
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
|
|
||||||
const cutoff = new Date('2026-02-19T00:00:00Z');
|
|
||||||
const prCreated = new Date(pr.created_at);
|
|
||||||
if (prCreated < cutoff) {
|
|
||||||
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if author is a team member or bot
|
// Check if author is a team member or bot
|
||||||
if (login === 'opencode-agent[bot]') return;
|
if (login === 'opencode-agent[bot]') return;
|
||||||
const { data: file } = await github.rest.repos.getContent({
|
const { data: file } = await github.rest.repos.getContent({
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
---
|
|
||||||
mode: primary
|
|
||||||
hidden: true
|
|
||||||
model: opencode/claude-haiku-4-5
|
|
||||||
color: "#E67E22"
|
|
||||||
tools:
|
|
||||||
"*": false
|
|
||||||
"github-issue-search": true
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a duplicate issue detection agent. When an issue is opened, your job is to search for potentially duplicate or related open issues.
|
|
||||||
|
|
||||||
You have two jobs:
|
|
||||||
|
|
||||||
1. Check if the issue follows our issue templates/contributing requirements.
|
|
||||||
2. Check for potential duplicate issues.
|
|
||||||
|
|
||||||
Use the github-issue-search tool to find potentially related issues.
|
|
||||||
|
|
||||||
IMPORTANT: The input will contain a line `CURRENT_ISSUE_NUMBER: NNNN`. Never mark that issue as a duplicate of itself.
|
|
||||||
|
|
||||||
The input may also contain `MODE: recheck-compliance`.
|
|
||||||
|
|
||||||
- When MODE is `recheck-compliance`, ONLY run compliance checks. Do not run duplicate checks. Do not include duplicate or keybind sections.
|
|
||||||
- When MODE is missing, do the full opened-issue behavior (compliance + duplicates + keybind note).
|
|
||||||
|
|
||||||
## Compliance checks
|
|
||||||
|
|
||||||
This project has three issue templates:
|
|
||||||
|
|
||||||
1. Bug Report - needs a Description field with real content.
|
|
||||||
2. Feature Request - title should start with `[FEATURE]:` and include verification checkbox + meaningful description.
|
|
||||||
3. Question - needs a Question field with real content.
|
|
||||||
|
|
||||||
Also check:
|
|
||||||
|
|
||||||
- no AI-generated walls of text
|
|
||||||
- required sections are not placeholder-only / unchanged template text
|
|
||||||
- bug reports include some repro context
|
|
||||||
- feature requests explain the problem/need
|
|
||||||
- encourage system information where relevant
|
|
||||||
|
|
||||||
Do not be nitpicky about optional fields. Only flag real issues (missing template/required content, placeholder-only content, obviously AI-generated wall of text, empty/nonsensical issue).
|
|
||||||
|
|
||||||
## Duplicate checks
|
|
||||||
|
|
||||||
Search for duplicates by trying multiple keyword combinations from the issue title/body. Prioritize:
|
|
||||||
|
|
||||||
- similar title/description
|
|
||||||
- same error/symptoms
|
|
||||||
- same component/feature area
|
|
||||||
|
|
||||||
If the issue mentions keybinds, keyboard shortcuts, or key bindings, include a note to check pinned issue #4997.
|
|
||||||
|
|
||||||
## Output rules
|
|
||||||
|
|
||||||
If MODE is `recheck-compliance` and the issue is compliant, output exactly:
|
|
||||||
|
|
||||||
No action required
|
|
||||||
|
|
||||||
If MODE is missing and the issue is compliant AND no duplicates are found AND no keybind note is needed, output exactly:
|
|
||||||
|
|
||||||
No action required
|
|
||||||
|
|
||||||
Otherwise output exactly one markdown comment body with this structure:
|
|
||||||
|
|
||||||
- In `recheck-compliance` mode: include only the non-compliant section and ending note.
|
|
||||||
- In default mode: include sections as needed (non-compliant, duplicates, keybind).
|
|
||||||
|
|
||||||
- If non-compliant, start with:
|
|
||||||
|
|
||||||
<!-- issue-compliance -->
|
|
||||||
|
|
||||||
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
|
|
||||||
|
|
||||||
**What needs to be fixed:**
|
|
||||||
|
|
||||||
- [specific reason]
|
|
||||||
|
|
||||||
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
|
|
||||||
|
|
||||||
- If duplicates were found, add:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This issue might be a duplicate of existing issues. Please check:
|
|
||||||
|
|
||||||
- #1234: [brief reason]
|
|
||||||
|
|
||||||
- If keybind-related, add:
|
|
||||||
|
|
||||||
For keybind-related issues, please also check our pinned keybinds documentation: #4997
|
|
||||||
|
|
||||||
- If non-compliant, end with:
|
|
||||||
|
|
||||||
If you believe this was flagged incorrectly, please let a maintainer know.
|
|
||||||
|
|
||||||
Keep output concise. Do not wrap output in code fences.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
|
||||||
import { tool } from "@opencode-ai/plugin"
|
|
||||||
import DESCRIPTION from "./github-issue-search.txt"
|
|
||||||
|
|
||||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|
||||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
|
||||||
Accept: "application/vnd.github+json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Issue {
|
|
||||||
title: string
|
|
||||||
html_url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default tool({
|
|
||||||
description: DESCRIPTION,
|
|
||||||
args: {
|
|
||||||
query: tool.schema.string().describe("Search query for issue titles and descriptions"),
|
|
||||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
|
||||||
offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
|
|
||||||
},
|
|
||||||
async execute(args) {
|
|
||||||
const owner = "anomalyco"
|
|
||||||
const repo = "opencode"
|
|
||||||
|
|
||||||
const page = Math.floor(args.offset / args.limit) + 1
|
|
||||||
const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:issue state:open`)
|
|
||||||
const result = await githubFetch(
|
|
||||||
`/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.total_count === 0) {
|
|
||||||
return `No issues found matching "${args.query}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const issues = result.items as Issue[]
|
|
||||||
|
|
||||||
if (issues.length === 0) {
|
|
||||||
return `No other issues found matching "${args.query}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatted = issues.map((issue) => `${issue.title}\n${issue.html_url}`).join("\n\n")
|
|
||||||
|
|
||||||
return `Found ${result.total_count} issues (showing ${issues.length}):\n\n${formatted}`
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Use this tool to search GitHub issues by title and description.
|
|
||||||
|
|
||||||
This tool searches issues in the sst/opencode repository and returns LLM-friendly results including:
|
|
||||||
- Issue number and title
|
|
||||||
- Author
|
|
||||||
- State (open/closed/merged)
|
|
||||||
- Labels
|
|
||||||
- Description snippet
|
|
||||||
|
|
||||||
Use the query parameter to search for keywords that might appear in issue titles or descriptions.
|
|
||||||
@@ -427,7 +427,6 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const key = ServerConnection.key(i)
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||||
<Show
|
<Show
|
||||||
@@ -447,8 +446,8 @@ export function DialogSelectServer() {
|
|||||||
>
|
>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
conn={i}
|
conn={i}
|
||||||
status={store.status[key]}
|
status={store.status[ServerConnection.key(i)]}
|
||||||
dimmed={store.status[key]?.healthy === false}
|
dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultUrl() === i.http.url}>
|
<Show when={defaultUrl() === i.http.url}>
|
||||||
@@ -461,7 +460,7 @@ export function DialogSelectServer() {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={store.editServer.id !== i.http.url}>
|
<Show when={store.editServer.id !== i.http.url}>
|
||||||
<div class="flex items-center justify-center gap-5 pl-4">
|
<div class="flex items-center justify-center gap-5 pl-4">
|
||||||
<Show when={ServerConnection.key(current()) === key}>
|
<Show when={current() === i}>
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|||||||
@@ -102,19 +102,15 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
const allServers = createMemo(
|
||||||
const servers = [
|
(): Array<ServerConnection.Any> => [
|
||||||
...(props.servers ?? []),
|
...(props.servers ?? []),
|
||||||
...store.list.map((value) => ({
|
...store.list.map((value) => ({
|
||||||
type: "http" as const,
|
type: "http" as const,
|
||||||
http: typeof value === "string" ? { url: value } : value,
|
http: typeof value === "string" ? { url: value } : value,
|
||||||
})),
|
})),
|
||||||
]
|
],
|
||||||
|
)
|
||||||
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
|
|
||||||
|
|
||||||
return [...deduped.values()]
|
|
||||||
})
|
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
active: props.defaultServer,
|
active: props.defaultServer,
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ export function MessageTimeline(props: {
|
|||||||
classes={{
|
classes={{
|
||||||
root: "min-w-0 w-full relative",
|
root: "min-w-0 w-full relative",
|
||||||
content: "flex flex-col justify-between !overflow-visible",
|
content: "flex flex-col justify-between !overflow-visible",
|
||||||
container: "w-full px-4 md:px-5",
|
container: "w-full px-4 md:px-6",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,11 +174,11 @@ export function SessionPromptDock(props: {
|
|||||||
<div
|
<div
|
||||||
ref={props.setPromptDockRef}
|
ref={props.setPromptDockRef}
|
||||||
data-component="session-prompt-dock"
|
data-component="session-prompt-dock"
|
||||||
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
class="shrink-0 w-full pb-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"w-full px-3 pointer-events-auto": true,
|
"w-full px-4 pointer-events-auto": true,
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -168,17 +168,12 @@ function websearch(info: ToolProps<typeof WebSearchTool>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function task(info: ToolProps<typeof TaskTool>) {
|
function task(info: ToolProps<typeof TaskTool>) {
|
||||||
const input = info.part.state.input
|
const agent = Locale.titlecase(info.input.subagent_type)
|
||||||
const status = info.part.state.status
|
const desc = info.input.description
|
||||||
const subagent =
|
const started = info.part.state.status === "running"
|
||||||
typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown"
|
|
||||||
const agent = Locale.titlecase(subagent)
|
|
||||||
const desc =
|
|
||||||
typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined
|
|
||||||
const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓"
|
|
||||||
const name = desc ?? `${agent} Task`
|
const name = desc ?? `${agent} Task`
|
||||||
inline({
|
inline({
|
||||||
icon,
|
icon: started ? "•" : "✓",
|
||||||
title: name,
|
title: name,
|
||||||
description: desc ? `${agent} Agent` : undefined,
|
description: desc ? `${agent} Agent` : undefined,
|
||||||
})
|
})
|
||||||
@@ -456,17 +451,9 @@ export const RunCommand = cmd({
|
|||||||
const part = event.properties.part
|
const part = event.properties.part
|
||||||
if (part.sessionID !== sessionID) continue
|
if (part.sessionID !== sessionID) continue
|
||||||
|
|
||||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
if (part.type === "tool" && part.state.status === "completed") {
|
||||||
if (emit("tool_use", { part })) continue
|
if (emit("tool_use", { part })) continue
|
||||||
if (part.state.status === "completed") {
|
tool(part)
|
||||||
tool(part)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inline({
|
|
||||||
icon: "✗",
|
|
||||||
title: `${part.tool} failed`,
|
|
||||||
})
|
|
||||||
UI.error(part.state.error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|||||||
renderer.setTerminalTitle("")
|
renderer.setTerminalTitle("")
|
||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
win32FlushInputBuffer()
|
win32FlushInputBuffer()
|
||||||
|
await input.onExit?.()
|
||||||
if (reason) {
|
if (reason) {
|
||||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||||
if (formatted) {
|
if (formatted) {
|
||||||
@@ -42,7 +43,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
const text = store.get()
|
const text = store.get()
|
||||||
if (text) process.stdout.write(text + "\n")
|
if (text) process.stdout.write(text + "\n")
|
||||||
await input.onExit?.()
|
process.exit(0)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: store,
|
message: store,
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { tui } from "./app"
|
|||||||
import { Rpc } from "@/util/rpc"
|
import { Rpc } from "@/util/rpc"
|
||||||
import { type rpc } from "./worker"
|
import { type rpc } from "./worker"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
import { UI } from "@/cli/ui"
|
import { UI } from "@/cli/ui"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
import type { Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
@@ -99,7 +101,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
|
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
|
||||||
const workerPath = await iife(async () => {
|
const workerPath = await iife(async () => {
|
||||||
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
|
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
|
||||||
if (await Bun.file(distWorker).exists()) return distWorker
|
if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker
|
||||||
return localWorker
|
return localWorker
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -104,9 +104,6 @@ export namespace UI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function error(message: string) {
|
export function error(message: string) {
|
||||||
if (message.startsWith("Error: ")) {
|
|
||||||
message = message.slice("Error: ".length)
|
|
||||||
}
|
|
||||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,8 +147,7 @@ export namespace LSPClient {
|
|||||||
notify: {
|
notify: {
|
||||||
async open(input: { path: string }) {
|
async open(input: { path: string }) {
|
||||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
|
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
|
||||||
const file = Bun.file(input.path)
|
const text = await Filesystem.readText(input.path)
|
||||||
const text = await file.text()
|
|
||||||
const extension = path.extname(input.path)
|
const extension = path.extname(input.path)
|
||||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export namespace LSPServer {
|
|||||||
"bin",
|
"bin",
|
||||||
"vue-language-server.js",
|
"vue-language-server.js",
|
||||||
)
|
)
|
||||||
if (!(await Bun.file(js).exists())) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
@@ -173,14 +173,14 @@ export namespace LSPServer {
|
|||||||
if (!eslint) return
|
if (!eslint) return
|
||||||
log.info("spawning eslint server")
|
log.info("spawning eslint server")
|
||||||
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
|
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
|
||||||
if (!(await Bun.file(serverPath).exists())) {
|
if (!(await Filesystem.exists(serverPath))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
log.info("downloading and building VS Code ESLint server")
|
log.info("downloading and building VS Code ESLint server")
|
||||||
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
|
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
|
||||||
if (!response.ok) return
|
if (!response.ok) return
|
||||||
|
|
||||||
const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
|
const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
|
||||||
await Bun.file(zipPath).write(response)
|
if (response.body) await Filesystem.writeStream(zipPath, response.body)
|
||||||
|
|
||||||
const ok = await Archive.extractZip(zipPath, Global.Path.bin)
|
const ok = await Archive.extractZip(zipPath, Global.Path.bin)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
@@ -242,7 +242,7 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
const resolveBin = async (target: string) => {
|
const resolveBin = async (target: string) => {
|
||||||
const localBin = path.join(root, target)
|
const localBin = path.join(root, target)
|
||||||
if (await Bun.file(localBin).exists()) return localBin
|
if (await Filesystem.exists(localBin)) return localBin
|
||||||
|
|
||||||
const candidates = Filesystem.up({
|
const candidates = Filesystem.up({
|
||||||
targets: [target],
|
targets: [target],
|
||||||
@@ -326,7 +326,7 @@ export namespace LSPServer {
|
|||||||
async spawn(root) {
|
async spawn(root) {
|
||||||
const localBin = path.join(root, "node_modules", ".bin", "biome")
|
const localBin = path.join(root, "node_modules", ".bin", "biome")
|
||||||
let bin: string | undefined
|
let bin: string | undefined
|
||||||
if (await Bun.file(localBin).exists()) bin = localBin
|
if (await Filesystem.exists(localBin)) bin = localBin
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
const found = Bun.which("biome")
|
const found = Bun.which("biome")
|
||||||
if (found) bin = found
|
if (found) bin = found
|
||||||
@@ -467,7 +467,7 @@ export namespace LSPServer {
|
|||||||
const potentialPythonPath = isWindows
|
const potentialPythonPath = isWindows
|
||||||
? path.join(venvPath, "Scripts", "python.exe")
|
? path.join(venvPath, "Scripts", "python.exe")
|
||||||
: path.join(venvPath, "bin", "python")
|
: path.join(venvPath, "bin", "python")
|
||||||
if (await Bun.file(potentialPythonPath).exists()) {
|
if (await Filesystem.exists(potentialPythonPath)) {
|
||||||
initialization["pythonPath"] = potentialPythonPath
|
initialization["pythonPath"] = potentialPythonPath
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -479,7 +479,7 @@ export namespace LSPServer {
|
|||||||
const potentialTyPath = isWindows
|
const potentialTyPath = isWindows
|
||||||
? path.join(venvPath, "Scripts", "ty.exe")
|
? path.join(venvPath, "Scripts", "ty.exe")
|
||||||
: path.join(venvPath, "bin", "ty")
|
: path.join(venvPath, "bin", "ty")
|
||||||
if (await Bun.file(potentialTyPath).exists()) {
|
if (await Filesystem.exists(potentialTyPath)) {
|
||||||
binary = potentialTyPath
|
binary = potentialTyPath
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -511,7 +511,7 @@ export namespace LSPServer {
|
|||||||
const args = []
|
const args = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||||
if (!(await Bun.file(js).exists())) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
await Bun.spawn([BunProc.which(), "install", "pyright"], {
|
await Bun.spawn([BunProc.which(), "install", "pyright"], {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
@@ -536,7 +536,7 @@ export namespace LSPServer {
|
|||||||
const potentialPythonPath = isWindows
|
const potentialPythonPath = isWindows
|
||||||
? path.join(venvPath, "Scripts", "python.exe")
|
? path.join(venvPath, "Scripts", "python.exe")
|
||||||
: path.join(venvPath, "bin", "python")
|
: path.join(venvPath, "bin", "python")
|
||||||
if (await Bun.file(potentialPythonPath).exists()) {
|
if (await Filesystem.exists(potentialPythonPath)) {
|
||||||
initialization["pythonPath"] = potentialPythonPath
|
initialization["pythonPath"] = potentialPythonPath
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -571,7 +571,7 @@ export namespace LSPServer {
|
|||||||
process.platform === "win32" ? "language_server.bat" : "language_server.sh",
|
process.platform === "win32" ? "language_server.bat" : "language_server.sh",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!(await Bun.file(binary).exists())) {
|
if (!(await Filesystem.exists(binary))) {
|
||||||
const elixir = Bun.which("elixir")
|
const elixir = Bun.which("elixir")
|
||||||
if (!elixir) {
|
if (!elixir) {
|
||||||
log.error("elixir is required to run elixir-ls")
|
log.error("elixir is required to run elixir-ls")
|
||||||
@@ -584,7 +584,7 @@ export namespace LSPServer {
|
|||||||
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
|
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
|
||||||
if (!response.ok) return
|
if (!response.ok) return
|
||||||
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
|
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
|
||||||
await Bun.file(zipPath).write(response)
|
if (response.body) await Filesystem.writeStream(zipPath, response.body)
|
||||||
|
|
||||||
const ok = await Archive.extractZip(zipPath, Global.Path.bin)
|
const ok = await Archive.extractZip(zipPath, Global.Path.bin)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
@@ -692,7 +692,7 @@ export namespace LSPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = path.join(Global.Path.bin, assetName)
|
const tempPath = path.join(Global.Path.bin, assetName)
|
||||||
await Bun.file(tempPath).write(downloadResponse)
|
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
|
||||||
|
|
||||||
if (ext === "zip") {
|
if (ext === "zip") {
|
||||||
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
||||||
@@ -710,7 +710,7 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
|
bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
|
||||||
|
|
||||||
if (!(await Bun.file(bin).exists())) {
|
if (!(await Filesystem.exists(bin))) {
|
||||||
log.error("Failed to extract zls binary")
|
log.error("Failed to extract zls binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -857,7 +857,7 @@ export namespace LSPServer {
|
|||||||
// Stop at filesystem root
|
// Stop at filesystem root
|
||||||
const cargoTomlPath = path.join(currentDir, "Cargo.toml")
|
const cargoTomlPath = path.join(currentDir, "Cargo.toml")
|
||||||
try {
|
try {
|
||||||
const cargoTomlContent = await Bun.file(cargoTomlPath).text()
|
const cargoTomlContent = await Filesystem.readText(cargoTomlPath)
|
||||||
if (cargoTomlContent.includes("[workspace]")) {
|
if (cargoTomlContent.includes("[workspace]")) {
|
||||||
return currentDir
|
return currentDir
|
||||||
}
|
}
|
||||||
@@ -907,7 +907,7 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
const ext = process.platform === "win32" ? ".exe" : ""
|
const ext = process.platform === "win32" ? ".exe" : ""
|
||||||
const direct = path.join(Global.Path.bin, "clangd" + ext)
|
const direct = path.join(Global.Path.bin, "clangd" + ext)
|
||||||
if (await Bun.file(direct).exists()) {
|
if (await Filesystem.exists(direct)) {
|
||||||
return {
|
return {
|
||||||
process: spawn(direct, args, {
|
process: spawn(direct, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
@@ -920,7 +920,7 @@ export namespace LSPServer {
|
|||||||
if (!entry.isDirectory()) continue
|
if (!entry.isDirectory()) continue
|
||||||
if (!entry.name.startsWith("clangd_")) continue
|
if (!entry.name.startsWith("clangd_")) continue
|
||||||
const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext)
|
const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext)
|
||||||
if (await Bun.file(candidate).exists()) {
|
if (await Filesystem.exists(candidate)) {
|
||||||
return {
|
return {
|
||||||
process: spawn(candidate, args, {
|
process: spawn(candidate, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
@@ -990,7 +990,7 @@ export namespace LSPServer {
|
|||||||
log.error("Failed to write clangd archive")
|
log.error("Failed to write clangd archive")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await Bun.write(archive, buf)
|
await Filesystem.write(archive, Buffer.from(buf))
|
||||||
|
|
||||||
const zip = name.endsWith(".zip")
|
const zip = name.endsWith(".zip")
|
||||||
const tar = name.endsWith(".tar.xz")
|
const tar = name.endsWith(".tar.xz")
|
||||||
@@ -1014,7 +1014,7 @@ export namespace LSPServer {
|
|||||||
await fs.rm(archive, { force: true })
|
await fs.rm(archive, { force: true })
|
||||||
|
|
||||||
const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext)
|
const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext)
|
||||||
if (!(await Bun.file(bin).exists())) {
|
if (!(await Filesystem.exists(bin))) {
|
||||||
log.error("Failed to extract clangd binary")
|
log.error("Failed to extract clangd binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1045,7 +1045,7 @@ export namespace LSPServer {
|
|||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||||
if (!(await Bun.file(js).exists())) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
@@ -1092,7 +1092,7 @@ export namespace LSPServer {
|
|||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||||
if (!(await Bun.file(js).exists())) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
@@ -1248,7 +1248,7 @@ export namespace LSPServer {
|
|||||||
const distPath = path.join(Global.Path.bin, "kotlin-ls")
|
const distPath = path.join(Global.Path.bin, "kotlin-ls")
|
||||||
const launcherScript =
|
const launcherScript =
|
||||||
process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh")
|
process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh")
|
||||||
const installed = await Bun.file(launcherScript).exists()
|
const installed = await Filesystem.exists(launcherScript)
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
log.info("Downloading Kotlin Language Server from GitHub.")
|
log.info("Downloading Kotlin Language Server from GitHub.")
|
||||||
@@ -1307,7 +1307,7 @@ export namespace LSPServer {
|
|||||||
}
|
}
|
||||||
log.info("Installed Kotlin Language Server", { path: launcherScript })
|
log.info("Installed Kotlin Language Server", { path: launcherScript })
|
||||||
}
|
}
|
||||||
if (!(await Bun.file(launcherScript).exists())) {
|
if (!(await Filesystem.exists(launcherScript))) {
|
||||||
log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`)
|
log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1336,7 +1336,7 @@ export namespace LSPServer {
|
|||||||
"src",
|
"src",
|
||||||
"server.js",
|
"server.js",
|
||||||
)
|
)
|
||||||
const exists = await Bun.file(js).exists()
|
const exists = await Filesystem.exists(js)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
||||||
@@ -1443,7 +1443,7 @@ export namespace LSPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = path.join(Global.Path.bin, assetName)
|
const tempPath = path.join(Global.Path.bin, assetName)
|
||||||
await Bun.file(tempPath).write(downloadResponse)
|
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
|
||||||
|
|
||||||
// Unlike zls which is a single self-contained binary,
|
// Unlike zls which is a single self-contained binary,
|
||||||
// lua-language-server needs supporting files (meta/, locale/, etc.)
|
// lua-language-server needs supporting files (meta/, locale/, etc.)
|
||||||
@@ -1482,7 +1482,7 @@ export namespace LSPServer {
|
|||||||
// Binary is located in bin/ subdirectory within the extracted archive
|
// Binary is located in bin/ subdirectory within the extracted archive
|
||||||
bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
|
bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
|
||||||
|
|
||||||
if (!(await Bun.file(bin).exists())) {
|
if (!(await Filesystem.exists(bin))) {
|
||||||
log.error("Failed to extract lua-language-server binary")
|
log.error("Failed to extract lua-language-server binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1516,7 +1516,7 @@ export namespace LSPServer {
|
|||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
||||||
if (!(await Bun.file(js).exists())) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
|
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
@@ -1613,7 +1613,7 @@ export namespace LSPServer {
|
|||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
||||||
if (!(await Bun.file(js).exists())) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
|
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
@@ -1654,22 +1654,17 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
if (!bin) {
|
if (!bin) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
log.info("downloading terraform-ls from GitHub releases")
|
log.info("downloading terraform-ls from HashiCorp releases")
|
||||||
|
|
||||||
const releaseResponse = await fetch("https://api.github.com/repos/hashicorp/terraform-ls/releases/latest")
|
const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest")
|
||||||
if (!releaseResponse.ok) {
|
if (!releaseResponse.ok) {
|
||||||
log.error("Failed to fetch terraform-ls release info")
|
log.error("Failed to fetch terraform-ls release info")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const release = (await releaseResponse.json()) as {
|
const release = (await releaseResponse.json()) as {
|
||||||
tag_name?: string
|
version?: string
|
||||||
assets?: { name?: string; browser_download_url?: string }[]
|
builds?: { arch?: string; os?: string; url?: string }[]
|
||||||
}
|
|
||||||
const version = release.tag_name?.replace("v", "")
|
|
||||||
if (!version) {
|
|
||||||
log.error("terraform-ls release did not include a version tag")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = process.platform
|
const platform = process.platform
|
||||||
@@ -1678,23 +1673,21 @@ export namespace LSPServer {
|
|||||||
const tfArch = arch === "arm64" ? "arm64" : "amd64"
|
const tfArch = arch === "arm64" ? "arm64" : "amd64"
|
||||||
const tfPlatform = platform === "win32" ? "windows" : platform
|
const tfPlatform = platform === "win32" ? "windows" : platform
|
||||||
|
|
||||||
const assetName = `terraform-ls_${version}_${tfPlatform}_${tfArch}.zip`
|
const builds = release.builds ?? []
|
||||||
|
const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform)
|
||||||
const assets = release.assets ?? []
|
if (!build?.url) {
|
||||||
const asset = assets.find((a) => a.name === assetName)
|
log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`)
|
||||||
if (!asset?.browser_download_url) {
|
|
||||||
log.error(`Could not find asset ${assetName} in terraform-ls release`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadResponse = await fetch(asset.browser_download_url)
|
const downloadResponse = await fetch(build.url)
|
||||||
if (!downloadResponse.ok) {
|
if (!downloadResponse.ok) {
|
||||||
log.error("Failed to download terraform-ls")
|
log.error("Failed to download terraform-ls")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = path.join(Global.Path.bin, assetName)
|
const tempPath = path.join(Global.Path.bin, "terraform-ls.zip")
|
||||||
await Bun.file(tempPath).write(downloadResponse)
|
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
|
||||||
|
|
||||||
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
@@ -1707,7 +1700,7 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : ""))
|
bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : ""))
|
||||||
|
|
||||||
if (!(await Bun.file(bin).exists())) {
|
if (!(await Filesystem.exists(bin))) {
|
||||||
log.error("Failed to extract terraform-ls binary")
|
log.error("Failed to extract terraform-ls binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1784,7 +1777,7 @@ export namespace LSPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = path.join(Global.Path.bin, assetName)
|
const tempPath = path.join(Global.Path.bin, assetName)
|
||||||
await Bun.file(tempPath).write(downloadResponse)
|
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
|
||||||
|
|
||||||
if (ext === "zip") {
|
if (ext === "zip") {
|
||||||
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
||||||
@@ -1803,7 +1796,7 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : ""))
|
bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : ""))
|
||||||
|
|
||||||
if (!(await Bun.file(bin).exists())) {
|
if (!(await Filesystem.exists(bin))) {
|
||||||
log.error("Failed to extract texlab binary")
|
log.error("Failed to extract texlab binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1832,7 +1825,7 @@ export namespace LSPServer {
|
|||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (!binary) {
|
if (!binary) {
|
||||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||||
if (!(await Bun.file(js).exists())) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
@@ -1990,7 +1983,7 @@ export namespace LSPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = path.join(Global.Path.bin, assetName)
|
const tempPath = path.join(Global.Path.bin, assetName)
|
||||||
await Bun.file(tempPath).write(downloadResponse)
|
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
|
||||||
|
|
||||||
if (ext === "zip") {
|
if (ext === "zip") {
|
||||||
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
||||||
@@ -2008,7 +2001,7 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : ""))
|
bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : ""))
|
||||||
|
|
||||||
if (!(await Bun.file(bin).exists())) {
|
if (!(await Filesystem.exists(bin))) {
|
||||||
log.error("Failed to extract tinymist binary")
|
log.error("Failed to extract tinymist binary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import z from "zod"
|
|||||||
import { Installation } from "../installation"
|
import { Installation } from "../installation"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
||||||
// Try to import bundled snapshot (generated at build time)
|
// Try to import bundled snapshot (generated at build time)
|
||||||
// Falls back to undefined in dev mode when snapshot doesn't exist
|
// Falls back to undefined in dev mode when snapshot doesn't exist
|
||||||
@@ -85,8 +86,7 @@ export namespace ModelsDev {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Data = lazy(async () => {
|
export const Data = lazy(async () => {
|
||||||
const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath)
|
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
|
||||||
const result = await file.json().catch(() => {})
|
|
||||||
if (result) return result
|
if (result) return result
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const snapshot = await import("./models-snapshot")
|
const snapshot = await import("./models-snapshot")
|
||||||
@@ -104,7 +104,6 @@ export namespace ModelsDev {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
const file = Bun.file(filepath)
|
|
||||||
const result = await fetch(`${url()}/api.json`, {
|
const result = await fetch(`${url()}/api.json`, {
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": Installation.USER_AGENT,
|
"User-Agent": Installation.USER_AGENT,
|
||||||
@@ -116,7 +115,7 @@ export namespace ModelsDev {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (result && result.ok) {
|
if (result && result.ok) {
|
||||||
await Bun.write(file, await result.text())
|
await Filesystem.write(filepath, await result.text())
|
||||||
ModelsDev.Data.reset()
|
ModelsDev.Data.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { MCP } from "../mcp"
|
|||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { ReadTool } from "../tool/read"
|
import { ReadTool } from "../tool/read"
|
||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
import { spawn } from "child_process"
|
import { spawn } from "child_process"
|
||||||
@@ -1082,11 +1083,9 @@ export namespace SessionPrompt {
|
|||||||
// have to normalize, symbol search returns absolute paths
|
// have to normalize, symbol search returns absolute paths
|
||||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||||
const filepath = fileURLToPath(part.url)
|
const filepath = fileURLToPath(part.url)
|
||||||
const stat = await Bun.file(filepath)
|
const s = Filesystem.stat(filepath)
|
||||||
.stat()
|
|
||||||
.catch(() => undefined)
|
|
||||||
|
|
||||||
if (stat?.isDirectory()) {
|
if (s?.isDirectory()) {
|
||||||
part.mime = "application/x-directory"
|
part.mime = "application/x-directory"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,14 +1232,13 @@ export namespace SessionPrompt {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(filepath)
|
|
||||||
FileTime.read(input.sessionID, filepath)
|
FileTime.read(input.sessionID, filepath)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Called the Read tool with the following input: {\"filePath\":\"${filepath}\"}`,
|
text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`,
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1248,7 +1246,8 @@ export namespace SessionPrompt {
|
|||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "file",
|
type: "file",
|
||||||
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
|
url:
|
||||||
|
`data:${part.mime};base64,` + Buffer.from(await Filesystem.readBytes(filepath)).toString("base64"),
|
||||||
mime: part.mime,
|
mime: part.mime,
|
||||||
filename: part.filename!,
|
filename: part.filename!,
|
||||||
source: part.source,
|
source: part.source,
|
||||||
@@ -1354,7 +1353,7 @@ export namespace SessionPrompt {
|
|||||||
// Switching from plan mode to build mode
|
// Switching from plan mode to build mode
|
||||||
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
|
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
|
||||||
const plan = Session.plan(input.session)
|
const plan = Session.plan(input.session)
|
||||||
const exists = await Bun.file(plan).exists()
|
const exists = await Filesystem.exists(plan)
|
||||||
if (exists) {
|
if (exists) {
|
||||||
const part = await Session.updatePart({
|
const part = await Session.updatePart({
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
@@ -1373,7 +1372,7 @@ export namespace SessionPrompt {
|
|||||||
// Entering plan mode
|
// Entering plan mode
|
||||||
if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") {
|
if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") {
|
||||||
const plan = Session.plan(input.session)
|
const plan = Session.plan(input.session)
|
||||||
const exists = await Bun.file(plan).exists()
|
const exists = await Filesystem.exists(plan)
|
||||||
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
|
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
|
||||||
const part = await Session.updatePart({
|
const part = await Session.updatePart({
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from "path"
|
|||||||
import { mkdir } from "fs/promises"
|
import { mkdir } from "fs/promises"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
||||||
export namespace Discovery {
|
export namespace Discovery {
|
||||||
const log = Log.create({ service: "skill-discovery" })
|
const log = Log.create({ service: "skill-discovery" })
|
||||||
@@ -19,14 +20,14 @@ export namespace Discovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(url: string, dest: string): Promise<boolean> {
|
async function get(url: string, dest: string): Promise<boolean> {
|
||||||
if (await Bun.file(dest).exists()) return true
|
if (await Filesystem.exists(dest)) return true
|
||||||
return fetch(url)
|
return fetch(url)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
log.error("failed to download", { url, status: response.status })
|
log.error("failed to download", { url, status: response.status })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
await Bun.write(dest, await response.text())
|
if (response.body) await Filesystem.writeStream(dest, response.body)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -88,7 +89,7 @@ export namespace Discovery {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const md = path.join(root, "SKILL.md")
|
const md = path.join(root, "SKILL.md")
|
||||||
if (await Bun.file(md).exists()) result.push(root)
|
if (await Filesystem.exists(md)) result.push(root)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { mkdir, readFile, writeFile } from "fs/promises"
|
import { chmod, mkdir, readFile, writeFile } from "fs/promises"
|
||||||
import { existsSync, statSync } from "fs"
|
import { createWriteStream, existsSync, statSync } from "fs"
|
||||||
import { lookup } from "mime-types"
|
import { lookup } from "mime-types"
|
||||||
import { realpathSync } from "fs"
|
import { realpathSync } from "fs"
|
||||||
import { dirname, join, relative } from "path"
|
import { dirname, join, relative } from "path"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
import { pipeline } from "stream/promises"
|
||||||
|
|
||||||
export namespace Filesystem {
|
export namespace Filesystem {
|
||||||
// Fast sync version for metadata checks
|
// Fast sync version for metadata checks
|
||||||
@@ -68,6 +70,25 @@ export namespace Filesystem {
|
|||||||
return write(p, JSON.stringify(data, null, 2), mode)
|
return write(p, JSON.stringify(data, null, 2), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function writeStream(
|
||||||
|
p: string,
|
||||||
|
stream: ReadableStream<Uint8Array> | Readable,
|
||||||
|
mode?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const dir = dirname(p)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream
|
||||||
|
const writeStream = createWriteStream(p)
|
||||||
|
await pipeline(nodeStream, writeStream)
|
||||||
|
|
||||||
|
if (mode) {
|
||||||
|
await chmod(p, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function mimeType(p: string): string {
|
export function mimeType(p: string): string {
|
||||||
return lookup(p) || "application/octet-stream"
|
return lookup(p) || "application/octet-stream"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
|
import { createWriteStream } from "fs"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
@@ -63,13 +64,15 @@ export namespace Log {
|
|||||||
Global.Path.log,
|
Global.Path.log,
|
||||||
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||||
)
|
)
|
||||||
const logfile = Bun.file(logpath)
|
|
||||||
await fs.truncate(logpath).catch(() => {})
|
await fs.truncate(logpath).catch(() => {})
|
||||||
const writer = logfile.writer()
|
const stream = createWriteStream(logpath, { flags: "a" })
|
||||||
write = async (msg: any) => {
|
write = async (msg: any) => {
|
||||||
const num = writer.write(msg)
|
return new Promise((resolve, reject) => {
|
||||||
writer.flush()
|
stream.write(msg, (err) => {
|
||||||
return num
|
if (err) reject(err)
|
||||||
|
else resolve(msg.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,4 +285,125 @@ describe("filesystem", () => {
|
|||||||
expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
|
expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("writeStream()", () => {
|
||||||
|
test("writes from Web ReadableStream", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "streamed.txt")
|
||||||
|
const content = "Hello from stream!"
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(content))
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Filesystem.writeStream(filepath, stream)
|
||||||
|
|
||||||
|
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes from Node.js Readable stream", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "node-streamed.txt")
|
||||||
|
const content = "Hello from Node stream!"
|
||||||
|
const { Readable } = await import("stream")
|
||||||
|
const stream = Readable.from([content])
|
||||||
|
|
||||||
|
await Filesystem.writeStream(filepath, stream)
|
||||||
|
|
||||||
|
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes binary data from Web ReadableStream", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "binary.dat")
|
||||||
|
const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff])
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(binaryData)
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Filesystem.writeStream(filepath, stream)
|
||||||
|
|
||||||
|
const read = await fs.readFile(filepath)
|
||||||
|
expect(Buffer.from(read)).toEqual(Buffer.from(binaryData))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes large content in chunks", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "large.txt")
|
||||||
|
const chunks = ["chunk1", "chunk2", "chunk3", "chunk4", "chunk5"]
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(chunk))
|
||||||
|
}
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Filesystem.writeStream(filepath, stream)
|
||||||
|
|
||||||
|
expect(await fs.readFile(filepath, "utf-8")).toBe(chunks.join(""))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates parent directories", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "nested", "deep", "streamed.txt")
|
||||||
|
const content = "nested stream content"
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(content))
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Filesystem.writeStream(filepath, stream)
|
||||||
|
|
||||||
|
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes with permissions", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "protected-stream.txt")
|
||||||
|
const content = "secret stream content"
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(content))
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Filesystem.writeStream(filepath, stream, 0o600)
|
||||||
|
|
||||||
|
const stats = await fs.stat(filepath)
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
expect(stats.mode & 0o777).toBe(0o600)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes executable with permissions", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "script.sh")
|
||||||
|
const content = "#!/bin/bash\necho hello"
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(content))
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Filesystem.writeStream(filepath, stream, 0o755)
|
||||||
|
|
||||||
|
const stats = await fs.stat(filepath)
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
expect(stats.mode & 0o777).toBe(0o755)
|
||||||
|
}
|
||||||
|
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,24 +6,15 @@ import { $ } from "bun"
|
|||||||
const dir = new URL("..", import.meta.url).pathname
|
const dir = new URL("..", import.meta.url).pathname
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
const pkg = (await import("../package.json").then((m) => m.default)) as {
|
const pkg = await import("../package.json").then((m) => m.default)
|
||||||
exports: Record<string, string | object>
|
|
||||||
}
|
|
||||||
const original = JSON.parse(JSON.stringify(pkg))
|
const original = JSON.parse(JSON.stringify(pkg))
|
||||||
function transformExports(exports: Record<string, string | object>) {
|
for (const [key, value] of Object.entries(pkg.exports)) {
|
||||||
for (const [key, value] of Object.entries(exports)) {
|
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
||||||
if (typeof value === "object" && value !== null) {
|
pkg.exports[key] = {
|
||||||
transformExports(value as Record<string, string | object>)
|
import: file + ".js",
|
||||||
} else if (typeof value === "string") {
|
types: file + ".d.ts",
|
||||||
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
|
||||||
exports[key] = {
|
|
||||||
import: file + ".js",
|
|
||||||
types: file + ".d.ts",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transformExports(pkg.exports)
|
|
||||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
|
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
|
||||||
await $`bun pm pack`
|
await $`bun pm pack`
|
||||||
await $`npm publish *.tgz --tag ${Script.channel} --access public`
|
await $`npm publish *.tgz --tag ${Script.channel} --access public`
|
||||||
|
|||||||
@@ -29,10 +29,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="collapsible-arrow-icon"] {
|
[data-slot="collapsible-arrow-icon"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
color: var(--icon-weaker);
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
transition: transform 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover [data-slot="collapsible-arrow"] {
|
&:hover [data-slot="collapsible-arrow"] {
|
||||||
@@ -73,8 +74,12 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="collapsible-arrow-icon"] {
|
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
|
||||||
transform: rotate(0deg);
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="collapsible-arrow-icon"][data-direction="down"] {
|
||||||
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
|
|||||||
function CollapsibleArrow(props?: ComponentProps<"div">) {
|
function CollapsibleArrow(props?: ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div data-slot="collapsible-arrow" {...(props || {})}>
|
<div data-slot="collapsible-arrow" {...(props || {})}>
|
||||||
<span data-slot="collapsible-arrow-icon">
|
<span data-slot="collapsible-arrow-icon" data-direction="right">
|
||||||
|
<Icon name="chevron-right" size="small" />
|
||||||
|
</span>
|
||||||
|
<span data-slot="collapsible-arrow-icon" data-direction="down">
|
||||||
<Icon name="chevron-down" size="small" />
|
<Icon name="chevron-down" size="small" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const icons = {
|
|||||||
"layout-right-partial": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
"layout-right-partial": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
||||||
"layout-right-full": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
"layout-right-full": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
|
||||||
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
|
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
"open-file": `<path d="M7.91602 2.91406H2.91602V17.0807H17.0827V12.0807M12.0827 2.91406H17.0827V7.91406M9.58268 10.4141L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
|
|
||||||
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
|
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
comment: `<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
comment: `<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
"folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
|
"folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
|||||||
@@ -65,61 +65,14 @@
|
|||||||
top: -40px;
|
top: -40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-review-diffs-group"] {
|
[data-slot="accordion-trigger"] {
|
||||||
background-color: var(--background-stronger);
|
background-color: var(--background-stronger) !important;
|
||||||
border-radius: var(--radius-lg);
|
}
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
overflow: clip;
|
|
||||||
|
|
||||||
[data-component="accordion"] {
|
[data-slot="session-review-accordion-item"][data-selected] {
|
||||||
gap: 0;
|
[data-slot="session-review-accordion-content"] {
|
||||||
}
|
box-shadow: var(--shadow-xs-border-select);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
[data-component="accordion"] [data-slot="accordion-item"] {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="accordion"]
|
|
||||||
[data-slot="accordion-item"]
|
|
||||||
[data-slot="accordion-header"]
|
|
||||||
[data-slot="accordion-trigger"] {
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
background-color: transparent;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--surface-base-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: var(--surface-base-active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="accordion"]
|
|
||||||
[data-slot="accordion-item"]
|
|
||||||
+ [data-slot="accordion-item"]
|
|
||||||
[data-slot="accordion-header"]
|
|
||||||
[data-slot="accordion-trigger"] {
|
|
||||||
border-top: 1px solid var(--border-weak-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="accordion"] [data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] {
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid var(--border-weak-base);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="sticky-accordion-header"][data-expanded]::before,
|
|
||||||
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-review-accordion-item"][data-selected]
|
|
||||||
[data-slot="accordion-header"]
|
|
||||||
[data-slot="accordion-trigger"] {
|
|
||||||
background-color: var(--surface-base-active);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,13 +110,12 @@
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 20px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-review-file-name-container"] {
|
[data-slot="session-review-file-name-container"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -194,8 +146,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
will-change: opacity;
|
|
||||||
transform: translateZ(0);
|
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -216,23 +166,6 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-review-diff-chevron"] {
|
|
||||||
display: inline-flex;
|
|
||||||
color: var(--icon-weaker);
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
transition: transform 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="accordion-item"][data-expanded] [data-slot="session-review-diff-chevron"] {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-review-change-group"] {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="session-review-change"] {
|
[data-slot="session-review-change"] {
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { FileIcon } from "./file-icon"
|
|||||||
import { Icon } from "./icon"
|
import { Icon } from "./icon"
|
||||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||||
import { Tooltip } from "./tooltip"
|
|
||||||
import { useDiffComponent } from "../context/diff"
|
import { useDiffComponent } from "../context/diff"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
@@ -320,395 +319,385 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div data-slot="session-review-container" class={props.classes?.container}>
|
<div data-slot="session-review-container" class={props.classes?.container}>
|
||||||
<Show when={hasDiffs()} fallback={props.empty}>
|
<Show when={hasDiffs()} fallback={props.empty}>
|
||||||
<div data-slot="session-review-diffs-group">
|
<Accordion multiple value={open()} onChange={handleChange}>
|
||||||
<Accordion multiple value={open()} onChange={handleChange}>
|
<For each={props.diffs}>
|
||||||
<For each={props.diffs}>
|
{(diff) => {
|
||||||
{(diff) => {
|
let wrapper: HTMLDivElement | undefined
|
||||||
let wrapper: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
const expanded = createMemo(() => open().includes(diff.file))
|
const expanded = createMemo(() => open().includes(diff.file))
|
||||||
const [force, setForce] = createSignal(false)
|
const [force, setForce] = createSignal(false)
|
||||||
|
|
||||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
|
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
|
||||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||||
|
|
||||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||||
const changedLines = () => diff.additions + diff.deletions
|
const changedLines = () => diff.additions + diff.deletions
|
||||||
|
|
||||||
const tooLarge = createMemo(() => {
|
const tooLarge = createMemo(() => {
|
||||||
if (!expanded()) return false
|
if (!expanded()) return false
|
||||||
if (force()) return false
|
if (force()) return false
|
||||||
if (isImageFile(diff.file)) return false
|
if (isImageFile(diff.file)) return false
|
||||||
return changedLines() > MAX_DIFF_CHANGED_LINES
|
return changedLines() > MAX_DIFF_CHANGED_LINES
|
||||||
})
|
})
|
||||||
|
|
||||||
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||||
const isDeleted = () =>
|
const isDeleted = () =>
|
||||||
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||||
const isImage = () => isImageFile(diff.file)
|
const isImage = () => isImageFile(diff.file)
|
||||||
const isAudio = () => isAudioFile(diff.file)
|
const isAudio = () => isAudioFile(diff.file)
|
||||||
|
|
||||||
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||||
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
|
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
|
||||||
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||||
|
|
||||||
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||||
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
|
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
|
||||||
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||||
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
||||||
|
|
||||||
const selectedLines = createMemo(() => {
|
const selectedLines = createMemo(() => {
|
||||||
const current = selection()
|
const current = selection()
|
||||||
if (!current || current.file !== diff.file) return null
|
if (!current || current.file !== diff.file) return null
|
||||||
return current.range
|
return current.range
|
||||||
})
|
})
|
||||||
|
|
||||||
const draftRange = createMemo(() => {
|
const draftRange = createMemo(() => {
|
||||||
const current = commenting()
|
const current = commenting()
|
||||||
if (!current || current.file !== diff.file) return null
|
if (!current || current.file !== diff.file) return null
|
||||||
return current.range
|
return current.range
|
||||||
})
|
})
|
||||||
|
|
||||||
const [draft, setDraft] = createSignal("")
|
const [draft, setDraft] = createSignal("")
|
||||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||||
|
|
||||||
const getRoot = () => {
|
const getRoot = () => {
|
||||||
const el = wrapper
|
const el = wrapper
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
const host = el.querySelector("diffs-container")
|
const host = el.querySelector("diffs-container")
|
||||||
if (!(host instanceof HTMLElement)) return
|
if (!(host instanceof HTMLElement)) return
|
||||||
return host.shadowRoot ?? undefined
|
return host.shadowRoot ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAnchors = () => {
|
||||||
|
const el = wrapper
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const root = getRoot()
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const next: Record<string, number> = {}
|
||||||
|
for (const item of comments()) {
|
||||||
|
const marker = findMarker(root, item.selection)
|
||||||
|
if (!marker) continue
|
||||||
|
next[item.id] = markerTop(el, marker)
|
||||||
|
}
|
||||||
|
setPositions(next)
|
||||||
|
|
||||||
|
const range = draftRange()
|
||||||
|
if (!range) {
|
||||||
|
setDraftTop(undefined)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAnchors = () => {
|
const marker = findMarker(root, range)
|
||||||
const el = wrapper
|
if (!marker) {
|
||||||
if (!el) return
|
setDraftTop(undefined)
|
||||||
|
return
|
||||||
const root = getRoot()
|
|
||||||
if (!root) return
|
|
||||||
|
|
||||||
const next: Record<string, number> = {}
|
|
||||||
for (const item of comments()) {
|
|
||||||
const marker = findMarker(root, item.selection)
|
|
||||||
if (!marker) continue
|
|
||||||
next[item.id] = markerTop(el, marker)
|
|
||||||
}
|
|
||||||
setPositions(next)
|
|
||||||
|
|
||||||
const range = draftRange()
|
|
||||||
if (!range) {
|
|
||||||
setDraftTop(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = findMarker(root, range)
|
|
||||||
if (!marker) {
|
|
||||||
setDraftTop(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setDraftTop(markerTop(el, marker))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleAnchors = () => {
|
setDraftTop(markerTop(el, marker))
|
||||||
requestAnimationFrame(updateAnchors)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
const scheduleAnchors = () => {
|
||||||
comments()
|
requestAnimationFrame(updateAnchors)
|
||||||
scheduleAnchors()
|
}
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const range = draftRange()
|
comments()
|
||||||
if (!range) return
|
scheduleAnchors()
|
||||||
setDraft("")
|
})
|
||||||
scheduleAnchors()
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!open().includes(diff.file)) return
|
const range = draftRange()
|
||||||
if (!isImage()) return
|
if (!range) return
|
||||||
if (imageSrc()) return
|
setDraft("")
|
||||||
if (imageStatus() !== "idle") return
|
scheduleAnchors()
|
||||||
if (isDeleted()) return
|
})
|
||||||
|
|
||||||
const reader = props.readFile
|
createEffect(() => {
|
||||||
if (!reader) return
|
if (!open().includes(diff.file)) return
|
||||||
|
if (!isImage()) return
|
||||||
|
if (imageSrc()) return
|
||||||
|
if (imageStatus() !== "idle") return
|
||||||
|
if (isDeleted()) return
|
||||||
|
|
||||||
setImageStatus("loading")
|
const reader = props.readFile
|
||||||
reader(diff.file)
|
if (!reader) return
|
||||||
.then((result) => {
|
|
||||||
const src = dataUrl(result)
|
setImageStatus("loading")
|
||||||
if (!src) {
|
reader(diff.file)
|
||||||
setImageStatus("error")
|
.then((result) => {
|
||||||
return
|
const src = dataUrl(result)
|
||||||
}
|
if (!src) {
|
||||||
setImageSrc(src)
|
|
||||||
setImageStatus("idle")
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setImageStatus("error")
|
setImageStatus("error")
|
||||||
})
|
return
|
||||||
})
|
}
|
||||||
|
setImageSrc(src)
|
||||||
|
setImageStatus("idle")
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setImageStatus("error")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!open().includes(diff.file)) return
|
if (!open().includes(diff.file)) return
|
||||||
if (!isAudio()) return
|
if (!isAudio()) return
|
||||||
if (audioSrc()) return
|
if (audioSrc()) return
|
||||||
if (audioStatus() !== "idle") return
|
if (audioStatus() !== "idle") return
|
||||||
|
|
||||||
const reader = props.readFile
|
const reader = props.readFile
|
||||||
if (!reader) return
|
if (!reader) return
|
||||||
|
|
||||||
setAudioStatus("loading")
|
setAudioStatus("loading")
|
||||||
reader(diff.file)
|
reader(diff.file)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
const src = dataUrl(result)
|
const src = dataUrl(result)
|
||||||
if (!src) {
|
if (!src) {
|
||||||
setAudioStatus("error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAudioMime(normalizeMimeType(result?.mimeType))
|
|
||||||
setAudioSrc(src)
|
|
||||||
setAudioStatus("idle")
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setAudioStatus("error")
|
setAudioStatus("error")
|
||||||
})
|
return
|
||||||
})
|
}
|
||||||
|
setAudioMime(normalizeMimeType(result?.mimeType))
|
||||||
|
setAudioSrc(src)
|
||||||
|
setAudioStatus("idle")
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setAudioStatus("error")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||||
if (!props.onLineComment) return
|
if (!props.onLineComment) return
|
||||||
|
|
||||||
if (!range) {
|
if (!range) {
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
setSelection({ file: diff.file, range })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
setSelection({ file: diff.file, range })
|
||||||
if (!props.onLineComment) return
|
}
|
||||||
|
|
||||||
if (!range) {
|
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||||
setCommenting(null)
|
if (!props.onLineComment) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelection({ file: diff.file, range })
|
if (!range) {
|
||||||
setCommenting({ file: diff.file, range })
|
setCommenting(null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const openComment = (comment: SessionReviewComment) => {
|
setSelection({ file: diff.file, range })
|
||||||
setOpened({ file: comment.file, id: comment.id })
|
setCommenting({ file: diff.file, range })
|
||||||
setSelection({ file: comment.file, range: comment.selection })
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const isCommentOpen = (comment: SessionReviewComment) => {
|
const openComment = (comment: SessionReviewComment) => {
|
||||||
const current = opened()
|
setOpened({ file: comment.file, id: comment.id })
|
||||||
if (!current) return false
|
setSelection({ file: comment.file, range: comment.selection })
|
||||||
return current.file === comment.file && current.id === comment.id
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const isCommentOpen = (comment: SessionReviewComment) => {
|
||||||
<Accordion.Item
|
const current = opened()
|
||||||
value={diff.file}
|
if (!current) return false
|
||||||
id={diffId(diff.file)}
|
return current.file === comment.file && current.id === comment.id
|
||||||
data-file={diff.file}
|
}
|
||||||
data-slot="session-review-accordion-item"
|
|
||||||
data-selected={props.focusedFile === diff.file ? "" : undefined}
|
return (
|
||||||
>
|
<Accordion.Item
|
||||||
<StickyAccordionHeader>
|
value={diff.file}
|
||||||
<Accordion.Trigger>
|
id={diffId(diff.file)}
|
||||||
<div data-slot="session-review-trigger-content">
|
data-file={diff.file}
|
||||||
<div data-slot="session-review-file-info">
|
data-slot="session-review-accordion-item"
|
||||||
<FileIcon node={{ path: diff.file, type: "file" }} />
|
data-selected={props.focusedFile === diff.file ? "" : undefined}
|
||||||
<div data-slot="session-review-file-name-container">
|
>
|
||||||
<Show when={diff.file.includes("/")}>
|
<StickyAccordionHeader>
|
||||||
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
<Accordion.Trigger>
|
||||||
</Show>
|
<div data-slot="session-review-trigger-content">
|
||||||
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
<div data-slot="session-review-file-info">
|
||||||
<Show when={props.onViewFile}>
|
<FileIcon node={{ path: diff.file, type: "file" }} />
|
||||||
<Tooltip value="Open file" placement="top" gutter={4}>
|
<div data-slot="session-review-file-name-container">
|
||||||
<button
|
<Show when={diff.file.includes("/")}>
|
||||||
data-slot="session-review-view-button"
|
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||||
type="button"
|
</Show>
|
||||||
aria-label="Open file"
|
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
||||||
onClick={(e) => {
|
<Show when={props.onViewFile}>
|
||||||
e.stopPropagation()
|
<button
|
||||||
props.onViewFile?.(diff.file)
|
data-slot="session-review-view-button"
|
||||||
}}
|
type="button"
|
||||||
>
|
onClick={(e) => {
|
||||||
<Icon name="open-file" size="small" />
|
e.stopPropagation()
|
||||||
</button>
|
props.onViewFile?.(diff.file)
|
||||||
</Tooltip>
|
}}
|
||||||
</Show>
|
>
|
||||||
</div>
|
<Icon name="eye" size="small" />
|
||||||
</div>
|
</button>
|
||||||
<div data-slot="session-review-trigger-actions">
|
</Show>
|
||||||
<Switch>
|
|
||||||
<Match when={isAdded()}>
|
|
||||||
<div data-slot="session-review-change-group" data-type="added">
|
|
||||||
<span data-slot="session-review-change" data-type="added">
|
|
||||||
{i18n.t("ui.sessionReview.change.added")}
|
|
||||||
</span>
|
|
||||||
<DiffChanges changes={diff} />
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={isDeleted()}>
|
|
||||||
<span data-slot="session-review-change" data-type="removed">
|
|
||||||
{i18n.t("ui.sessionReview.change.removed")}
|
|
||||||
</span>
|
|
||||||
</Match>
|
|
||||||
<Match when={isImage()}>
|
|
||||||
<span data-slot="session-review-change" data-type="modified">
|
|
||||||
{i18n.t("ui.sessionReview.change.modified")}
|
|
||||||
</span>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<DiffChanges changes={diff} />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<span data-slot="session-review-diff-chevron">
|
|
||||||
<Icon name="chevron-down" size="small" />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Accordion.Trigger>
|
<div data-slot="session-review-trigger-actions">
|
||||||
</StickyAccordionHeader>
|
|
||||||
<Accordion.Content data-slot="session-review-accordion-content">
|
|
||||||
<div
|
|
||||||
data-slot="session-review-diff-wrapper"
|
|
||||||
ref={(el) => {
|
|
||||||
wrapper = el
|
|
||||||
anchors.set(diff.file, el)
|
|
||||||
scheduleAnchors()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={expanded()}>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={isImage() && imageSrc()}>
|
<Match when={isAdded()}>
|
||||||
<div data-slot="session-review-image-container">
|
<span data-slot="session-review-change" data-type="added">
|
||||||
<img data-slot="session-review-image" src={imageSrc()} alt={diff.file} />
|
{i18n.t("ui.sessionReview.change.added")}
|
||||||
</div>
|
</span>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={isImage() && isDeleted()}>
|
<Match when={isDeleted()}>
|
||||||
<div data-slot="session-review-image-container" data-removed>
|
<span data-slot="session-review-change" data-type="removed">
|
||||||
<span data-slot="session-review-image-placeholder">
|
{i18n.t("ui.sessionReview.change.removed")}
|
||||||
{i18n.t("ui.sessionReview.change.removed")}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={isImage() && !imageSrc()}>
|
<Match when={isImage()}>
|
||||||
<div data-slot="session-review-image-container">
|
<span data-slot="session-review-change" data-type="modified">
|
||||||
<span data-slot="session-review-image-placeholder">
|
{i18n.t("ui.sessionReview.change.modified")}
|
||||||
{imageStatus() === "loading"
|
</span>
|
||||||
? i18n.t("ui.sessionReview.image.loading")
|
|
||||||
: i18n.t("ui.sessionReview.image.placeholder")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={!isImage() && tooLarge()}>
|
<Match when={true}>
|
||||||
<div data-slot="session-review-large-diff">
|
<DiffChanges changes={diff} />
|
||||||
<div data-slot="session-review-large-diff-title">
|
|
||||||
{i18n.t("ui.sessionReview.largeDiff.title")}
|
|
||||||
</div>
|
|
||||||
<div data-slot="session-review-large-diff-meta">
|
|
||||||
{i18n.t("ui.sessionReview.largeDiff.meta", {
|
|
||||||
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
|
|
||||||
current: changedLines().toLocaleString(),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div data-slot="session-review-large-diff-actions">
|
|
||||||
<Button size="normal" variant="secondary" onClick={() => setForce(true)}>
|
|
||||||
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={!isImage()}>
|
|
||||||
<Dynamic
|
|
||||||
component={diffComponent}
|
|
||||||
preloadedDiff={diff.preloaded}
|
|
||||||
diffStyle={diffStyle()}
|
|
||||||
onRendered={() => {
|
|
||||||
props.onDiffRendered?.()
|
|
||||||
scheduleAnchors()
|
|
||||||
}}
|
|
||||||
enableLineSelection={props.onLineComment != null}
|
|
||||||
onLineSelected={handleLineSelected}
|
|
||||||
onLineSelectionEnd={handleLineSelectionEnd}
|
|
||||||
selectedLines={selectedLines()}
|
|
||||||
commentedLines={commentedLines()}
|
|
||||||
before={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: typeof diff.before === "string" ? diff.before : "",
|
|
||||||
}}
|
|
||||||
after={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: typeof diff.after === "string" ? diff.after : "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
<Icon name="chevron-grabber-vertical" size="small" />
|
||||||
<For each={comments()}>
|
</div>
|
||||||
{(comment) => (
|
|
||||||
<LineComment
|
|
||||||
id={comment.id}
|
|
||||||
top={positions()[comment.id]}
|
|
||||||
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
|
||||||
onClick={() => {
|
|
||||||
if (isCommentOpen(comment)) {
|
|
||||||
setOpened(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openComment(comment)
|
|
||||||
}}
|
|
||||||
open={isCommentOpen(comment)}
|
|
||||||
comment={comment.comment}
|
|
||||||
selection={selectionLabel(comment.selection)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<Show when={draftRange()}>
|
|
||||||
{(range) => (
|
|
||||||
<Show when={draftTop() !== undefined}>
|
|
||||||
<LineCommentEditor
|
|
||||||
top={draftTop()}
|
|
||||||
value={draft()}
|
|
||||||
selection={selectionLabel(range())}
|
|
||||||
onInput={setDraft}
|
|
||||||
onCancel={() => setCommenting(null)}
|
|
||||||
onSubmit={(comment) => {
|
|
||||||
props.onLineComment?.({
|
|
||||||
file: diff.file,
|
|
||||||
selection: range(),
|
|
||||||
comment,
|
|
||||||
preview: selectionPreview(diff, range()),
|
|
||||||
})
|
|
||||||
setCommenting(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Accordion.Content>
|
</Accordion.Trigger>
|
||||||
</Accordion.Item>
|
</StickyAccordionHeader>
|
||||||
)
|
<Accordion.Content data-slot="session-review-accordion-content">
|
||||||
}}
|
<div
|
||||||
</For>
|
data-slot="session-review-diff-wrapper"
|
||||||
</Accordion>
|
ref={(el) => {
|
||||||
</div>
|
wrapper = el
|
||||||
|
anchors.set(diff.file, el)
|
||||||
|
scheduleAnchors()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={isImage() && imageSrc()}>
|
||||||
|
<div data-slot="session-review-image-container">
|
||||||
|
<img data-slot="session-review-image" src={imageSrc()} alt={diff.file} />
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={isImage() && isDeleted()}>
|
||||||
|
<div data-slot="session-review-image-container" data-removed>
|
||||||
|
<span data-slot="session-review-image-placeholder">
|
||||||
|
{i18n.t("ui.sessionReview.change.removed")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={isImage() && !imageSrc()}>
|
||||||
|
<div data-slot="session-review-image-container">
|
||||||
|
<span data-slot="session-review-image-placeholder">
|
||||||
|
{imageStatus() === "loading"
|
||||||
|
? i18n.t("ui.sessionReview.image.loading")
|
||||||
|
: i18n.t("ui.sessionReview.image.placeholder")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={!isImage() && tooLarge()}>
|
||||||
|
<div data-slot="session-review-large-diff">
|
||||||
|
<div data-slot="session-review-large-diff-title">
|
||||||
|
{i18n.t("ui.sessionReview.largeDiff.title")}
|
||||||
|
</div>
|
||||||
|
<div data-slot="session-review-large-diff-meta">
|
||||||
|
{i18n.t("ui.sessionReview.largeDiff.meta", {
|
||||||
|
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
|
||||||
|
current: changedLines().toLocaleString(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div data-slot="session-review-large-diff-actions">
|
||||||
|
<Button size="normal" variant="secondary" onClick={() => setForce(true)}>
|
||||||
|
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={!isImage()}>
|
||||||
|
<Dynamic
|
||||||
|
component={diffComponent}
|
||||||
|
preloadedDiff={diff.preloaded}
|
||||||
|
diffStyle={diffStyle()}
|
||||||
|
onRendered={() => {
|
||||||
|
props.onDiffRendered?.()
|
||||||
|
scheduleAnchors()
|
||||||
|
}}
|
||||||
|
enableLineSelection={props.onLineComment != null}
|
||||||
|
onLineSelected={handleLineSelected}
|
||||||
|
onLineSelectionEnd={handleLineSelectionEnd}
|
||||||
|
selectedLines={selectedLines()}
|
||||||
|
commentedLines={commentedLines()}
|
||||||
|
before={{
|
||||||
|
name: diff.file!,
|
||||||
|
contents: typeof diff.before === "string" ? diff.before : "",
|
||||||
|
}}
|
||||||
|
after={{
|
||||||
|
name: diff.file!,
|
||||||
|
contents: typeof diff.after === "string" ? diff.after : "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
|
||||||
|
<For each={comments()}>
|
||||||
|
{(comment) => (
|
||||||
|
<LineComment
|
||||||
|
id={comment.id}
|
||||||
|
top={positions()[comment.id]}
|
||||||
|
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
||||||
|
onClick={() => {
|
||||||
|
if (isCommentOpen(comment)) {
|
||||||
|
setOpened(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openComment(comment)
|
||||||
|
}}
|
||||||
|
open={isCommentOpen(comment)}
|
||||||
|
comment={comment.comment}
|
||||||
|
selection={selectionLabel(comment.selection)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={draftRange()}>
|
||||||
|
{(range) => (
|
||||||
|
<Show when={draftTop() !== undefined}>
|
||||||
|
<LineCommentEditor
|
||||||
|
top={draftTop()}
|
||||||
|
value={draft()}
|
||||||
|
selection={selectionLabel(range())}
|
||||||
|
onInput={setDraft}
|
||||||
|
onCancel={() => setCommenting(null)}
|
||||||
|
onSubmit={(comment) => {
|
||||||
|
props.onLineComment?.({
|
||||||
|
file: diff.file,
|
||||||
|
selection: range(),
|
||||||
|
comment,
|
||||||
|
preview: selectionPreview(diff, range()),
|
||||||
|
})
|
||||||
|
setCommenting(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Accordion>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,49 +127,7 @@
|
|||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
gap: 12px;
|
||||||
|
|
||||||
[data-slot="session-turn-diffs-group"] {
|
|
||||||
background-color: var(--background-stronger);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
overflow: clip;
|
|
||||||
|
|
||||||
[data-component="accordion"] {
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="accordion"]
|
|
||||||
[data-slot="accordion-item"]
|
|
||||||
[data-slot="accordion-header"]
|
|
||||||
[data-slot="accordion-trigger"] {
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
background-color: transparent;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--surface-base-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: var(--surface-base-active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="accordion"]
|
|
||||||
[data-slot="accordion-item"]
|
|
||||||
+ [data-slot="accordion-item"]
|
|
||||||
[data-slot="accordion-header"]
|
|
||||||
[data-slot="accordion-trigger"] {
|
|
||||||
border-top: 1px solid var(--border-weak-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="accordion"] [data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] {
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid var(--border-weak-base);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-turn-diff-trigger"] {
|
[data-slot="session-turn-diff-trigger"] {
|
||||||
@@ -182,11 +140,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-turn-diff-path"] {
|
[data-slot="session-turn-diff-path"] {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
line-height: var(--line-height-large);
|
line-height: var(--line-height-large);
|
||||||
@@ -194,13 +153,6 @@
|
|||||||
|
|
||||||
[data-slot="session-turn-diff-directory"] {
|
[data-slot="session-turn-diff-directory"] {
|
||||||
color: var(--text-weak);
|
color: var(--text-weak);
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
direction: rtl;
|
|
||||||
unicode-bidi: plaintext;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="session-turn-diff-filename"] {
|
[data-slot="session-turn-diff-filename"] {
|
||||||
|
|||||||
@@ -315,78 +315,76 @@ export function SessionTurn(
|
|||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<Show when={open()}>
|
<Show when={open()}>
|
||||||
<div data-component="session-turn-diffs-content">
|
<div data-component="session-turn-diffs-content">
|
||||||
<div data-slot="session-turn-diffs-group">
|
<Accordion
|
||||||
<Accordion
|
multiple
|
||||||
multiple
|
value={expanded()}
|
||||||
value={expanded()}
|
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
>
|
||||||
>
|
<For each={diffs()}>
|
||||||
<For each={diffs()}>
|
{(diff) => {
|
||||||
{(diff) => {
|
const active = createMemo(() => expanded().includes(diff.file))
|
||||||
const active = createMemo(() => expanded().includes(diff.file))
|
const [visible, setVisible] = createSignal(false)
|
||||||
const [visible, setVisible] = createSignal(false)
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
active,
|
active,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!active()) return
|
if (!active()) return
|
||||||
setVisible(true)
|
setVisible(true)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion.Item value={diff.file}>
|
<Accordion.Item value={diff.file}>
|
||||||
<Accordion.Header>
|
<Accordion.Header>
|
||||||
<Accordion.Trigger>
|
<Accordion.Trigger>
|
||||||
<div data-slot="session-turn-diff-trigger">
|
<div data-slot="session-turn-diff-trigger">
|
||||||
<span data-slot="session-turn-diff-path">
|
<span data-slot="session-turn-diff-path">
|
||||||
<Show when={diff.file.includes("/")}>
|
<Show when={diff.file.includes("/")}>
|
||||||
<span data-slot="session-turn-diff-directory">
|
<span data-slot="session-turn-diff-directory">
|
||||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
{getDirectory(diff.file)}
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span data-slot="session-turn-diff-filename">
|
|
||||||
{getFilename(diff.file)}
|
|
||||||
</span>
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span data-slot="session-turn-diff-filename">
|
||||||
|
{getFilename(diff.file)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div data-slot="session-turn-diff-meta">
|
||||||
|
<span data-slot="session-turn-diff-changes">
|
||||||
|
<DiffChanges changes={diff} />
|
||||||
|
</span>
|
||||||
|
<span data-slot="session-turn-diff-chevron">
|
||||||
|
<Icon name="chevron-down" size="small" />
|
||||||
</span>
|
</span>
|
||||||
<div data-slot="session-turn-diff-meta">
|
|
||||||
<span data-slot="session-turn-diff-changes">
|
|
||||||
<DiffChanges changes={diff} />
|
|
||||||
</span>
|
|
||||||
<span data-slot="session-turn-diff-chevron">
|
|
||||||
<Icon name="chevron-down" size="small" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Accordion.Trigger>
|
</div>
|
||||||
</Accordion.Header>
|
</Accordion.Trigger>
|
||||||
<Accordion.Content>
|
</Accordion.Header>
|
||||||
<Show when={visible()}>
|
<Accordion.Content>
|
||||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
<Show when={visible()}>
|
||||||
<Dynamic
|
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||||
component={diffComponent}
|
<Dynamic
|
||||||
before={{ name: diff.file, contents: diff.before }}
|
component={diffComponent}
|
||||||
after={{ name: diff.file, contents: diff.after }}
|
before={{ name: diff.file, contents: diff.before }}
|
||||||
/>
|
after={{ name: diff.file, contents: diff.after }}
|
||||||
</div>
|
/>
|
||||||
</Show>
|
</div>
|
||||||
</Accordion.Content>
|
</Show>
|
||||||
</Accordion.Item>
|
</Accordion.Content>
|
||||||
)
|
</Accordion.Item>
|
||||||
}}
|
)
|
||||||
</For>
|
}}
|
||||||
</Accordion>
|
</For>
|
||||||
</div>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
|
|||||||
@@ -390,7 +390,7 @@
|
|||||||
"border-selected": "var(--cobalt-dark-alpha-11)",
|
"border-selected": "var(--cobalt-dark-alpha-11)",
|
||||||
"border-disabled": "var(--gray-dark-alpha-8)",
|
"border-disabled": "var(--gray-dark-alpha-8)",
|
||||||
"border-focus": "var(--gray-dark-alpha-9)",
|
"border-focus": "var(--gray-dark-alpha-9)",
|
||||||
"border-weak-base": "var(--gray-dark-alpha-5)",
|
"border-weak-base": "var(--gray-dark-alpha-4)",
|
||||||
"border-weak-hover": "var(--gray-dark-alpha-7)",
|
"border-weak-hover": "var(--gray-dark-alpha-7)",
|
||||||
"border-weak-active": "var(--gray-dark-alpha-8)",
|
"border-weak-active": "var(--gray-dark-alpha-8)",
|
||||||
"border-weak-selected": "var(--cobalt-dark-alpha-6)",
|
"border-weak-selected": "var(--cobalt-dark-alpha-6)",
|
||||||
|
|||||||
Reference in New Issue
Block a user