Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6f89d705 | ||
|
|
3871578db6 | ||
|
|
f380c757ff | ||
|
|
8fd4568071 |
@@ -17,6 +17,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
@@ -26,44 +28,94 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": "deny",
|
||||
"webfetch": "deny",
|
||||
"edit": "deny",
|
||||
"write": "deny"
|
||||
"bash": {
|
||||
"*": "deny",
|
||||
"gh issue*": "allow"
|
||||
},
|
||||
"webfetch": "deny"
|
||||
}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
ISSUE_TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq .title)
|
||||
ISSUE_BODY=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq .body)
|
||||
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||
|
||||
PROMPT=$(cat <<EOF
|
||||
Check this new issue for compliance and duplicates:
|
||||
Issue number: ${{ github.event.issue.number }}
|
||||
|
||||
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
|
||||
exit 0
|
||||
fi
|
||||
Check whether the issue follows our contributing guidelines and issue templates.
|
||||
|
||||
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
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label needs:compliance
|
||||
fi
|
||||
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
|
||||
|
||||
---
|
||||
|
||||
TASK 2: DUPLICATE CHECK
|
||||
|
||||
Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
|
||||
Consider:
|
||||
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:
|
||||
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||
@@ -77,6 +129,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
@@ -86,54 +140,38 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": "deny",
|
||||
"webfetch": "deny",
|
||||
"edit": "deny",
|
||||
"write": "deny"
|
||||
"bash": {
|
||||
"*": "deny",
|
||||
"gh issue*": "allow"
|
||||
},
|
||||
"webfetch": "deny"
|
||||
}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
ISSUE_TITLE=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq .title)
|
||||
ISSUE_BODY=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json body --jq .body)
|
||||
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||
|
||||
PROMPT=$(cat <<EOF
|
||||
Recheck this edited issue for compliance:
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||
|
||||
MODE: recheck-compliance
|
||||
CURRENT_ISSUE_NUMBER: $ISSUE_NUMBER
|
||||
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
||||
|
||||
Title: $ISSUE_TITLE
|
||||
This project has three issue templates that every issue MUST use one of:
|
||||
|
||||
Description:
|
||||
$ISSUE_BODY
|
||||
EOF
|
||||
)
|
||||
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
|
||||
|
||||
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
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label needs:compliance || true
|
||||
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.
|
||||
|
||||
IDS=$(gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments" --jq '.[] | select(.body | contains("<!-- issue-compliance -->")) | .id')
|
||||
for id in $IDS; do
|
||||
gh api -X DELETE "repos/$REPO/issues/comments/$id"
|
||||
done
|
||||
If the issue is NOW compliant:
|
||||
1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance
|
||||
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}
|
||||
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:"
|
||||
exit 0
|
||||
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"
|
||||
If the issue is STILL not compliant:
|
||||
Post a comment explaining what still needs to be fixed. Keep the needs:compliance label."
|
||||
|
||||
@@ -6,18 +6,6 @@ on:
|
||||
|
||||
jobs:
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,14 +18,6 @@ jobs:
|
||||
const pr = context.payload.pull_request;
|
||||
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
|
||||
if (login === 'opencode-agent[bot]') return;
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
@@ -177,14 +157,6 @@ jobs:
|
||||
const pr = context.payload.pull_request;
|
||||
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
|
||||
if (login === 'opencode-agent[bot]') return;
|
||||
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) => {
|
||||
const key = ServerConnection.key(i)
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
@@ -447,8 +446,8 @@ export function DialogSelectServer() {
|
||||
>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
status={store.status[key]}
|
||||
dimmed={store.status[key]?.healthy === false}
|
||||
status={store.status[ServerConnection.key(i)]}
|
||||
dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
@@ -461,7 +460,7 @@ export function DialogSelectServer() {
|
||||
</Show>
|
||||
<Show when={store.editServer.id !== i.http.url}>
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -102,19 +102,15 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}),
|
||||
)
|
||||
|
||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||
const servers = [
|
||||
const allServers = createMemo(
|
||||
(): Array<ServerConnection.Any> => [
|
||||
...(props.servers ?? []),
|
||||
...store.list.map((value) => ({
|
||||
type: "http" as const,
|
||||
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({
|
||||
active: props.defaultServer,
|
||||
|
||||
@@ -535,7 +535,7 @@ export function MessageTimeline(props: {
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
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>
|
||||
|
||||
@@ -174,11 +174,11 @@ export function SessionPromptDock(props: {
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
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
|
||||
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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -168,17 +168,12 @@ function websearch(info: ToolProps<typeof WebSearchTool>) {
|
||||
}
|
||||
|
||||
function task(info: ToolProps<typeof TaskTool>) {
|
||||
const input = info.part.state.input
|
||||
const status = info.part.state.status
|
||||
const subagent =
|
||||
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 agent = Locale.titlecase(info.input.subagent_type)
|
||||
const desc = info.input.description
|
||||
const started = info.part.state.status === "running"
|
||||
const name = desc ?? `${agent} Task`
|
||||
inline({
|
||||
icon,
|
||||
icon: started ? "•" : "✓",
|
||||
title: name,
|
||||
description: desc ? `${agent} Agent` : undefined,
|
||||
})
|
||||
@@ -456,17 +451,9 @@ export const RunCommand = cmd({
|
||||
const part = event.properties.part
|
||||
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 (part.state.status === "completed") {
|
||||
tool(part)
|
||||
continue
|
||||
}
|
||||
inline({
|
||||
icon: "✗",
|
||||
title: `${part.tool} failed`,
|
||||
})
|
||||
UI.error(part.state.error)
|
||||
tool(part)
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -34,6 +34,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
await input.onExit?.()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
@@ -42,7 +43,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
}
|
||||
const text = store.get()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
await input.onExit?.()
|
||||
process.exit(0)
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
|
||||
@@ -3,10 +3,12 @@ import { tui } from "./app"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "./worker"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
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 workerPath = await iife(async () => {
|
||||
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
|
||||
})
|
||||
try {
|
||||
|
||||
@@ -104,9 +104,6 @@ export namespace UI {
|
||||
}
|
||||
|
||||
export function error(message: string) {
|
||||
if (message.startsWith("Error: ")) {
|
||||
message = message.slice("Error: ".length)
|
||||
}
|
||||
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
|
||||
}
|
||||
|
||||
|
||||
@@ -147,8 +147,7 @@ export namespace LSPClient {
|
||||
notify: {
|
||||
async open(input: { path: string }) {
|
||||
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
|
||||
const file = Bun.file(input.path)
|
||||
const text = await file.text()
|
||||
const text = await Filesystem.readText(input.path)
|
||||
const extension = path.extname(input.path)
|
||||
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export namespace LSPServer {
|
||||
"bin",
|
||||
"vue-language-server.js",
|
||||
)
|
||||
if (!(await Bun.file(js).exists())) {
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
@@ -173,14 +173,14 @@ export namespace LSPServer {
|
||||
if (!eslint) return
|
||||
log.info("spawning eslint server")
|
||||
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
|
||||
log.info("downloading and building VS Code ESLint server")
|
||||
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
|
||||
if (!response.ok) return
|
||||
|
||||
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)
|
||||
.then(() => true)
|
||||
@@ -242,7 +242,7 @@ export namespace LSPServer {
|
||||
|
||||
const resolveBin = async (target: string) => {
|
||||
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({
|
||||
targets: [target],
|
||||
@@ -326,7 +326,7 @@ export namespace LSPServer {
|
||||
async spawn(root) {
|
||||
const localBin = path.join(root, "node_modules", ".bin", "biome")
|
||||
let bin: string | undefined
|
||||
if (await Bun.file(localBin).exists()) bin = localBin
|
||||
if (await Filesystem.exists(localBin)) bin = localBin
|
||||
if (!bin) {
|
||||
const found = Bun.which("biome")
|
||||
if (found) bin = found
|
||||
@@ -467,7 +467,7 @@ export namespace LSPServer {
|
||||
const potentialPythonPath = isWindows
|
||||
? path.join(venvPath, "Scripts", "python.exe")
|
||||
: path.join(venvPath, "bin", "python")
|
||||
if (await Bun.file(potentialPythonPath).exists()) {
|
||||
if (await Filesystem.exists(potentialPythonPath)) {
|
||||
initialization["pythonPath"] = potentialPythonPath
|
||||
break
|
||||
}
|
||||
@@ -479,7 +479,7 @@ export namespace LSPServer {
|
||||
const potentialTyPath = isWindows
|
||||
? path.join(venvPath, "Scripts", "ty.exe")
|
||||
: path.join(venvPath, "bin", "ty")
|
||||
if (await Bun.file(potentialTyPath).exists()) {
|
||||
if (await Filesystem.exists(potentialTyPath)) {
|
||||
binary = potentialTyPath
|
||||
break
|
||||
}
|
||||
@@ -511,7 +511,7 @@ export namespace LSPServer {
|
||||
const args = []
|
||||
if (!binary) {
|
||||
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
|
||||
await Bun.spawn([BunProc.which(), "install", "pyright"], {
|
||||
cwd: Global.Path.bin,
|
||||
@@ -536,7 +536,7 @@ export namespace LSPServer {
|
||||
const potentialPythonPath = isWindows
|
||||
? path.join(venvPath, "Scripts", "python.exe")
|
||||
: path.join(venvPath, "bin", "python")
|
||||
if (await Bun.file(potentialPythonPath).exists()) {
|
||||
if (await Filesystem.exists(potentialPythonPath)) {
|
||||
initialization["pythonPath"] = potentialPythonPath
|
||||
break
|
||||
}
|
||||
@@ -571,7 +571,7 @@ export namespace LSPServer {
|
||||
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")
|
||||
if (!elixir) {
|
||||
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")
|
||||
if (!response.ok) return
|
||||
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)
|
||||
.then(() => true)
|
||||
@@ -692,7 +692,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
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") {
|
||||
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" : ""))
|
||||
|
||||
if (!(await Bun.file(bin).exists())) {
|
||||
if (!(await Filesystem.exists(bin))) {
|
||||
log.error("Failed to extract zls binary")
|
||||
return
|
||||
}
|
||||
@@ -857,7 +857,7 @@ export namespace LSPServer {
|
||||
// Stop at filesystem root
|
||||
const cargoTomlPath = path.join(currentDir, "Cargo.toml")
|
||||
try {
|
||||
const cargoTomlContent = await Bun.file(cargoTomlPath).text()
|
||||
const cargoTomlContent = await Filesystem.readText(cargoTomlPath)
|
||||
if (cargoTomlContent.includes("[workspace]")) {
|
||||
return currentDir
|
||||
}
|
||||
@@ -907,7 +907,7 @@ export namespace LSPServer {
|
||||
|
||||
const ext = process.platform === "win32" ? ".exe" : ""
|
||||
const direct = path.join(Global.Path.bin, "clangd" + ext)
|
||||
if (await Bun.file(direct).exists()) {
|
||||
if (await Filesystem.exists(direct)) {
|
||||
return {
|
||||
process: spawn(direct, args, {
|
||||
cwd: root,
|
||||
@@ -920,7 +920,7 @@ export namespace LSPServer {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (!entry.name.startsWith("clangd_")) continue
|
||||
const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext)
|
||||
if (await Bun.file(candidate).exists()) {
|
||||
if (await Filesystem.exists(candidate)) {
|
||||
return {
|
||||
process: spawn(candidate, args, {
|
||||
cwd: root,
|
||||
@@ -990,7 +990,7 @@ export namespace LSPServer {
|
||||
log.error("Failed to write clangd archive")
|
||||
return
|
||||
}
|
||||
await Bun.write(archive, buf)
|
||||
await Filesystem.write(archive, Buffer.from(buf))
|
||||
|
||||
const zip = name.endsWith(".zip")
|
||||
const tar = name.endsWith(".tar.xz")
|
||||
@@ -1014,7 +1014,7 @@ export namespace LSPServer {
|
||||
await fs.rm(archive, { force: true })
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -1045,7 +1045,7 @@ export namespace LSPServer {
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
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
|
||||
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
@@ -1092,7 +1092,7 @@ export namespace LSPServer {
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
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
|
||||
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
@@ -1248,7 +1248,7 @@ export namespace LSPServer {
|
||||
const distPath = path.join(Global.Path.bin, "kotlin-ls")
|
||||
const launcherScript =
|
||||
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 (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
log.info("Downloading Kotlin Language Server from GitHub.")
|
||||
@@ -1307,7 +1307,7 @@ export namespace LSPServer {
|
||||
}
|
||||
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}.`)
|
||||
return
|
||||
}
|
||||
@@ -1336,7 +1336,7 @@ export namespace LSPServer {
|
||||
"src",
|
||||
"server.js",
|
||||
)
|
||||
const exists = await Bun.file(js).exists()
|
||||
const exists = await Filesystem.exists(js)
|
||||
if (!exists) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
||||
@@ -1443,7 +1443,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
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,
|
||||
// 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
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -1516,7 +1516,7 @@ export namespace LSPServer {
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
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
|
||||
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
|
||||
cwd: Global.Path.bin,
|
||||
@@ -1613,7 +1613,7 @@ export namespace LSPServer {
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
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
|
||||
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
@@ -1654,22 +1654,17 @@ export namespace LSPServer {
|
||||
|
||||
if (!bin) {
|
||||
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) {
|
||||
log.error("Failed to fetch terraform-ls release info")
|
||||
return
|
||||
}
|
||||
|
||||
const release = (await releaseResponse.json()) as {
|
||||
tag_name?: string
|
||||
assets?: { name?: string; browser_download_url?: string }[]
|
||||
}
|
||||
const version = release.tag_name?.replace("v", "")
|
||||
if (!version) {
|
||||
log.error("terraform-ls release did not include a version tag")
|
||||
return
|
||||
version?: string
|
||||
builds?: { arch?: string; os?: string; url?: string }[]
|
||||
}
|
||||
|
||||
const platform = process.platform
|
||||
@@ -1678,23 +1673,21 @@ export namespace LSPServer {
|
||||
const tfArch = arch === "arm64" ? "arm64" : "amd64"
|
||||
const tfPlatform = platform === "win32" ? "windows" : platform
|
||||
|
||||
const assetName = `terraform-ls_${version}_${tfPlatform}_${tfArch}.zip`
|
||||
|
||||
const assets = release.assets ?? []
|
||||
const asset = assets.find((a) => a.name === assetName)
|
||||
if (!asset?.browser_download_url) {
|
||||
log.error(`Could not find asset ${assetName} in terraform-ls release`)
|
||||
const builds = release.builds ?? []
|
||||
const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform)
|
||||
if (!build?.url) {
|
||||
log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`)
|
||||
return
|
||||
}
|
||||
|
||||
const downloadResponse = await fetch(asset.browser_download_url)
|
||||
const downloadResponse = await fetch(build.url)
|
||||
if (!downloadResponse.ok) {
|
||||
log.error("Failed to download terraform-ls")
|
||||
return
|
||||
}
|
||||
|
||||
const tempPath = path.join(Global.Path.bin, assetName)
|
||||
await Bun.file(tempPath).write(downloadResponse)
|
||||
const tempPath = path.join(Global.Path.bin, "terraform-ls.zip")
|
||||
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
|
||||
|
||||
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
|
||||
.then(() => true)
|
||||
@@ -1707,7 +1700,7 @@ export namespace LSPServer {
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -1784,7 +1777,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
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") {
|
||||
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" : ""))
|
||||
|
||||
if (!(await Bun.file(bin).exists())) {
|
||||
if (!(await Filesystem.exists(bin))) {
|
||||
log.error("Failed to extract texlab binary")
|
||||
return
|
||||
}
|
||||
@@ -1832,7 +1825,7 @@ export namespace LSPServer {
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
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
|
||||
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
||||
cwd: Global.Path.bin,
|
||||
@@ -1990,7 +1983,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
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") {
|
||||
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" : ""))
|
||||
|
||||
if (!(await Bun.file(bin).exists())) {
|
||||
if (!(await Filesystem.exists(bin))) {
|
||||
log.error("Failed to extract tinymist binary")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import z from "zod"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
// Try to import bundled snapshot (generated at build time)
|
||||
// Falls back to undefined in dev mode when snapshot doesn't exist
|
||||
@@ -85,8 +86,7 @@ export namespace ModelsDev {
|
||||
}
|
||||
|
||||
export const Data = lazy(async () => {
|
||||
const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
const snapshot = await import("./models-snapshot")
|
||||
@@ -104,7 +104,6 @@ export namespace ModelsDev {
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
const file = Bun.file(filepath)
|
||||
const result = await fetch(`${url()}/api.json`, {
|
||||
headers: {
|
||||
"User-Agent": Installation.USER_AGENT,
|
||||
@@ -116,7 +115,7 @@ export namespace ModelsDev {
|
||||
})
|
||||
})
|
||||
if (result && result.ok) {
|
||||
await Bun.write(file, await result.text())
|
||||
await Filesystem.write(filepath, await result.text())
|
||||
ModelsDev.Data.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { MCP } from "../mcp"
|
||||
import { LSP } from "../lsp"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
@@ -1082,11 +1083,9 @@ export namespace SessionPrompt {
|
||||
// have to normalize, symbol search returns absolute paths
|
||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||
const filepath = fileURLToPath(part.url)
|
||||
const stat = await Bun.file(filepath)
|
||||
.stat()
|
||||
.catch(() => undefined)
|
||||
const s = Filesystem.stat(filepath)
|
||||
|
||||
if (stat?.isDirectory()) {
|
||||
if (s?.isDirectory()) {
|
||||
part.mime = "application/x-directory"
|
||||
}
|
||||
|
||||
@@ -1233,14 +1232,13 @@ export namespace SessionPrompt {
|
||||
]
|
||||
}
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
FileTime.read(input.sessionID, filepath)
|
||||
return [
|
||||
{
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
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,
|
||||
},
|
||||
{
|
||||
@@ -1248,7 +1246,8 @@ export namespace SessionPrompt {
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
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,
|
||||
filename: part.filename!,
|
||||
source: part.source,
|
||||
@@ -1354,7 +1353,7 @@ export namespace SessionPrompt {
|
||||
// Switching from plan mode to build mode
|
||||
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
|
||||
const plan = Session.plan(input.session)
|
||||
const exists = await Bun.file(plan).exists()
|
||||
const exists = await Filesystem.exists(plan)
|
||||
if (exists) {
|
||||
const part = await Session.updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
@@ -1373,7 +1372,7 @@ export namespace SessionPrompt {
|
||||
// Entering plan mode
|
||||
if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") {
|
||||
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 })
|
||||
const part = await Session.updatePart({
|
||||
id: Identifier.ascending("part"),
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "path"
|
||||
import { mkdir } from "fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace Discovery {
|
||||
const log = Log.create({ service: "skill-discovery" })
|
||||
@@ -19,14 +20,14 @@ export namespace Discovery {
|
||||
}
|
||||
|
||||
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)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
log.error("failed to download", { url, status: response.status })
|
||||
return false
|
||||
}
|
||||
await Bun.write(dest, await response.text())
|
||||
if (response.body) await Filesystem.writeStream(dest, response.body)
|
||||
return true
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -88,7 +89,7 @@ export namespace Discovery {
|
||||
)
|
||||
|
||||
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 { existsSync, statSync } from "fs"
|
||||
import { chmod, mkdir, readFile, writeFile } from "fs/promises"
|
||||
import { createWriteStream, existsSync, statSync } from "fs"
|
||||
import { lookup } from "mime-types"
|
||||
import { realpathSync } from "fs"
|
||||
import { dirname, join, relative } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
|
||||
export namespace Filesystem {
|
||||
// Fast sync version for metadata checks
|
||||
@@ -68,6 +70,25 @@ export namespace Filesystem {
|
||||
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 {
|
||||
return lookup(p) || "application/octet-stream"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createWriteStream } from "fs"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
|
||||
@@ -63,13 +64,15 @@ export namespace Log {
|
||||
Global.Path.log,
|
||||
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
)
|
||||
const logfile = Bun.file(logpath)
|
||||
await fs.truncate(logpath).catch(() => {})
|
||||
const writer = logfile.writer()
|
||||
const stream = createWriteStream(logpath, { flags: "a" })
|
||||
write = async (msg: any) => {
|
||||
const num = writer.write(msg)
|
||||
writer.flush()
|
||||
return num
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(msg, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve(msg.length)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,4 +285,125 @@ describe("filesystem", () => {
|
||||
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
|
||||
process.chdir(dir)
|
||||
|
||||
const pkg = (await import("../package.json").then((m) => m.default)) as {
|
||||
exports: Record<string, string | object>
|
||||
}
|
||||
const pkg = await import("../package.json").then((m) => m.default)
|
||||
const original = JSON.parse(JSON.stringify(pkg))
|
||||
function transformExports(exports: Record<string, string | object>) {
|
||||
for (const [key, value] of Object.entries(exports)) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
transformExports(value as Record<string, string | object>)
|
||||
} else if (typeof value === "string") {
|
||||
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
||||
exports[key] = {
|
||||
import: file + ".js",
|
||||
types: file + ".d.ts",
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(pkg.exports)) {
|
||||
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
||||
pkg.exports[key] = {
|
||||
import: file + ".js",
|
||||
types: file + ".d.ts",
|
||||
}
|
||||
}
|
||||
transformExports(pkg.exports)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
|
||||
await $`bun pm pack`
|
||||
await $`npm publish *.tgz --tag ${Script.channel} --access public`
|
||||
|
||||
@@ -29,10 +29,11 @@
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow-icon"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
|
||||
display: inline-flex;
|
||||
color: var(--icon-weaker);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover [data-slot="collapsible-arrow"] {
|
||||
@@ -73,8 +74,12 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow-icon"] {
|
||||
transform: rotate(0deg);
|
||||
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
|
||||
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">) {
|
||||
return (
|
||||
<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" />
|
||||
</span>
|
||||
</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-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"/>`,
|
||||
"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"/>`,
|
||||
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"/>`,
|
||||
|
||||
@@ -65,61 +65,14 @@
|
||||
top: -40px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-diffs-group"] {
|
||||
background-color: var(--background-stronger);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
overflow: clip;
|
||||
[data-slot="accordion-trigger"] {
|
||||
background-color: var(--background-stronger) !important;
|
||||
}
|
||||
|
||||
[data-component="accordion"] {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[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);
|
||||
[data-slot="session-review-accordion-item"][data-selected] {
|
||||
[data-slot="session-review-accordion-content"] {
|
||||
box-shadow: var(--shadow-xs-border-select);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,13 +110,12 @@
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-file-name-container"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -194,8 +146,6 @@
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
transform: translateZ(0);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
@@ -216,23 +166,6 @@
|
||||
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"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
@@ -320,395 +319,385 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
</div>
|
||||
<div data-slot="session-review-container" class={props.classes?.container}>
|
||||
<Show when={hasDiffs()} fallback={props.empty}>
|
||||
<div data-slot="session-review-diffs-group">
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={props.diffs}>
|
||||
{(diff) => {
|
||||
let wrapper: HTMLDivElement | undefined
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={props.diffs}>
|
||||
{(diff) => {
|
||||
let wrapper: HTMLDivElement | undefined
|
||||
|
||||
const expanded = createMemo(() => open().includes(diff.file))
|
||||
const [force, setForce] = createSignal(false)
|
||||
const expanded = createMemo(() => open().includes(diff.file))
|
||||
const [force, setForce] = createSignal(false)
|
||||
|
||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||
const changedLines = () => diff.additions + diff.deletions
|
||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||
const changedLines = () => diff.additions + diff.deletions
|
||||
|
||||
const tooLarge = createMemo(() => {
|
||||
if (!expanded()) return false
|
||||
if (force()) return false
|
||||
if (isImageFile(diff.file)) return false
|
||||
return changedLines() > MAX_DIFF_CHANGED_LINES
|
||||
})
|
||||
const tooLarge = createMemo(() => {
|
||||
if (!expanded()) return false
|
||||
if (force()) return false
|
||||
if (isImageFile(diff.file)) return false
|
||||
return changedLines() > MAX_DIFF_CHANGED_LINES
|
||||
})
|
||||
|
||||
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
const isDeleted = () =>
|
||||
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
const isImage = () => isImageFile(diff.file)
|
||||
const isAudio = () => isAudioFile(diff.file)
|
||||
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
const isDeleted = () =>
|
||||
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
const isImage = () => isImageFile(diff.file)
|
||||
const isAudio = () => isAudioFile(diff.file)
|
||||
|
||||
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
|
||||
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
|
||||
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||
|
||||
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
|
||||
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
||||
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
|
||||
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
||||
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
if (!current || current.file !== diff.file) return null
|
||||
return current.range
|
||||
})
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
if (!current || current.file !== diff.file) return null
|
||||
return current.range
|
||||
})
|
||||
|
||||
const draftRange = createMemo(() => {
|
||||
const current = commenting()
|
||||
if (!current || current.file !== diff.file) return null
|
||||
return current.range
|
||||
})
|
||||
const draftRange = createMemo(() => {
|
||||
const current = commenting()
|
||||
if (!current || current.file !== diff.file) return null
|
||||
return current.range
|
||||
})
|
||||
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||
|
||||
const getRoot = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
const getRoot = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host.shadowRoot ?? undefined
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
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 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 marker = findMarker(root, range)
|
||||
if (!marker) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
setDraftTop(markerTop(el, marker))
|
||||
const marker = findMarker(root, range)
|
||||
if (!marker) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const scheduleAnchors = () => {
|
||||
requestAnimationFrame(updateAnchors)
|
||||
}
|
||||
setDraftTop(markerTop(el, marker))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
comments()
|
||||
scheduleAnchors()
|
||||
})
|
||||
const scheduleAnchors = () => {
|
||||
requestAnimationFrame(updateAnchors)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const range = draftRange()
|
||||
if (!range) return
|
||||
setDraft("")
|
||||
scheduleAnchors()
|
||||
})
|
||||
createEffect(() => {
|
||||
comments()
|
||||
scheduleAnchors()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!open().includes(diff.file)) return
|
||||
if (!isImage()) return
|
||||
if (imageSrc()) return
|
||||
if (imageStatus() !== "idle") return
|
||||
if (isDeleted()) return
|
||||
createEffect(() => {
|
||||
const range = draftRange()
|
||||
if (!range) return
|
||||
setDraft("")
|
||||
scheduleAnchors()
|
||||
})
|
||||
|
||||
const reader = props.readFile
|
||||
if (!reader) return
|
||||
createEffect(() => {
|
||||
if (!open().includes(diff.file)) return
|
||||
if (!isImage()) return
|
||||
if (imageSrc()) return
|
||||
if (imageStatus() !== "idle") return
|
||||
if (isDeleted()) return
|
||||
|
||||
setImageStatus("loading")
|
||||
reader(diff.file)
|
||||
.then((result) => {
|
||||
const src = dataUrl(result)
|
||||
if (!src) {
|
||||
setImageStatus("error")
|
||||
return
|
||||
}
|
||||
setImageSrc(src)
|
||||
setImageStatus("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
const reader = props.readFile
|
||||
if (!reader) return
|
||||
|
||||
setImageStatus("loading")
|
||||
reader(diff.file)
|
||||
.then((result) => {
|
||||
const src = dataUrl(result)
|
||||
if (!src) {
|
||||
setImageStatus("error")
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
setImageSrc(src)
|
||||
setImageStatus("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setImageStatus("error")
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!open().includes(diff.file)) return
|
||||
if (!isAudio()) return
|
||||
if (audioSrc()) return
|
||||
if (audioStatus() !== "idle") return
|
||||
createEffect(() => {
|
||||
if (!open().includes(diff.file)) return
|
||||
if (!isAudio()) return
|
||||
if (audioSrc()) return
|
||||
if (audioStatus() !== "idle") return
|
||||
|
||||
const reader = props.readFile
|
||||
if (!reader) return
|
||||
const reader = props.readFile
|
||||
if (!reader) return
|
||||
|
||||
setAudioStatus("loading")
|
||||
reader(diff.file)
|
||||
.then((result) => {
|
||||
const src = dataUrl(result)
|
||||
if (!src) {
|
||||
setAudioStatus("error")
|
||||
return
|
||||
}
|
||||
setAudioMime(normalizeMimeType(result?.mimeType))
|
||||
setAudioSrc(src)
|
||||
setAudioStatus("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setAudioStatus("loading")
|
||||
reader(diff.file)
|
||||
.then((result) => {
|
||||
const src = dataUrl(result)
|
||||
if (!src) {
|
||||
setAudioStatus("error")
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
setAudioMime(normalizeMimeType(result?.mimeType))
|
||||
setAudioSrc(src)
|
||||
setAudioStatus("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setAudioStatus("error")
|
||||
})
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
|
||||
if (!range) {
|
||||
setSelection(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSelection({ file: diff.file, range })
|
||||
if (!range) {
|
||||
setSelection(null)
|
||||
return
|
||||
}
|
||||
|
||||
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
setSelection({ file: diff.file, range })
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
|
||||
setSelection({ file: diff.file, range })
|
||||
setCommenting({ file: diff.file, range })
|
||||
if (!range) {
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
const openComment = (comment: SessionReviewComment) => {
|
||||
setOpened({ file: comment.file, id: comment.id })
|
||||
setSelection({ file: comment.file, range: comment.selection })
|
||||
}
|
||||
setSelection({ file: diff.file, range })
|
||||
setCommenting({ file: diff.file, range })
|
||||
}
|
||||
|
||||
const isCommentOpen = (comment: SessionReviewComment) => {
|
||||
const current = opened()
|
||||
if (!current) return false
|
||||
return current.file === comment.file && current.id === comment.id
|
||||
}
|
||||
const openComment = (comment: SessionReviewComment) => {
|
||||
setOpened({ file: comment.file, id: comment.id })
|
||||
setSelection({ file: comment.file, range: comment.selection })
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item
|
||||
value={diff.file}
|
||||
id={diffId(diff.file)}
|
||||
data-file={diff.file}
|
||||
data-slot="session-review-accordion-item"
|
||||
data-selected={props.focusedFile === diff.file ? "" : undefined}
|
||||
>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-review-trigger-content">
|
||||
<div data-slot="session-review-file-info">
|
||||
<FileIcon node={{ path: diff.file, type: "file" }} />
|
||||
<div data-slot="session-review-file-name-container">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
||||
<Show when={props.onViewFile}>
|
||||
<Tooltip value="Open file" placement="top" gutter={4}>
|
||||
<button
|
||||
data-slot="session-review-view-button"
|
||||
type="button"
|
||||
aria-label="Open file"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onViewFile?.(diff.file)
|
||||
}}
|
||||
>
|
||||
<Icon name="open-file" size="small" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<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>
|
||||
const isCommentOpen = (comment: SessionReviewComment) => {
|
||||
const current = opened()
|
||||
if (!current) return false
|
||||
return current.file === comment.file && current.id === comment.id
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item
|
||||
value={diff.file}
|
||||
id={diffId(diff.file)}
|
||||
data-file={diff.file}
|
||||
data-slot="session-review-accordion-item"
|
||||
data-selected={props.focusedFile === diff.file ? "" : undefined}
|
||||
>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-review-trigger-content">
|
||||
<div data-slot="session-review-file-info">
|
||||
<FileIcon node={{ path: diff.file, type: "file" }} />
|
||||
<div data-slot="session-review-file-name-container">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
||||
<Show when={props.onViewFile}>
|
||||
<button
|
||||
data-slot="session-review-view-button"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onViewFile?.(diff.file)
|
||||
}}
|
||||
>
|
||||
<Icon name="eye" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</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()}>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<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 when={isAdded()}>
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
</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 when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{i18n.t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
</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 when={isImage()}>
|
||||
<span data-slot="session-review-change" data-type="modified">
|
||||
{i18n.t("ui.sessionReview.change.modified")}
|
||||
</span>
|
||||
</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 when={true}>
|
||||
<DiffChanges changes={diff} />
|
||||
</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>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,49 +127,7 @@
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-trigger"] {
|
||||
@@ -182,11 +140,12 @@
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-path"] {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
@@ -194,13 +153,6 @@
|
||||
|
||||
[data-slot="session-turn-diff-directory"] {
|
||||
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"] {
|
||||
|
||||
@@ -315,78 +315,76 @@ export function SessionTurn(
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<div data-slot="session-turn-diffs-group">
|
||||
<Accordion
|
||||
multiple
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
<Accordion
|
||||
multiple
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">
|
||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">
|
||||
{getFilename(diff.file)}
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">
|
||||
{getDirectory(diff.file)}
|
||||
</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>
|
||||
<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>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"border-selected": "var(--cobalt-dark-alpha-11)",
|
||||
"border-disabled": "var(--gray-dark-alpha-8)",
|
||||
"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-active": "var(--gray-dark-alpha-8)",
|
||||
"border-weak-selected": "var(--cobalt-dark-alpha-6)",
|
||||
|
||||
Reference in New Issue
Block a user