Compare commits

..
Author SHA1 Message Date
Dax Raad 2a6f89d705 core: use Filesystem utility for consistent file operations with better error handling 2026-02-18 18:26:57 -05:00
Dax Raad 3871578db6 refactor: use writeStream for downloading skills to avoid buffering 2026-02-18 18:05:42 -05:00
DaxandGitHub f380c757ff Merge branch 'dev' into migrate-skill-discovery 2026-02-18 17:53:44 -05:00
Dax Raad 8fd4568071 refactor: migrate src/skill/discovery.ts from Bun.file()/Bun.write() to Filesystem module
Replace Bun-specific file operations with Filesystem module:

- Add Filesystem import from ../util/filesystem

- Replace Bun.file().exists() with Filesystem.exists()

- Replace Bun.write() with Filesystem.write()

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