Compare commits

..
Author SHA1 Message Date
Dax 9291fb7ffe Merge branch 'dev' into migrate-mcp 2026-02-18 12:14:01 -05:00
Dax 3d189b42a3 refactor: migrate file/ripgrep.ts from Bun.file()/Bun.write() to Filesystem module (#14159) 2026-02-18 12:10:42 -05:00
opencode-agent[bot] 91a3ee642d chore: update nix node_modules hashes 2026-02-18 17:08:31 +00:00
Dax 37b24f4870 refactor: migrate index.ts from Bun.file() to Filesystem module (#14160) 2026-02-18 12:03:24 -05:00
Alexander AbramovandAlexander Abramov 38572b8175 feat: add Julia language server support (#14129)
Co-authored-by: Alexander Abramov <abramov231@gmail.com>
2026-02-18 11:02:29 -06:00
Aiden Cline fc1addb8f4 ignore: tweak contributing md (#14168) 2026-02-18 11:01:59 -06:00
Vladimir Glafirov 83b7d8e04c feat: GitLab Duo - bump gitlab-ai-provider to 3.6.0 (adds Sonnet 4.6) (#14115) 2026-02-18 10:58:38 -06:00
Mike Harris d27dbfe062 fix(cli): session list --max-count not honored, shows too few sessions (#14162) 2026-02-18 10:56:37 -06:00
Dax Raad ad30e5b378 refactor: migrate cli/cmd/mcp.ts from Bun.file()/Bun.write() to Filesystem module
Replace Bun-specific file operations with Filesystem module:

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

- Replace Bun.file().text() with Filesystem.readText()

- Replace Bun.write() with Filesystem.write()
2026-02-18 11:17:49 -05:00
Brendan Allan f8904e3972 desktop: handle sidecar key in projectsKey 2026-02-19 00:05:50 +08:00
Brendan Allan 4a5823562c desktop: fix isLocal 2026-02-19 00:03:08 +08:00
opencode-agent[bot] 3aaf29b693 chore: update nix node_modules hashes 2026-02-18 15:41:20 +00:00
Dax 6b29896a35 feat: Add centralized filesystem module for Bun.file migration (#14117) 2026-02-18 15:30:52 +00:00
Brendan Allan 1bb8574179 app: refactor server management backend (#13813) 2026-02-18 23:03:24 +08:00
Adam 2611c35acc fix(app): lower threshold for diff hiding 2026-02-18 08:40:05 -06:00
Adam 00c238777a chore: cleanup (#14113) 2026-02-18 08:26:15 -06:00
Dax Raad e4b548fa76 docs: add policy about AI-generated security reports
We receive a large number of AI-generated security reports and don't have the resources to review them all. This policy clarifies that such submissions will result in an automatic ban to protect our maintainers' time.
2026-02-18 09:15:18 -05:00
Adam e132dd2c70 chore: cleanup 2026-02-18 07:22:36 -06:00
David Hill fbe9669c57 fix: use group-hover for file tree icon color swap at all nesting levels 2026-02-18 13:20:02 +00:00
Adam c34ad7223a chore: cleanup 2026-02-18 07:12:54 -06:00
David Hill cc86a64bb5 tui: simplify mode toggle icon styling
Use consistent strong color for active mode icons instead of different
colors for shell vs normal mode, making the active state more visually
clear to users.
2026-02-18 12:35:28 +00:00
Adam 3394402aef chore: cleanup 2026-02-18 06:32:35 -06:00
Brendan Allan 6cd3a59022 desktop: cleanup 2026-02-18 16:24:28 +08:00
Brendan Allan 5aeb305344 desktop: temporarily disable wsl 2026-02-18 16:10:07 +08:00
Caleb Norton 6eb043aedb ci: allow commits on top of beta PRs (#11924) 2026-02-18 00:20:05 -06:00
Salam ElbiligandAiden Cline e96f6385c2 fix(opencode): fix Clojure syntax highlighting (#13453)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-18 00:18:18 -06:00
Jérôme Benoit 1109a282e0 ci: add nix-eval workflow for cross-platform flake evaluation (#12175) 2026-02-18 00:03:37 -06:00
Aiden Cline 25f3eef957 fix: ensure explore subagent has external_directory perm set to ask instead of auto denying (#14060) 2026-02-17 20:16:55 -06:00
88 changed files with 4019 additions and 1777 deletions
@@ -1,46 +0,0 @@
name: nix-desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
workflow_dispatch:
jobs:
nix-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15-intel
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L
+95
View File
@@ -0,0 +1,95 @@
name: nix-eval
on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
nix-eval:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Evaluate flake outputs (all systems)
run: |
set -euo pipefail
nix --version
echo "=== Flake metadata ==="
nix flake metadata
echo ""
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
PACKAGES="opencode"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
echo ""
echo "=== Evaluating packages for all systems ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for packages.$system.$pkg"
echo "$output"
exit 1
fi
done
done
echo ""
echo "=== Evaluating optional packages ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $OPTIONAL_PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::warning::Evaluation failed for packages.$system.$pkg"
echo "$output"
fi
done
done
echo ""
echo "=== Evaluating devShells for all systems ==="
for system in $SYSTEMS; do
printf "%s: " "$system"
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for devShells.$system.default"
echo "$output"
exit 1
fi
done
echo ""
echo "=== All evaluations passed ==="
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [dev] branches: [dev, beta]
paths: paths:
- "bun.lock" - "bun.lock"
- "package.json" - "package.json"
+9
View File
@@ -0,0 +1,9 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}
+5
View File
@@ -24,6 +24,11 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on. Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
## Adding New Providers
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
## Developing OpenCode ## Developing OpenCode
- Requirements: Bun 1.3+ - Requirements: Bun 1.3+
+6
View File
@@ -1,5 +1,11 @@
# Security # Security
## IMPORTANT
We do not accept AI generated security reports. We receive a large number of
these and we absolutely do not have the resources to review them all. If you
submit one that will be an automatic ban from the project.
## Threat Model ## Threat Model
### Overview ### Overview
+7 -2
View File
@@ -14,6 +14,7 @@
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"husky": "9.1.7", "husky": "9.1.7",
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
@@ -288,7 +289,7 @@
"@ai-sdk/vercel": "1.0.33", "@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.5.1", "@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@@ -324,6 +325,7 @@
"hono-openapi": "catalog:", "hono-openapi": "catalog:",
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
@@ -356,6 +358,7 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5", "@types/babel__core": "7.20.5",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -989,7 +992,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-I8+EGdUeKmGJSjAdFobHtqpxM9Fm00w0j7NJbtln/D/XQ1SKEGoZIuqJko4v0pV2mkhGUIs7qezljH/2kbXovA=="], "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="], "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
@@ -1917,6 +1920,8 @@
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="], "@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=", "x86_64-linux": "sha256-5sXHoHbRdXbqM/zRJZiXt26sm/yyyZN/4OOHUtdofhk=",
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=", "aarch64-linux": "sha256-JCMm5X7e27BBV4wyaknCMM4CBt4Lr72SSvaGxEeNsJE=",
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=", "aarch64-darwin": "sha256-DBQJURlTPqFt0OYUHSvZZ4H0NUf020aic4zNX5CXzDc=",
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0=" "x86_64-darwin": "sha256-t2luVxqCcRSgq/WNWkm4ZpKXO22n2RnAWP6msoTOr+A="
} }
} }
+1
View File
@@ -69,6 +69,7 @@
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"husky": "9.1.7", "husky": "9.1.7",
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
+37 -64
View File
@@ -1,35 +1,36 @@
import "@/index.css" import "@/index.css"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync" import { MetaProvider } from "@solidjs/meta"
import { PermissionProvider } from "@/context/permission" import { Navigate, Route, Router } from "@solidjs/router"
import { LayoutProvider } from "@/context/layout" import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server" import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
@@ -57,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean } __OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
} }
} }
@@ -107,30 +112,6 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
) )
} }
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) { export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
@@ -157,27 +138,19 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( return (
<Show when={server.url} keyed> <Show when={server.key} keyed>
{props.children} {props.children}
</Show> </Show>
) )
} }
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { export function AppInterface(props: {
const platform = usePlatform() children?: JSX.Element
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform) defaultServer: ServerConnection.Key
const defaultServerUrl = resolveDefaultServerUrl({ servers?: Array<ServerConnection.Any>
defaultUrl: props.defaultUrl, }) {
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
return ( return (
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
@@ -1,19 +1,18 @@
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { Button } from "@opencode-ai/ui/button"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { useGlobalSDK } from "@/context/global-sdk" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps { interface AddRowProps {
@@ -89,7 +88,7 @@ function useServerPreview(fetcher: typeof fetch) {
if (!looksComplete(value)) return if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) return if (!normalized) return
const result = await checkServerHealth(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
@@ -171,14 +170,13 @@ export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher) const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>, status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
adding: false, adding: false,
@@ -214,24 +212,25 @@ export function DialogSelectServer() {
}) })
} }
const replaceServer = (original: string, next: string) => { const replaceServer = (original: ServerConnection.Http, next: string) => {
const active = server.url const active = server.key
const nextActive = active === original ? next : active const newConn = server.add(next)
if (!newConn) return
server.add(next) const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive) if (nextActive) server.setActive(nextActive)
server.remove(original) server.remove(ServerConnection.key(original))
} }
const items = createMemo(() => { const items = createMemo(() => {
const current = server.url const current = server.current
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (!list.includes(current)) return [current, ...list] if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)] return [current, ...list.filter((x) => x !== current)]
}) })
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0]) const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const list = items() const list = items()
@@ -246,17 +245,17 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1 if (a === active) return -1
if (b === active) return 1 if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b]) const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
}) })
async function refreshHealth() { async function refreshHealth() {
const results: Record<string, ServerHealth> = {} const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all( await Promise.all(
items().map(async (url) => { items().map(async (conn) => {
results[url] = await checkServerHealth(url, fetcher) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@@ -269,15 +268,15 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval)) onCleanup(() => clearInterval(interval))
}) })
async function select(value: string, persist?: boolean) { async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close() dialog.close()
if (persist) { if (persist) {
server.add(value) server.add(conn.http.url)
navigate("/") navigate("/")
return return
} }
server.setActive(value) server.setActive(ServerConnection.key(conn))
navigate("/") navigate("/")
} }
@@ -311,7 +310,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" }) setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("addServer", { adding: false }) setStore("addServer", { adding: false })
if (!result.healthy) { if (!result.healthy) {
@@ -320,25 +319,25 @@ export function DialogSelectServer() {
} }
resetAdd() resetAdd()
await select(normalized, true) await select({ type: "http", http: { url: normalized } }, true)
} }
async function handleEdit(original: string, value: string) { async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy) return if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) { if (!normalized) {
resetEdit() resetEdit()
return return
} }
if (normalized === original) { if (normalized === original.http.url) {
resetEdit() resetEdit()
return return
} }
setStore("editServer", { busy: true, error: "" }) setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("editServer", { busy: false }) setStore("editServer", { busy: false })
if (!result.healthy) { if (!result.healthy) {
@@ -366,7 +365,7 @@ export function DialogSelectServer() {
handleAdd(store.addServer.url) handleAdd(store.addServer.url)
} }
const handleEditKey = (event: KeyboardEvent, original: string) => { const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
event.stopPropagation() event.stopPropagation()
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
@@ -378,7 +377,7 @@ export function DialogSelectServer() {
handleEdit(original, store.editServer.value) handleEdit(original, store.editServer.value)
} }
async function handleRemove(url: string) { async function handleRemove(url: ServerConnection.Key) {
server.remove(url) server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) { if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null) platform.setDefaultServerUrl?.(null)
@@ -390,11 +389,14 @@ export function DialogSelectServer() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}> <div ref={(el) => (listRoot = el)}>
<List <List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={sortedItems} items={sortedItems}
key={(x) => x} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x) select(x) if (x) select(x)
}} }}
@@ -428,7 +430,7 @@ export function DialogSelectServer() {
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
when={store.editServer.id !== i} when={store.editServer.id !== i.http.url}
fallback={ fallback={
<EditRow <EditRow
value={store.editServer.value} value={store.editServer.value}
@@ -443,12 +445,12 @@ export function DialogSelectServer() {
} }
> >
<ServerRow <ServerRow
url={i} conn={i}
status={store.status[i]} status={store.status[ServerConnection.key(i)]}
dimmed={store.status[i]?.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}> <Show when={defaultUrl() === i.http.url}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -456,59 +458,63 @@ export function DialogSelectServer() {
} }
/> />
</Show> </Show>
<Show when={store.editServer.id !== i}> <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={current() === i}> <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>
<DropdownMenu> <Show when={i.type === "http"}>
<DropdownMenu.Trigger <DropdownMenu>
as={IconButton} <DropdownMenu.Trigger
icon="dot-grid" as={IconButton}
variant="ghost" icon="dot-grid"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" variant="ghost"
onClick={(e: MouseEvent) => e.stopPropagation()} class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onPointerDown={(e: PointerEvent) => e.stopPropagation()} onClick={(e: MouseEvent) => e.stopPropagation()}
/> onPointerDown={(e: PointerEvent) => e.stopPropagation()}
<DropdownMenu.Portal> />
<DropdownMenu.Content class="mt-1"> <DropdownMenu.Portal>
<DropdownMenu.Item <DropdownMenu.Content class="mt-1">
onSelect={() => { <DropdownMenu.Item
setStore("editServer", { onSelect={() => {
id: i, setStore("editServer", {
value: i, id: i.http.url,
error: "", value: i.http.url,
status: store.status[i]?.healthy, error: "",
}) status: store.status[ServerConnection.key(i)]?.healthy,
}} })
> }}
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> >
</DropdownMenu.Item> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
<Show when={canDefault() && defaultUrl() !== i}> </DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => setDefault(i)}> <Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </DropdownMenu.Content>
<Show when={canDefault() && defaultUrl() === i}> </DropdownMenu.Portal>
<DropdownMenu.Item onSelect={() => setDefault(null)}> </DropdownMenu>
<DropdownMenu.ItemLabel> </Show>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div> </div>
</Show> </Show>
</div> </div>
+9 -2
View File
@@ -550,8 +550,15 @@ export default function FileTree(props: {
</Match> </Match>
<Match when={!node.ignored}> <Match when={!node.ignored}>
<span class="filetree-iconpair size-4"> <span class="filetree-iconpair size-4">
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" /> <FileIcon
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono /> node={node}
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
/>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
mono
/>
</span> </span>
</Match> </Match>
</Switch> </Switch>
+1 -2
View File
@@ -1375,8 +1375,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
name={mode === "shell" ? "console" : "prompt"} name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]" class="size-[18px]"
classList={{ classList={{
"text-icon-strong-base": mode === "shell" && store.mode === "shell", "text-icon-strong-base": store.mode === mode,
"text-icon-interactive-base": mode === "normal" && store.mode === "normal",
"text-icon-weak": store.mode !== mode, "text-icon-weak": store.mode !== mode,
}} }}
/> />
@@ -12,24 +12,27 @@ let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const clientFor = (directory: string) => ({ const clientFor = (directory: string) => {
session: { createdClients.push(directory)
create: async () => { return {
createdSessions.push(directory) session: {
return { data: { id: `session-${createdSessions.length}` } } create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
}, },
shell: async () => { worktree: {
sentShell.push(directory) create: async () => ({ data: { directory: `${directory}/new` } }),
return { data: undefined }
}, },
prompt: async () => ({ data: undefined }), }
command: async () => ({ data: undefined }), }
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
})
beforeAll(async () => { beforeAll(async () => {
const rootClient = clientFor("/repo/main") const rootClient = clientFor("/repo/main")
@@ -88,11 +91,17 @@ beforeAll(async () => {
})) }))
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => ({ useSDK: () => {
directory: "/repo/main", const sdk = {
client: rootClient, directory: "/repo/main",
url: "http://localhost:4096", client: rootClient,
}), url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
},
}
return sdk
},
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
@@ -1,21 +1,20 @@
import { Accessor } from "solid-js" import type { Message } from "@opencode-ai/sdk/v2/client"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useLocal } from "@/context/local" import { useNavigate, useParams } from "@solidjs/router"
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal() const local = useLocal()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
@@ -175,9 +173,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = createOpencodeClient({ client = sdk.createClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory, directory: sessionDirectory,
throwOnError: true, throwOnError: true,
}) })
@@ -372,7 +368,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timer = { id: undefined as number | undefined } const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => { timer.id = window.setTimeout(() => {
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs) }, timeoutMs)
}) })
@@ -1,10 +1,19 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import {
import { serverDisplayName } from "@/context/server" createEffect,
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps { interface ServerRowProps extends ParentProps {
url: string conn: ServerConnection.Any
status?: ServerHealth status?: ServerHealth
class?: string class?: string
nameClass?: string nameClass?: string
@@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.url)) const name = createMemo(() => serverDisplayName(props.conn))
const check = () => { const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => { createEffect(() => {
name() name()
props.url props.conn.http.url
props.status?.version props.status?.version
queueMicrotask(check) queueMicrotask(check)
}) })
@@ -5,6 +5,7 @@ import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array" import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -16,13 +17,6 @@ import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = { const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)", system: "var(--syntax-info)",
user: "var(--syntax-success)", user: "var(--syntax-success)",
@@ -91,11 +85,45 @@ function RawMessage(props: {
) )
} }
export function SessionContextTab(props: SessionContextTabProps) { const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
},
emptyMessages,
{ equals: same },
)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.locale(), { new Intl.NumberFormat(language.locale(), {
@@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale())) const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
@@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
const all = props.messages() const all = messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return { return {
@@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}) })
const systemPrompt = createMemo(() => { const systemPrompt = createMemo(() => {
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system) const msg = findLast(visibleUserMessages(), (m) => !!m.system)
const system = msg?.system const system = msg?.system
if (!system) return if (!system) return
const trimmed = system.trim() const trimmed = system.trim()
@@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
const breakdown = createMemo( const breakdown = createMemo(
on( on(
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()], () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => { () => {
const c = ctx() const c = ctx()
if (!c?.input) return [] if (!c?.input) return []
return estimateSessionContextBreakdown({ return estimateSessionContextBreakdown({
messages: props.messages(), messages: messages(),
parts: sync.data.part as Record<string, Part[] | undefined>, parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input, input: c.input,
systemPrompt: systemPrompt(), systemPrompt: systemPrompt(),
@@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
} }
const stats = [ const stats = [
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" }, { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
@@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost }, { label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) }, { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[] ] satisfies { label: string; value: () => JSX.Element }[]
@@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = props.view()?.scroll("context") const s = view().scroll("context")
if (!s) return if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollTop !== s.y) el.scrollTop = s.y
@@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
pending = undefined pending = undefined
if (!next) return if (!next) return
props.view().setScroll("context", next) view().setScroll("context", next)
}) })
} }
createEffect( createEffect(
on( on(
() => props.messages().length, () => messages().length,
() => { () => {
requestAnimationFrame(restoreScroll) requestAnimationFrame(restoreScroll)
}, },
@@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple> <Accordion multiple>
<For each={props.messages()}> <For each={messages()}>
{(message) => ( {(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} /> <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)} )}
@@ -431,7 +431,7 @@ export const SettingsGeneral: Component = () => {
<SoundsSection /> <SoundsSection />
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}> {/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => { {(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.()) const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest) const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
@@ -457,7 +457,7 @@ export const SettingsGeneral: Component = () => {
</div> </div>
) )
}} }}
</Show> </Show>*/}
<UpdatesSection /> <UpdatesSection />
+45 -37
View File
@@ -1,21 +1,21 @@
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Popover } from "@opencode-ai/ui/popover"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync" import { useNavigate } from "@solidjs/router"
import { useSDK } from "@/context/sdk" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { normalizeServerUrl, useServer } from "@/context/server" import { createStore, reconcile } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server"
import { ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000 const pollMs = 10_000
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
} }
const listServersByHealth = ( const listServersByHealth = (
list: string[], list: ServerConnection.Any[],
active: string | undefined, active: ServerConnection.Key | undefined,
status: Record<string, ServerHealth | undefined>, status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => { ) => {
if (!list.length) return list if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
@@ -45,16 +45,16 @@ const listServersByHealth = (
} }
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1 if (ServerConnection.key(a) === active) return -1
if (b === active) return 1 if (ServerConnection.key(b) === active) return 1
const diff = rank(status[a]) - rank(status[b]) const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
} }
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => { const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>) const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => { createEffect(() => {
const list = servers() const list = servers()
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
const refresh = async () => { const refresh = async () => {
const results: Record<string, ServerHealth> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
list.map(async (url) => { list.map(async (conn) => {
results[url] = await checkServerHealth(url, fetcher) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
if (dead) return if (dead) return
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
return status return status
} }
const useDefaultServerUrl = ( const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => { ) => {
const [url, setUrl] = createSignal<string | undefined>() const [url, setUrl] = createSignal<string | undefined>()
@@ -117,7 +117,14 @@ const useDefaultServerUrl = (
}) })
}) })
return { url, refresh: () => setTick((value) => value + 1) } return {
key: () => {
const u = url()
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setTick((value) => value + 1),
}
} }
const useMcpToggle = (input: { const useMcpToggle = (input: {
@@ -163,16 +170,16 @@ export function StatusPopover() {
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.url const current = server.current
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (!list.includes(current)) return [current, ...list] if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => item !== current)] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
}) })
const health = useServerHealth(servers, fetcher) const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language }) const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -251,8 +258,9 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}> <For each={sortedServers()}>
{(url) => { {(s) => {
const isBlocked = () => health[url]?.healthy === false const key = ServerConnection.key(s)
const isBlocked = () => health[key]?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -264,19 +272,19 @@ export function StatusPopover() {
aria-disabled={isBlocked()} aria-disabled={isBlocked()}
onClick={() => { onClick={() => {
if (isBlocked()) return if (isBlocked()) return
server.setActive(url) server.setActive(key)
navigate("/") navigate("/")
}} }}
> >
<ServerRow <ServerRow
url={url} conn={s}
status={health[url]} status={health[key]}
dimmed={isBlocked()} dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"
badge={ badge={
<Show when={url === defaultServer.url()}> <Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")} {language.t("common.default")}
</span> </span>
@@ -284,7 +292,7 @@ export function StatusPopover() {
} }
> >
<div class="flex-1" /> <div class="flex-1" />
<Show when={url === server.url}> <Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" /> <Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show> </Show>
</ServerRow> </ServerRow>
+27 -15
View File
@@ -1,14 +1,15 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings" import { monoFontFamily, useSettings } from "@/context/settings"
import { parseKeybind, matchKeybind } from "@/context/command" import type { LocalPTY } from "@/context/terminal"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
@@ -106,8 +107,14 @@ const useTerminalUiBindings = (input: {
input.container.addEventListener("pointerdown", input.handlePointerDown) input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { capture: true }) input.container.addEventListener("click", input.handleLinkClick, {
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true })) capture: true,
})
input.cleanups.push(() =>
input.container.removeEventListener("click", input.handleLinkClick, {
capture: true,
}),
)
input.term.textarea?.addEventListener("focus", handleTextareaFocus) input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur) input.term.textarea?.addEventListener("blur", handleTextareaBlur)
@@ -148,6 +155,7 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
let ws: WebSocket | undefined let ws: WebSocket | undefined
@@ -372,7 +380,13 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer serializeAddon = serializer
t.open(container) t.open(container)
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) useTerminalUiBindings({
container,
term: t,
cleanups,
handlePointerDown,
handleLinkClick,
})
focusTerminal() focusTerminal()
@@ -428,10 +442,8 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory) url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) { url.username = server.current?.http.username ?? ""
url.username = "opencode" url.password = server.current?.http.password ?? ""
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url) const socket = new WebSocket(url)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
ws = socket ws = socket
+27 -21
View File
@@ -1,8 +1,9 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js" import { batch, onCleanup } from "solid-js"
import z from "zod" import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server" import { useServer } from "./server"
@@ -17,20 +18,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => { const eventFetch = (() => {
if (!platform.fetch) return if (!platform.fetch || !server.current) return
try { try {
const url = new URL(server.url) const url = new URL(server.current.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch if (url.protocol === "http:" && !loopback) return platform.fetch
} catch { } catch {
@@ -38,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
} }
})() })()
const eventSdk = createOpencodeClient({ const currentServer = server.current
baseUrl: server.url, if (!currentServer) throw new Error("No server available")
const eventSdk = createSdkForServer({
signal: abort.signal, signal: abort.signal,
fetch: eventFetch, fetch: eventFetch,
headers: eventFetch ? undefined : auth, server: currentServer.http,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: Event
@@ -133,7 +126,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (streamErrorLogged) return if (streamErrorLogged) return
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream error", { console.error("[global-sdk] event stream error", {
url: server.url, url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
@@ -166,7 +159,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (!aborted(error) && !streamErrorLogged) { if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream failed", { console.error("[global-sdk] event stream failed", {
url: server.url, url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
@@ -200,12 +193,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
flush() flush()
}) })
const sdk = createOpencodeClient({ const sdk = createSdkForServer({
baseUrl: server.url, server: server.current.http,
fetch: platform.fetch, fetch: platform.fetch,
throwOnError: true, throwOnError: true,
}) })
return { url: server.url, client: sdk, event: emitter } return {
url: currentServer.http.url,
client: sdk,
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error("Server not available")
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
}, },
}) })
+41 -31
View File
@@ -1,41 +1,41 @@
import { import type {
type Config, Config,
type Path, OpencodeClient,
type Project, Path,
type ProviderAuthResponse, Project,
type ProviderListResponse, ProviderAuthResponse,
type Todo, ProviderListResponse,
createOpencodeClient, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store" import { showToast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "./global-sdk" import { getFilename } from "@opencode-ai/util/path"
import type { InitError } from "../pages/error"
import { import {
createContext, createContext,
createEffect, createEffect,
untrack,
getOwner, getOwner,
useContext, Match,
onCleanup, onCleanup,
onMount, onMount,
type ParentProps, type ParentProps,
Switch, Switch,
Match, untrack,
useContext,
} from "solid-js" } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast" import { createStore, produce, reconcile } from "solid-js/store"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { createRefreshQueue } from "./global-sync/queue" import type { InitError } from "../pages/error"
import { createChildStoreManager } from "./global-sync/child-store" import { useGlobalSDK } from "./global-sdk"
import { trimSessions } from "./global-sync/session-trim"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -77,7 +77,7 @@ function createGlobalSync() {
loadSessionsFallback: 0, loadSessionsFallback: 0,
} }
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>() const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
@@ -151,9 +151,7 @@ function createGlobalSync() {
const sdkFor = (directory: string) => { const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory) const cached = sdkCache.get(directory)
if (cached) return cached if (cached) return cached
const sdk = createOpencodeClient({ const sdk = globalSDK.createClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory, directory,
throwOnError: true, throwOnError: true,
}) })
@@ -193,7 +191,10 @@ function createGlobalSync() {
const [store, setStore] = children.child(directory, { bootstrap: false }) const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory) const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) { if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission }) const next = trimSessions(store.session, {
limit: store.limit,
permission: store.permission,
})
if (next.length !== store.session.length) { if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
} }
@@ -218,10 +219,17 @@ function createGlobalSync() {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID) const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission }) const sessions = trimSessions([...nonArchived, ...childSessions], {
limit,
permission: store.permission,
})
setStore( setStore(
"sessionTotal", "sessionTotal",
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), estimateRootSessionTotal({
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
) )
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit }) sessionMeta.set(directory, { limit })
@@ -331,7 +339,9 @@ function createGlobalSync() {
await bootstrapGlobal({ await bootstrapGlobal({
globalSDK: globalSDK.client, globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"), connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }), connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
setGlobalStore, setGlobalStore,
}) })
@@ -1,21 +1,21 @@
import { import type {
type Config, Config,
type Path, OpencodeClient,
type PermissionRequest, Path,
type Project, PermissionRequest,
type ProviderAuthResponse, Project,
type ProviderListResponse, ProviderAuthResponse,
type QuestionRequest, ProviderListResponse,
type Todo, QuestionRequest,
createOpencodeClient, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { cmp, normalizeProviderList } from "./utils"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -31,7 +31,7 @@ type GlobalStore = {
} }
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
globalSDK: ReturnType<typeof createOpencodeClient> globalSDK: OpencodeClient
connectErrorTitle: string connectErrorTitle: string
connectErrorDescription: string connectErrorDescription: string
requestFailedTitle: string requestFailedTitle: string
@@ -110,7 +110,7 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
sdk: ReturnType<typeof createOpencodeClient> sdk: OpencodeClient
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache vcsCache: VcsCache
+6 -7
View File
@@ -1,9 +1,8 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
type SDKEventMap = { type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
@@ -12,14 +11,11 @@ type SDKEventMap = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { directory: Accessor<string> }) => { init: (props: { directory: Accessor<string> }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const directory = createMemo(props.directory) const directory = createMemo(props.directory)
const client = createMemo(() => const client = createMemo(() =>
createOpencodeClient({ globalSDK.createClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory: directory(), directory: directory(),
throwOnError: true, throwOnError: true,
}), }),
@@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get url() { get url() {
return globalSDK.url return globalSDK.url
}, },
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
} }
}, },
}) })
+120 -80
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -15,92 +15,118 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "") return withProtocol.replace(/\/+$/, "")
} }
export function serverDisplayName(url: string) { export function serverDisplayName(conn?: ServerConnection.Any) {
if (!url) return "" if (!conn) return ""
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "") if (conn.displayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
} }
function projectsKey(url: string) { function projectsKey(key: ServerConnection.Key) {
if (!url) return "" if (!key) return ""
const host = url.replace(/^https?:\/\//, "").split(":")[0] if (key === "sidecar") return "local"
const host = key.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local" if (host === "localhost" || host === "127.0.0.1") return "local"
return url return key
}
export namespace ServerConnection {
type Base = { displayName?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
} }
export const { use: useServer, provider: ServerProvider } = createSimpleContext({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { defaultUrl: string; isSidecar?: boolean }) => { init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]), Persist.global("server", ["server.v3"]),
createStore({ createStore({
list: [] as string[], list: [] as string[],
currentSidecarUrl: "",
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>, lastProject: {} as Record<string, string>,
}), }),
) )
const allServers = createMemo(
(): Array<ServerConnection.Any> => [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
],
)
const [state, setState] = createStore({ const [state, setState] = createStore({
active: "", active: props.defaultServer,
healthy: undefined as boolean | undefined, healthy: undefined as boolean | undefined,
}) })
const healthy = () => state.healthy const healthy = () => state.healthy
const defaultUrl = () => normalizeServerUrl(props.defaultUrl) function startHealthPolling(conn: ServerConnection.Any) {
function reconcileStartup() {
const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
}
function updateServerList(url: string, remove = false) {
if (remove) {
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
batch(() => {
setStore("list", list)
setState("active", next)
})
return
}
batch(() => {
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setState("active", url)
})
}
function startHealthPolling(url: string) {
let alive = true let alive = true
let busy = false let busy = false
const run = () => { const run = () => {
if (busy) return if (busy) return
busy = true busy = true
void check(url) void check(conn)
.then((next) => { .then((next) => {
if (!alive) return if (!alive) return
setState("healthy", next) setState("healthy", next)
@@ -118,59 +144,73 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
} }
function setActive(input: string) { function setActive(input: ServerConnection.Key) {
const url = normalizeServerUrl(input) if (state.active !== input) setState("active", input)
if (!url) return
setState("active", url)
} }
function add(input: string) { function add(input: string) {
const url = normalizeServerUrl(input) const url = normalizeServerUrl(input)
if (!url) return if (!url) return
updateServerList(url) return batch(() => {
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
return conn
})
} }
function remove(input: string) { function remove(key: ServerConnection.Key) {
const url = normalizeServerUrl(input) const list = store.list.filter((x) => x !== key)
if (!url) return batch(() => {
updateServerList(url, true) setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
}
})
} }
createEffect(() => {
if (!ready()) return
if (state.active) return
reconcileStartup()
})
const isReady = createMemo(() => ready() && !!state.active) const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
createEffect(() => { createEffect(() => {
const url = state.active const current_ = current()
if (!url) return if (!current_) return
setState("healthy", undefined) setState("healthy", undefined)
onCleanup(startHealthPolling(url)) onCleanup(startHealthPolling(current_))
}) })
const origin = createMemo(() => projectsKey(state.active)) const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? []) const projectsList = createMemo(() => store.projects[origin()] ?? [])
const isLocal = createMemo(() => origin() === "local") const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" && c.variant === "base"
})
return { return {
ready: isReady, ready: isReady,
healthy, healthy,
isLocal, isLocal,
get url() { get key() {
return state.active return state.active
}, },
get name() { get name() {
return serverDisplayName(state.active) return serverDisplayName(current())
}, },
get list() { get list() {
return store.list return allServers()
},
get current() {
return current()
}, },
setActive, setActive,
add, add,
+15 -2
View File
@@ -1,11 +1,14 @@
// @refresh reload // @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click" import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -107,12 +110,22 @@ const platform: Platform = {
setDefaultServerUrl: writeDefaultServerUrl, setDefaultServerUrl: writeDefaultServerUrl,
} }
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders>
<AppInterface /> <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),
+2 -1
View File
@@ -1,4 +1,5 @@
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click" export { handleNotificationClick } from "./utils/notification-click"
+13 -509
View File
@@ -1,26 +1,20 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Dialog } from "@opencode-ai/ui/dialog"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { createAutoScroll } from "@opencode-ai/ui/hooks" import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync" import { useTerminal } from "@/context/terminal"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode" import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array" import { findLast } from "@opencode-ai/util/array"
@@ -34,16 +28,14 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" import { SessionHeader, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf" import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers" import { createOpenReviewFile } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy" import { createScrollSpy } from "@/pages/session/scroll-spy"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { import {
SessionReviewTab, SessionReviewTab,
StickyAddButton, StickyAddButton,
@@ -51,7 +43,6 @@ import {
type SessionReviewTabProps, type SessionReviewTabProps,
} from "@/pages/session/review-tab" } from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionPromptDock } from "@/pages/session/session-prompt-dock" import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
@@ -59,42 +50,13 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const HANDOFF_MAX = 40
const handoff = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > HANDOFF_MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
touch(handoff.session, key, { ...prev, ...patch })
}
export default function Page() { export default function Page() {
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
const file = useFile() const file = useFile()
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync()
const terminal = useTerminal() const terminal = useTerminal()
const dialog = useDialog() const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
const params = useParams() const params = useParams()
@@ -104,53 +66,21 @@ export default function Page() {
const comments = useComments() const comments = useComments()
const permission = usePermission() const permission = usePermission()
const permRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const questionRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const blocked = createMemo(() => !!permRequest() || !!questionRequest())
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
responding: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
scrollGesture: 0, scrollGesture: 0,
autoCreated: false,
scroll: { scroll: {
overflow: false, overflow: false,
bottom: true, bottom: true,
}, },
}) })
createEffect( const blocked = createMemo(() => {
on( const sessionID = params.id
() => permRequest()?.id, if (!sessionID) return false
() => setUi("responding", false), return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
{ defer: true }, })
),
)
const decide = (response: "once" | "always" | "reject") => {
const perm = permRequest()
if (!perm) return
if (ui.responding) return
setUi("responding", true)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setUi("responding", false))
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "") const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
@@ -323,206 +253,6 @@ export default function Page() {
return sync.session.history.loading(id) return sync.session.history.loading(id)
}) })
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!params.id) return
setTitle({ editing: true, draft: info()?.title ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const sessionID = params.id
if (!sessionID) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (info()?.title ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function deleteSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
function DialogDeleteSession(props: { sessionID: string }) {
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: title() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo( const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[], () => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -555,8 +285,6 @@ export default function Page() {
) )
const [store, setStore] = createStore({ const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
turnStart: 0, turnStart: 0,
mobileTab: "session" as "session" | "changes", mobileTab: "session" as "session" | "changes",
@@ -679,43 +407,6 @@ export default function Page() {
void sync.session.todo(id) void sync.session.todo(id)
}) })
createEffect(() => {
if (!view().terminal.opened()) {
setUi("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
terminal.new()
setUi("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (view().terminal.opened()) {
view().terminal.toggle()
}
}
},
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !view().terminal.opened()) return
// Immediately remove focus
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
},
),
)
createEffect( createEffect(
on( on(
() => visibleUserMessages().at(-1)?.id, () => visibleUserMessages().at(-1)?.id,
@@ -729,11 +420,6 @@ export default function Page() {
) )
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const todos = createMemo(() => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
createEffect( createEffect(
on( on(
@@ -741,7 +427,6 @@ export default function Page() {
() => { () => {
setStore("messageId", undefined) setStore("messageId", undefined)
setStore("changes", "session") setStore("changes", "session")
setUi("autoCreated", false)
}, },
{ defer: true }, { defer: true },
), ),
@@ -827,53 +512,6 @@ export default function Page() {
} }
} }
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
}
}
const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeTerminalDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex)
}
}
}
const handleTerminalDragEnd = () => {
setStore("activeTerminalDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
focusTerminalById(activeId)
}, 0)
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() => const openedTabs = createMemo(() =>
tabs() tabs()
@@ -1485,58 +1123,6 @@ export default function Page() {
document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
}) })
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
createEffect(() => {
if (!terminal.ready()) return
language.locale()
touch(
handoff.terminal,
params.dir!,
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
onCleanup(() => { onCleanup(() => {
cancelTurnBackfill() cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener("keydown", handleKeyDown)
@@ -1555,7 +1141,6 @@ export default function Page() {
reviewCount={reviewCount()} reviewCount={reviewCount()}
onSession={() => setStore("mobileTab", "session")} onSession={() => setStore("mobileTab", "session")}
onChanges={() => setStore("mobileTab", "changes")} onChanges={() => setStore("mobileTab", "changes")}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
/> />
{/* Session panel */} {/* Session panel */}
@@ -1595,27 +1180,7 @@ export default function Page() {
isDesktop={isDesktop()} isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll} onScrollSpyScroll={scrollSpy.onScroll}
onAutoScrollInteraction={autoScroll.handleInteraction} onAutoScrollInteraction={autoScroll.handleInteraction}
showHeader={!!(info()?.title || info()?.parentID)}
centered={centered()} centered={centered()}
title={info()?.title}
parentID={info()?.parentID}
openTitleEditor={openTitleEditor}
closeTitleEditor={closeTitleEditor}
saveTitleEditor={saveTitleEditor}
titleRef={(el) => {
titleRef = el
}}
titleState={title}
onTitleDraft={(value) => setTitle("draft", value)}
onTitleMenuOpen={(open) => setTitle("menuOpen", open)}
onTitlePendingRename={(value) => setTitle("pendingRename", value)}
onNavigateParent={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
sessionID={params.id!}
onArchiveSession={(sessionID) => void archiveSession(sessionID)}
onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
setContentRef={(el) => { setContentRef={(el) => {
content = el content = el
autoScroll.contentRef(el) autoScroll.contentRef(el)
@@ -1670,15 +1235,6 @@ export default function Page() {
<SessionPromptDock <SessionPromptDock
centered={centered()} centered={centered()}
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
responding={ui.responding}
onDecide={decide}
inputRef={(el) => { inputRef={(el) => {
inputRef = el inputRef = el
}} }}
@@ -1688,7 +1244,9 @@ export default function Page() {
comments.clear() comments.clear()
resumeScroll() resumeScroll()
}} }}
setPromptDockRef={(el) => (promptDock = el)} setPromptDockRef={(el) => {
promptDock = el
}}
/> />
<Show when={desktopReviewOpen()}> <Show when={desktopReviewOpen()}>
@@ -1702,64 +1260,10 @@ export default function Page() {
</Show> </Show>
</div> </div>
<SessionSidePanel <SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
open={desktopSidePanelOpen()}
reviewOpen={desktopReviewOpen()}
language={language}
layout={layout}
command={command}
dialog={dialog}
file={file}
comments={comments}
hasReview={hasReview()}
reviewCount={reviewCount()}
reviewTab={reviewTab()}
contextOpen={contextOpen}
openedTabs={openedTabs}
activeTab={activeTab}
activeFileTab={activeFileTab}
tabs={tabs}
openTab={openTab}
showAllFiles={showAllFiles}
reviewPanel={reviewPanel}
vm={{
messages,
visibleUserMessages,
view,
info,
}}
handoffFiles={() => handoff.session.get(sessionKey())?.files}
codeComponent={codeComponent}
addCommentToContext={addCommentToContext}
activeDraggable={() => store.activeDraggable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
fileTreeTab={fileTreeTab}
setFileTreeTabValue={setFileTreeTabValue}
diffsReady={diffsReady()}
diffFiles={diffFiles()}
kinds={kinds()}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
/>
</div> </div>
<TerminalPanel <TerminalPanel />
open={isDesktop() && view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}
terminal={terminal}
language={language}
command={command}
handoff={() => handoff.terminal.get(params.dir!) ?? []}
activeTerminalDraggable={() => store.activeTerminalDraggable}
handleTerminalDragStart={handleTerminalDragStart}
handleTerminalDragOver={handleTerminalDragOver}
handleTerminalDragEnd={handleTerminalDragEnd}
onCloseTab={() => setUi("autoCreated", false)}
/>
</div> </div>
) )
} }
+81 -46
View File
@@ -1,6 +1,8 @@
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { sampledChecksum } from "@opencode-ai/util/encode" import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
@@ -8,9 +10,11 @@ import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useFile, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff"
const formatCommentLabel = (range: SelectedLineRange) => { const formatCommentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end) const start = Math.min(range.start, range.end)
@@ -19,34 +23,29 @@ const formatCommentLabel = (range: SelectedLineRange) => {
return `lines ${start}-${end}` return `lines ${start}-${end}`
} }
export function FileTabContent(props: { export function FileTabContent(props: { tab: string }) {
tab: string const params = useParams()
activeTab: () => string const layout = useLayout()
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> const file = useFile()
view: () => ReturnType<ReturnType<typeof useLayout>["view"]> const comments = useComments()
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined const language = useLanguage()
file: ReturnType<typeof useFile> const prompt = usePrompt()
comments: ReturnType<typeof useComments> const codeComponent = useCodeComponent()
language: ReturnType<typeof useLanguage>
codeComponent: NonNullable<ValidComponent> const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
addCommentToContext: (input: { const tabs = createMemo(() => layout.tabs(sessionKey))
file: string const view = createMemo(() => layout.view(sessionKey))
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => void
}) {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = [] let codeScroll: HTMLElement[] = []
const path = createMemo(() => props.file.pathFromTab(props.tab)) const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => { const state = createMemo(() => {
const p = path() const p = path()
if (!p) return if (!p) return
return props.file.get(p) return file.get(p)
}) })
const contents = createMemo(() => state()?.content?.content ?? "") const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents())) const cacheKey = createMemo(() => sampledChecksum(contents()))
@@ -82,7 +81,7 @@ export function FileTabContent(props: {
svgToast.shown = true svgToast.shown = true
showToast({ showToast({
variant: "error", variant: "error",
title: props.language.t("toast.file.loadFailed.title"), title: language.t("toast.file.loadFailed.title"),
}) })
}) })
const svgPreviewUrl = createMemo(() => { const svgPreviewUrl = createMemo(() => {
@@ -100,16 +99,57 @@ export function FileTabContent(props: {
const selectedLines = createMemo(() => { const selectedLines = createMemo(() => {
const p = path() const p = path()
if (!p) return null if (!p) return null
if (props.file.ready()) return props.file.selectedLines(p) ?? null if (file.ready()) return file.selectedLines(p) ?? null
return props.handoffFiles()?.[p] ?? null return getSessionHandoff(sessionKey())?.files[p] ?? null
}) })
const selectionPreview = (source: string, selection: FileSelection) => {
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = source.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const saved = comments.add({
file: input.file,
selection: input.selection,
comment: input.comment,
})
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
let wrap: HTMLDivElement | undefined let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => { const fileComments = createMemo(() => {
const p = path() const p = path()
if (!p) return [] if (!p) return []
return props.comments.list(p) return comments.list(p)
}) })
const commentLayout = createMemo(() => { const commentLayout = createMemo(() => {
@@ -228,19 +268,19 @@ export function FileTabContent(props: {
}) })
createEffect(() => { createEffect(() => {
const focus = props.comments.focus() const focus = comments.focus()
const p = path() const p = path()
if (!focus || !p) return if (!focus || !p) return
if (focus.file !== p) return if (focus.file !== p) return
if (props.activeTab() !== props.tab) return if (tabs().active() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id) const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return if (!target) return
setNote("openedComment", target.id) setNote("openedComment", target.id)
setNote("commenting", null) setNote("commenting", null)
props.file.setSelectedLines(p, target.selection) file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => props.comments.clearFocus()) requestAnimationFrame(() => comments.clearFocus())
}) })
const getCodeScroll = () => { const getCodeScroll = () => {
@@ -269,7 +309,7 @@ export function FileTabContent(props: {
pending = undefined pending = undefined
if (!out) return if (!out) return
props.view().setScroll(props.tab, out) view().setScroll(props.tab, out)
}) })
} }
@@ -305,7 +345,7 @@ export function FileTabContent(props: {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = props.view()?.scroll(props.tab) const s = view().scroll(props.tab)
if (!s) return if (!s) return
syncCodeScroll() syncCodeScroll()
@@ -343,7 +383,7 @@ export function FileTabContent(props: {
createEffect( createEffect(
on( on(
() => props.file.ready(), () => file.ready(),
(ready) => { (ready) => {
if (!ready) return if (!ready) return
requestAnimationFrame(restoreScroll) requestAnimationFrame(restoreScroll)
@@ -354,7 +394,7 @@ export function FileTabContent(props: {
createEffect( createEffect(
on( on(
() => props.tabs().active() === props.tab, () => tabs().active() === props.tab,
(active) => { (active) => {
if (!active) return if (!active) return
if (!state()?.loaded) return if (!state()?.loaded) return
@@ -381,7 +421,7 @@ export function FileTabContent(props: {
class={`relative overflow-hidden ${wrapperClass}`} class={`relative overflow-hidden ${wrapperClass}`}
> >
<Dynamic <Dynamic
component={props.codeComponent} component={codeComponent}
file={{ file={{
name: path() ?? "", name: path() ?? "",
contents: source, contents: source,
@@ -397,7 +437,7 @@ export function FileTabContent(props: {
onLineSelected={(range: SelectedLineRange | null) => { onLineSelected={(range: SelectedLineRange | null) => {
const p = path() const p = path()
if (!p) return if (!p) return
props.file.setSelectedLines(p, range) file.setSelectedLines(p, range)
if (!range) setNote("commenting", null) if (!range) setNote("commenting", null)
}} }}
onLineSelectionEnd={(range: SelectedLineRange | null) => { onLineSelectionEnd={(range: SelectedLineRange | null) => {
@@ -423,14 +463,14 @@ export function FileTabContent(props: {
onMouseEnter={() => { onMouseEnter={() => {
const p = path() const p = path()
if (!p) return if (!p) return
props.file.setSelectedLines(p, comment.selection) file.setSelectedLines(p, comment.selection)
}} }}
onClick={() => { onClick={() => {
const p = path() const p = path()
if (!p) return if (!p) return
setNote("commenting", null) setNote("commenting", null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id)) setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
props.file.setSelectedLines(p, comment.selection) file.setSelectedLines(p, comment.selection)
}} }}
/> />
)} )}
@@ -447,12 +487,7 @@ export function FileTabContent(props: {
onSubmit={(value) => { onSubmit={(value) => {
const p = path() const p = path()
if (!p) return if (!p) return
props.addCommentToContext({ addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
file: p,
selection: range(),
comment: value,
origin: "file",
})
setNote("commenting", null) setNote("commenting", null)
}} }}
onPopoverFocusOut={(e: FocusEvent) => { onPopoverFocusOut={(e: FocusEvent) => {
@@ -509,13 +544,13 @@ export function FileTabContent(props: {
<Mark class="w-14 opacity-10" /> <Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md"> <div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div> <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div> <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div> </div>
</div> </div>
</Match> </Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match> <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}> <Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div> <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match> </Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match> <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch> </Switch>
+36
View File
@@ -0,0 +1,36 @@
import type { SelectedLineRange } from "@/context/file"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const MAX = 40
const store = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = store.session.get(key) ?? { prompt: "", files: {} }
touch(store.session, key, { ...prev, ...patch })
}
export const getSessionHandoff = (key: string) => store.session.get(key)
export const setTerminalHandoff = (key: string, value: string[]) => {
touch(store.terminal, key, value)
}
export const getTerminalHandoff = (key: string) => store.terminal.get(key)
@@ -1,13 +1,21 @@
import { For, onCleanup, onMount, Show, type JSX } from "solid-js" import { For, createEffect, createMemo, on, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined const current = target instanceof Element ? target : undefined
@@ -53,29 +61,7 @@ export function MessageTimeline(props: {
isDesktop: boolean isDesktop: boolean
onScrollSpyScroll: () => void onScrollSpyScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void onAutoScrollInteraction: (event: MouseEvent) => void
showHeader: boolean
centered: boolean centered: boolean
title?: string
parentID?: string
openTitleEditor: () => void
closeTitleEditor: () => void
saveTitleEditor: () => void | Promise<void>
titleRef: (el: HTMLInputElement) => void
titleState: {
draft: string
editing: boolean
saving: boolean
menuOpen: boolean
pendingRename: boolean
}
onTitleDraft: (value: string) => void
onTitleMenuOpen: (open: boolean) => void
onTitlePendingRename: (value: boolean) => void
onNavigateParent: () => void
sessionID: string
onArchiveSession: (sessionID: string) => void
onDeleteSession: (sessionID: string) => void
t: (key: string, vars?: Record<string, string | number | boolean>) => string
setContentRef: (el: HTMLDivElement) => void setContentRef: (el: HTMLDivElement) => void
turnStart: number turnStart: number
onRenderEarlier: () => void onRenderEarlier: () => void
@@ -91,6 +77,230 @@ export function MessageTimeline(props: {
}) { }) {
let touchGesture: number | undefined let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return ( return (
<Show <Show
when={!props.mobileChanges} when={!props.mobileChanges}
@@ -157,9 +367,9 @@ export function MessageTimeline(props: {
}} }}
onClick={props.onAutoScrollInteraction} onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }} style={{ "--session-title-height": showHeader() ? "40px" : "0px" }}
> >
<Show when={props.showHeader}> <Show when={showHeader()}>
<div <div
classList={{ classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
@@ -171,92 +381,96 @@ export function MessageTimeline(props: {
> >
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={props.parentID}> <Show when={parentID()}>
<IconButton <IconButton
tabIndex={-1} tabIndex={-1}
icon="arrow-left" icon="arrow-left"
variant="ghost" variant="ghost"
onClick={props.onNavigateParent} onClick={navigateParent}
aria-label={props.t("common.goBack")} aria-label={language.t("common.goBack")}
/> />
</Show> </Show>
<Show when={props.title || props.titleState.editing}> <Show when={titleValue() || title.editing}>
<Show <Show
when={props.titleState.editing} when={title.editing}
fallback={ fallback={
<h1 <h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={props.openTitleEditor} onDblClick={openTitleEditor}
> >
{props.title} {titleValue()}
</h1> </h1>
} }
> >
<InlineInput <InlineInput
ref={props.titleRef} ref={(el) => {
value={props.titleState.draft} titleRef = el
disabled={props.titleState.saving} }}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => props.onTitleDraft(event.currentTarget.value)} onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation() event.stopPropagation()
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
void props.saveTitleEditor() void saveTitleEditor()
return return
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
props.closeTitleEditor() closeTitleEditor()
} }
}} }}
onBlur={props.closeTitleEditor} onBlur={closeTitleEditor}
/> />
</Show> </Show>
</Show> </Show>
</div> </div>
<Show when={props.sessionID}> <Show when={sessionID()}>
{(id) => ( {(id) => (
<div class="shrink-0 flex items-center gap-3"> <div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" /> <SessionContextUsage placement="bottom" />
<DropdownMenu <DropdownMenu
gutter={4} gutter={4}
placement="bottom-end" placement="bottom-end"
open={props.titleState.menuOpen} open={title.menuOpen}
onOpenChange={props.onTitleMenuOpen} onOpenChange={(open) => setTitle("menuOpen", open)}
> >
<DropdownMenu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
icon="dot-grid" icon="dot-grid"
variant="ghost" variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active" class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={props.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content <DropdownMenu.Content
style={{ "min-width": "104px" }} style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
if (!props.titleState.pendingRename) return if (!title.pendingRename) return
event.preventDefault() event.preventDefault()
props.onTitlePendingRename(false) setTitle("pendingRename", false)
props.openTitleEditor() openTitleEditor()
}} }}
> >
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
props.onTitlePendingRename(true) setTitle("pendingRename", true)
props.onTitleMenuOpen(false) setTitle("menuOpen", false)
}} }}
> >
<DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}> <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}> <DropdownMenu.Item
<DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel> onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
@@ -282,7 +496,7 @@ export function MessageTimeline(props: {
<Show when={props.turnStart > 0}> <Show when={props.turnStart > 0}>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}> <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
{props.t("session.messages.renderEarlier")} {language.t("session.messages.renderEarlier")}
</Button> </Button>
</div> </div>
</Show> </Show>
@@ -296,8 +510,8 @@ export function MessageTimeline(props: {
onClick={props.onLoadEarlier} onClick={props.onLoadEarlier}
> >
{props.historyLoading {props.historyLoading
? props.t("session.messages.loadingEarlier") ? language.t("session.messages.loadingEarlier")
: props.t("session.messages.loadEarlier")} : language.t("session.messages.loadEarlier")}
</Button> </Button>
</div> </div>
</Show> </Show>
@@ -321,7 +535,7 @@ export function MessageTimeline(props: {
}} }}
> >
<SessionTurn <SessionTurn
sessionID={props.sessionID} sessionID={sessionID() ?? ""}
messageID={message.id} messageID={message.id}
lastUserMessageID={props.lastUserMessageID} lastUserMessageID={props.lastUserMessageID}
classes={{ classes={{
@@ -1,5 +1,6 @@
import { Show } from "solid-js" import { Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useLanguage } from "@/context/language"
export function SessionMobileTabs(props: { export function SessionMobileTabs(props: {
open: boolean open: boolean
@@ -8,8 +9,9 @@ export function SessionMobileTabs(props: {
reviewCount: number reviewCount: number
onSession: () => void onSession: () => void
onChanges: () => void onChanges: () => void
t: (key: string, vars?: Record<string, string | number | boolean>) => string
}) { }) {
const language = useLanguage()
return ( return (
<Show when={props.open}> <Show when={props.open}>
<Tabs value={props.mobileTab} class="h-auto"> <Tabs value={props.mobileTab} class="h-auto">
@@ -20,7 +22,7 @@ export function SessionMobileTabs(props: {
classes={{ button: "w-full" }} classes={{ button: "w-full" }}
onClick={props.onSession} onClick={props.onSession}
> >
{props.t("session.tab.session")} {language.t("session.tab.session")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger <Tabs.Trigger
value="changes" value="changes"
@@ -29,8 +31,8 @@ export function SessionMobileTabs(props: {
onClick={props.onChanges} onClick={props.onChanges}
> >
{props.hasReview {props.hasReview
? props.t("session.review.filesChanged", { count: props.reviewCount }) ? language.t("session.review.filesChanged", { count: props.reviewCount })
: props.t("session.review.change.other")} : language.t("session.review.change.other")}
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
@@ -1,35 +1,105 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock" import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock" import { SessionTodoDock } from "@/components/session-todo-dock"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
export function SessionPromptDock(props: { export function SessionPromptDock(props: {
centered: boolean centered: boolean
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
responding: boolean
onDecide: (response: "once" | "always" | "reject") => void
inputRef: (el: HTMLDivElement) => void inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string newSessionWorktree: string
onNewSessionWorktreeReset: () => void onNewSessionWorktreeReset: () => void
onSubmit: () => void onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void setPromptDockRef: (el: HTMLDivElement) => void
}) { }) {
const done = createMemo( const params = useParams()
() => const sdk = useSDK()
props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"), const sync = useSync()
const globalSync = useGlobalSync()
const prompt = usePrompt()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
const questionRequest = createMemo((): QuestionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const [responding, setResponding] = createSignal(false)
createEffect(
on(
() => permissionRequest()?.id,
() => setResponding(false),
{ defer: true },
),
) )
const [dock, setDock] = createSignal(props.todos.length > 0) const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (responding()) return
setResponding(true)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setResponding(false))
}
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const [dock, setDock] = createSignal(todos().length > 0)
const [closing, setClosing] = createSignal(false) const [closing, setClosing] = createSignal(false)
const [opening, setOpening] = createSignal(false) const [opening, setOpening] = createSignal(false)
let timer: number | undefined let timer: number | undefined
@@ -46,7 +116,7 @@ export function SessionPromptDock(props: {
createEffect( createEffect(
on( on(
() => [props.todos.length, done()] as const, () => [todos().length, done()] as const,
([count, complete], prev) => { ([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf) if (raf) cancelAnimationFrame(raf)
raf = undefined raf = undefined
@@ -113,7 +183,7 @@ export function SessionPromptDock(props: {
"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,
}} }}
> >
<Show when={props.questionRequest()} keyed> <Show when={questionRequest()} keyed>
{(req) => { {(req) => {
return ( return (
<div> <div>
@@ -123,11 +193,11 @@ export function SessionPromptDock(props: {
}} }}
</Show> </Show>
<Show when={props.permissionRequest()} keyed> <Show when={permissionRequest()} keyed>
{(perm) => { {(perm) => {
const toolDescription = () => { const toolDescription = () => {
const key = `settings.permissions.tool.${perm.permission}.description` const key = `settings.permissions.tool.${perm.permission}.description`
const value = props.t(key) const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return "" if (value === key) return ""
return value return value
} }
@@ -141,36 +211,26 @@ export function SessionPromptDock(props: {
<span data-slot="permission-icon"> <span data-slot="permission-icon">
<Icon name="warning" size="normal" /> <Icon name="warning" size="normal" />
</span> </span>
<div data-slot="permission-header-title">{props.t("notification.permission.title")}</div> <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
</div> </div>
} }
footer={ footer={
<> <>
<div /> <div />
<div data-slot="permission-footer-actions"> <div data-slot="permission-footer-actions">
<Button <Button variant="ghost" size="normal" onClick={() => decide("reject")} disabled={responding()}>
variant="ghost" {language.t("ui.permission.deny")}
size="normal"
onClick={() => props.onDecide("reject")}
disabled={props.responding}
>
{props.t("ui.permission.deny")}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="normal" size="normal"
onClick={() => props.onDecide("always")} onClick={() => decide("always")}
disabled={props.responding} disabled={responding()}
> >
{props.t("ui.permission.allowAlways")} {language.t("ui.permission.allowAlways")}
</Button> </Button>
<Button <Button variant="primary" size="normal" onClick={() => decide("once")} disabled={responding()}>
variant="primary" {language.t("ui.permission.allowOnce")}
size="normal"
onClick={() => props.onDecide("once")}
disabled={props.responding}
>
{props.t("ui.permission.allowOnce")}
</Button> </Button>
</div> </div>
</> </>
@@ -199,12 +259,12 @@ export function SessionPromptDock(props: {
}} }}
</Show> </Show>
<Show when={!props.blocked}> <Show when={!blocked()}>
<Show <Show
when={props.promptReady} when={prompt.ready()}
fallback={ fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{props.handoffPrompt || props.t("prompt.loading")} {handoffPrompt() || language.t("prompt.loading")}
</div> </div>
} }
> >
@@ -219,10 +279,10 @@ export function SessionPromptDock(props: {
}} }}
> >
<SessionTodoDock <SessionTodoDock
todos={props.todos} todos={todos()}
title={props.t("session.todo.title")} title={language.t("session.todo.title")}
collapseLabel={props.t("session.todo.collapse")} collapseLabel={language.t("session.todo.collapse")}
expandLabel={props.t("session.todo.expand")} expandLabel={language.t("session.todo.expand")}
/> />
</div> </div>
</Show> </Show>
@@ -1,156 +1,269 @@
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree" import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectFile } from "@/components/dialog-select-file"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { FileTabContent } from "@/pages/session/file-tabs"
import { StickyAddButton } from "@/pages/session/review-tab"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
type SessionSidePanelViewModel = { import { getTabReorderIndex } from "@/pages/session/helpers"
messages: () => Message[] import { StickyAddButton } from "@/pages/session/review-tab"
visibleUserMessages: () => UserMessage[] import { setSessionHandoff } from "@/pages/session/handoff"
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
open: boolean
reviewOpen: boolean
language: ReturnType<typeof useLanguage>
layout: ReturnType<typeof useLayout>
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
comments: ReturnType<typeof useComments>
hasReview: boolean
reviewCount: number
reviewTab: boolean
contextOpen: () => boolean
openedTabs: () => string[]
activeTab: () => string
activeFileTab: () => string | undefined
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
openTab: (value: string) => void
showAllFiles: () => void
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
vm: SessionSidePanelViewModel
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
codeComponent: NonNullable<ValidComponent>
addCommentToContext: (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => void
activeDraggable: () => string | undefined
onDragStart: (event: unknown) => void
onDragEnd: () => void
onDragOver: (event: DragEvent) => void
fileTreeTab: () => "changes" | "all"
setFileTreeTabValue: (value: string) => void
diffsReady: boolean
diffFiles: string[]
kinds: Map<string, "add" | "del" | "mix">
activeDiff?: string activeDiff?: string
focusReviewDiff: (path: string) => void focusReviewDiff: (path: string) => void
}) { }) {
const openedTabs = createMemo(() => props.openedTabs()) const params = useParams()
const layout = useLayout()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = (value: string) => {
const next = normalizeTab(value)
tabs().open(next)
const path = file.pathFromTab(next)
if (!path) return
file.load(path)
openReviewPanel()
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
.all()
.filter((tab) => tab !== "context" && tab !== "review"),
)
const activeTab = createMemo(() => {
const active = tabs().active()
if (active === "context") return "context"
if (active === "review" && reviewTab()) return "review"
if (active && file.pathFromTab(active)) return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (reviewTab() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
layout.fileTree.setTab("all")
}
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
}
const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
return ( return (
<Show when={props.open}> <Show when={open()}>
<aside <aside
id="review-panel" id="review-panel"
aria-label={props.language.t("session.panel.reviewAndFiles")} aria-label={language.t("session.panel.reviewAndFiles")}
class="relative min-w-0 h-full border-l border-border-weak-base flex" class="relative min-w-0 h-full border-l border-border-weak-base flex"
classList={{ classList={{
"flex-1": props.reviewOpen, "flex-1": reviewOpen(),
"shrink-0": !props.reviewOpen, "shrink-0": !reviewOpen(),
}} }}
style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }} style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
> >
<Show when={props.reviewOpen}> <Show when={reviewOpen()}>
<div class="flex-1 min-w-0 h-full"> <div class="flex-1 min-w-0 h-full">
<Show <Show
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"} when={layout.fileTree.opened() && fileTreeTab() === "changes"}
fallback={ fallback={
<DragDropProvider <DragDropProvider
onDragStart={props.onDragStart} onDragStart={handleDragStart}
onDragEnd={props.onDragEnd} onDragEnd={handleDragEnd}
onDragOver={props.onDragOver} onDragOver={handleDragOver}
collisionDetector={closestCenter} collisionDetector={closestCenter}
> >
<DragDropSensors /> <DragDropSensors />
<ConstrainDragYAxis /> <ConstrainDragYAxis />
<Tabs value={props.activeTab()} onChange={props.openTab}> <Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex"> <div class="sticky top-0 shrink-0 flex">
<Tabs.List <Tabs.List
ref={(el: HTMLDivElement) => { ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen }) const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop) onCleanup(stop)
}} }}
> >
<Show when={props.reviewTab}> <Show when={reviewTab()}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}> <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div>{props.language.t("session.tab.review")}</div> <div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview}> <Show when={hasReview()}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{props.reviewCount} {reviewCount()}
</div> </div>
</Show> </Show>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> </Show>
<Show when={props.contextOpen()}> <Show when={contextOpen()}>
<Tabs.Trigger <Tabs.Trigger
value="context" value="context"
closeButton={ closeButton={
<Tooltip value={props.language.t("common.closeTab")} placement="bottom"> <Tooltip value={language.t("common.closeTab")} placement="bottom">
<IconButton <IconButton
icon="close-small" icon="close-small"
variant="ghost" variant="ghost"
class="h-5 w-5" class="h-5 w-5"
onClick={() => props.tabs().close("context")} onClick={() => tabs().close("context")}
aria-label={props.language.t("common.closeTab")} aria-label={language.t("common.closeTab")}
/> />
</Tooltip> </Tooltip>
} }
hideCloseButton hideCloseButton
onMiddleClick={() => props.tabs().close("context")} onMiddleClick={() => tabs().close("context")}
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" /> <SessionContextUsage variant="indicator" />
<div>{props.language.t("session.tab.context")}</div> <div>{language.t("session.tab.context")}</div>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> </Show>
<SortableProvider ids={openedTabs()}> <SortableProvider ids={openedTabs()}>
<For each={openedTabs()}> <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
</For>
</SortableProvider> </SortableProvider>
<StickyAddButton> <StickyAddButton>
<TooltipKeybind <TooltipKeybind
title={props.language.t("command.file.open")} title={language.t("command.file.open")}
keybind={props.command.keybind("file.open")} keybind={command.keybind("file.open")}
class="flex items-center" class="flex items-center"
> >
<IconButton <IconButton
@@ -158,72 +271,52 @@ export function SessionSidePanel(props: {
variant="ghost" variant="ghost"
iconSize="large" iconSize="large"
onClick={() => onClick={() =>
props.dialog.show(() => ( dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
<DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
))
} }
aria-label={props.language.t("command.file.open")} aria-label={language.t("command.file.open")}
/> />
</TooltipKeybind> </TooltipKeybind>
</StickyAddButton> </StickyAddButton>
</Tabs.List> </Tabs.List>
</div> </div>
<Show when={props.reviewTab}> <Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show> <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "empty"}> <Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" /> <Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56"> <div class="text-14-regular text-text-weak max-w-56">
{props.language.t("session.files.selectToOpen")} {language.t("session.files.selectToOpen")}
</div> </div>
</div> </div>
</div> </div>
</Show> </Show>
</Tabs.Content> </Tabs.Content>
<Show when={props.contextOpen()}> <Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={props.activeTab() === "context"}> <Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab <SessionContextTab />
messages={props.vm.messages}
visibleUserMessages={props.vm.visibleUserMessages}
view={props.vm.view}
info={props.vm.info}
/>
</div> </div>
</Show> </Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<Show when={props.activeFileTab()} keyed> <Show when={activeFileTab()} keyed>
{(tab) => ( {(tab) => <FileTabContent tab={tab} />}
<FileTabContent
tab={tab}
activeTab={props.activeTab}
tabs={props.tabs}
view={props.vm.view}
handoffFiles={props.handoffFiles}
file={props.file}
comments={props.comments}
language={props.language}
codeComponent={props.codeComponent}
addCommentToContext={props.addCommentToContext}
/>
)}
</Show> </Show>
</Tabs> </Tabs>
<DragOverlay> <DragOverlay>
<Show when={props.activeDraggable()}> <Show when={store.activeDraggable} keyed>
{(tab) => { {(tab) => {
const path = createMemo(() => props.file.pathFromTab(tab())) const path = createMemo(() => file.pathFromTab(tab))
return ( return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent"> <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show> <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
@@ -240,50 +333,44 @@ export function SessionSidePanel(props: {
</div> </div>
</Show> </Show>
<Show when={props.layout.fileTree.opened()}> <Show when={layout.fileTree.opened()}>
<div <div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
id="file-tree-panel"
class="relative shrink-0 h-full"
style={{ width: `${props.layout.fileTree.width()}px` }}
>
<div <div
class="h-full flex flex-col overflow-hidden group/filetree" class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weak-base": props.reviewOpen }} classList={{ "border-l border-border-weak-base": reviewOpen() }}
> >
<Tabs <Tabs
variant="pill" variant="pill"
value={props.fileTreeTab()} value={fileTreeTab()}
onChange={props.setFileTreeTabValue} onChange={setFileTreeTabValue}
class="h-full" class="h-full"
data-scope="filetree" data-scope="filetree"
> >
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount}{" "} {reviewCount()}{" "}
{props.language.t( {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{props.language.t("session.files.all")} {language.t("session.files.all")}
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
<Tabs.Content value="changes" class="bg-background-base px-3 py-0"> <Tabs.Content value="changes" class="bg-background-base px-3 py-0">
<Switch> <Switch>
<Match when={props.hasReview}> <Match when={hasReview()}>
<Show <Show
when={props.diffsReady} when={diffsReady()}
fallback={ fallback={
<div class="px-2 py-2 text-12-regular text-text-weak"> <div class="px-2 py-2 text-12-regular text-text-weak">
{props.language.t("common.loading")} {language.t("common.loading")}
{props.language.t("common.loading.ellipsis")} {language.t("common.loading.ellipsis")}
</div> </div>
} }
> >
<FileTree <FileTree
path="" path=""
allowed={props.diffFiles} allowed={diffFiles()}
kinds={props.kinds} kinds={kinds()}
draggable={false} draggable={false}
active={props.activeDiff} active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)} onFileClick={(node) => props.focusReviewDiff(node.path)}
@@ -292,7 +379,7 @@ export function SessionSidePanel(props: {
</Match> </Match>
<Match when={true}> <Match when={true}>
<div class="mt-8 text-center text-12-regular text-text-weak"> <div class="mt-8 text-center text-12-regular text-text-weak">
{props.language.t("session.review.noChanges")} {language.t("session.review.noChanges")}
</div> </div>
</Match> </Match>
</Switch> </Switch>
@@ -300,9 +387,9 @@ export function SessionSidePanel(props: {
<Tabs.Content value="all" class="bg-background-base px-3 py-0"> <Tabs.Content value="all" class="bg-background-base px-3 py-0">
<FileTree <FileTree
path="" path=""
modified={props.diffFiles} modified={diffFiles()}
kinds={props.kinds} kinds={kinds()}
onFileClick={(node) => props.openTab(props.file.tab(node.path))} onFileClick={(node) => openTab(file.tab(node.path))}
/> />
</Tabs.Content> </Tabs.Content>
</Tabs> </Tabs>
@@ -310,12 +397,12 @@ export function SessionSidePanel(props: {
<ResizeHandle <ResizeHandle
direction="horizontal" direction="horizontal"
edge="start" edge="start"
size={props.layout.fileTree.width()} size={layout.fileTree.width()}
min={200} min={200}
max={480} max={480}
collapseThreshold={160} collapseThreshold={160}
onResize={props.layout.fileTree.resize} onResize={layout.fileTree.resize}
onCollapse={props.layout.fileTree.close} onCollapse={layout.fileTree.close}
/> />
</div> </div>
</Show> </Show>
+157 -78
View File
@@ -1,61 +1,161 @@
import { For, Show, createMemo } from "solid-js" import { For, Show, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { SortableTerminalTab } from "@/components/session" import { SortableTerminalTab } from "@/components/session"
import { Terminal } from "@/components/terminal" import { Terminal } from "@/components/terminal"
import { useTerminal } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label" import { terminalTabLabel } from "@/pages/session/terminal-label"
import { focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
export function TerminalPanel(props: { export function TerminalPanel() {
open: boolean const params = useParams()
height: number const layout = useLayout()
resize: (value: number) => void const terminal = useTerminal()
close: () => void const language = useLanguage()
terminal: ReturnType<typeof useTerminal> const command = useCommand()
language: ReturnType<typeof useLanguage>
command: ReturnType<typeof useCommand> const isDesktop = createMediaQuery("(min-width: 768px)")
handoff: () => string[] const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
activeTerminalDraggable: () => string | undefined const view = createMemo(() => layout.view(sessionKey))
handleTerminalDragStart: (event: unknown) => void
handleTerminalDragOver: (event: DragEvent) => void const opened = createMemo(() => view().terminal.opened())
handleTerminalDragEnd: () => void const open = createMemo(() => isDesktop() && opened())
onCloseTab: () => void const height = createMemo(() => layout.terminal.height())
}) { const close = () => view().terminal.close()
const all = createMemo(() => props.terminal.all())
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
})
createEffect(() => {
if (!opened()) {
setStore("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
terminal.new()
setStore("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (opened()) view().terminal.toggle()
}
},
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !opened()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
},
),
)
createEffect(() => {
const dir = params.dir
if (!dir) return
if (!terminal.ready()) return
language.locale()
setTerminalHandoff(
dir,
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
const handoff = createMemo(() => {
const dir = params.dir
if (!dir) return []
return getTerminalHandoff(dir) ?? []
})
const all = createMemo(() => terminal.all())
const ids = createMemo(() => all().map((pty) => pty.id)) const ids = createMemo(() => all().map((pty) => pty.id))
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty]))) const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex)
}
}
const handleTerminalDragEnd = () => {
setStore("activeDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
focusTerminalById(activeId)
}, 0)
}
return ( return (
<Show when={props.open}> <Show when={open()}>
<div <div
id="terminal-panel" id="terminal-panel"
role="region" role="region"
aria-label={props.language.t("terminal.title")} aria-label={language.t("terminal.title")}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base" class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${props.height}px` }} style={{ height: `${height()}px` }}
> >
<ResizeHandle <ResizeHandle
direction="vertical" direction="vertical"
size={props.height} size={height()}
min={100} min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6} max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50} collapseThreshold={50}
onResize={props.resize} onResize={layout.terminal.resize}
onCollapse={props.close} onCollapse={close}
/> />
<Show <Show
when={props.terminal.ready()} when={terminal.ready()}
fallback={ fallback={
<div class="flex flex-col h-full pointer-events-none"> <div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden"> <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={props.handoff()}> <For each={handoff()}>
{(title) => ( {(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title} {title}
@@ -64,20 +164,18 @@ export function TerminalPanel(props: {
</For> </For>
<div class="flex-1" /> <div class="flex-1" />
<div class="text-text-weak pr-2"> <div class="text-text-weak pr-2">
{props.language.t("common.loading")} {language.t("common.loading")}
{props.language.t("common.loading.ellipsis")} {language.t("common.loading.ellipsis")}
</div> </div>
</div> </div>
<div class="flex-1 flex items-center justify-center text-text-weak"> <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
{props.language.t("terminal.loading")}
</div>
</div> </div>
} }
> >
<DragDropProvider <DragDropProvider
onDragStart={props.handleTerminalDragStart} onDragStart={handleTerminalDragStart}
onDragEnd={props.handleTerminalDragEnd} onDragEnd={handleTerminalDragEnd}
onDragOver={props.handleTerminalDragOver} onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter} collisionDetector={closestCenter}
> >
<DragDropSensors /> <DragDropSensors />
@@ -85,36 +183,26 @@ export function TerminalPanel(props: {
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<Tabs <Tabs
variant="alt" variant="alt"
value={props.terminal.active()} value={terminal.active()}
onChange={(id) => props.terminal.open(id)} onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none" class="!h-auto !flex-none"
> >
<Tabs.List class="h-10"> <Tabs.List class="h-10">
<SortableProvider ids={ids()}> <SortableProvider ids={ids()}>
<For each={all()}> <For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
{(pty) => (
<SortableTerminalTab
terminal={pty}
onClose={() => {
props.close()
props.onCloseTab()
}}
/>
)}
</For>
</SortableProvider> </SortableProvider>
<div class="h-full flex items-center justify-center"> <div class="h-full flex items-center justify-center">
<TooltipKeybind <TooltipKeybind
title={props.language.t("command.terminal.new")} title={language.t("command.terminal.new")}
keybind={props.command.keybind("terminal.new")} keybind={command.keybind("terminal.new")}
class="flex items-center" class="flex items-center"
> >
<IconButton <IconButton
icon="plus-small" icon="plus-small"
variant="ghost" variant="ghost"
iconSize="large" iconSize="large"
onClick={props.terminal.new} onClick={terminal.new}
aria-label={props.language.t("command.terminal.new")} aria-label={language.t("command.terminal.new")}
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </div>
@@ -127,15 +215,11 @@ export function TerminalPanel(props: {
id={`terminal-wrapper-${pty.id}`} id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0" class="absolute inset-0"
style={{ style={{
display: props.terminal.active() === pty.id ? "block" : "none", display: terminal.active() === pty.id ? "block" : "none",
}} }}
> >
<Show when={pty.id} keyed> <Show when={pty.id} keyed>
<Terminal <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
pty={pty}
onCleanup={props.terminal.update}
onConnectError={() => props.terminal.clone(pty.id)}
/>
</Show> </Show>
</div> </div>
)} )}
@@ -143,25 +227,20 @@ export function TerminalPanel(props: {
</div> </div>
</div> </div>
<DragOverlay> <DragOverlay>
<Show when={props.activeTerminalDraggable()}> <Show when={store.activeDraggable}>
{(draggedId) => { {(draggedId) => (
return ( <Show when={byId().get(draggedId())}>
<Show when={byId().get(draggedId())}> {(t) => (
{(t) => ( <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> {terminalTabLabel({
{terminalTabLabel({ title: t().title,
title: t().title, titleNumber: t().titleNumber,
titleNumber: t().titleNumber, t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
t: props.language.t as ( })}
key: string, </div>
vars?: Record<string, string | number | boolean>, )}
) => string, </Show>
})} )}
</div>
)}
</Show>
)
}}
</Show> </Show>
</DragOverlay> </DragOverlay>
</DragDropProvider> </DragDropProvider>
+15 -6
View File
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import { checkServerHealth } from "./server-health" import { checkServerHealth } from "./server-health"
const server: ServerConnection.HttpBase = {
url: "http://localhost:4096",
}
function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
if (init?.signal) return init.signal if (init?.signal) return init.signal
if (input instanceof Request) return input.signal if (input instanceof Request) return input.signal
@@ -15,7 +20,7 @@ describe("checkServerHealth", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch })) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch) const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" }) expect(result).toEqual({ healthy: true, version: "1.2.3" })
}) })
@@ -25,7 +30,7 @@ describe("checkServerHealth", () => {
throw new Error("network") throw new Error("network")
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch) const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: false }) expect(result).toEqual({ healthy: false })
}) })
@@ -51,7 +56,9 @@ describe("checkServerHealth", () => {
) )
})) as unknown as typeof globalThis.fetch })) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => { const result = await checkServerHealth(server, fetch, {
timeoutMs: 10,
}).finally(() => {
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
}) })
@@ -71,7 +78,9 @@ describe("checkServerHealth", () => {
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const abort = new AbortController() const abort = new AbortController()
await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal }) await checkServerHealth(server, fetch, {
signal: abort.signal,
})
expect(signal).toBe(abort.signal) expect(signal).toBe(abort.signal)
}) })
@@ -87,7 +96,7 @@ describe("checkServerHealth", () => {
}) })
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { const result = await checkServerHealth(server, fetch, {
retryCount: 2, retryCount: 2,
retryDelayMs: 1, retryDelayMs: 1,
}) })
@@ -103,7 +112,7 @@ describe("checkServerHealth", () => {
throw new TypeError("network") throw new TypeError("network")
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { const result = await checkServerHealth(server, fetch, {
retryCount: 2, retryCount: 2,
retryDelayMs: 1, retryDelayMs: 1,
}) })
+9 -5
View File
@@ -1,4 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
export type ServerHealth = { healthy: boolean; version?: string } export type ServerHealth = { healthy: boolean; version?: string }
@@ -17,7 +18,10 @@ function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
if (timeout) { if (timeout) {
try { try {
return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined } return {
signal: timeout.call(AbortSignal, timeoutMs),
clear: undefined as (() => void) | undefined,
}
} catch {} } catch {}
} }
const controller = new AbortController() const controller = new AbortController()
@@ -52,7 +56,7 @@ function retryable(error: unknown, signal?: AbortSignal) {
} }
export async function checkServerHealth( export async function checkServerHealth(
url: string, server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch, fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions, opts?: CheckServerHealthOptions,
): Promise<ServerHealth> { ): Promise<ServerHealth> {
@@ -67,8 +71,8 @@ export async function checkServerHealth(
.catch(() => ({ healthy: false })) .catch(() => ({ healthy: false }))
} }
const attempt = (count: number): Promise<ServerHealth> => const attempt = (count: number): Promise<ServerHealth> =>
createOpencodeClient({ createSdkForServer({
baseUrl: url, server,
fetch, fetch,
signal, signal,
}) })
+22
View File
@@ -0,0 +1,22 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
export function createSdkForServer({
server,
...config
}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
server: ServerConnection.HttpBase
}) {
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
}
})()
return createOpencodeClient({
...config,
headers: { ...config.headers, ...auth },
baseUrl: server.url,
})
}
+2 -1
View File
@@ -22,5 +22,6 @@
} }
}, },
"include": ["src", "package.json"], "include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"] "exclude": ["dist", "ts-dist"],
"references": [{ "path": "../sdk/js" }]
} }
+4 -12
View File
@@ -9,7 +9,6 @@ use std::os::unix::process::ExitStatusExt;
use std::sync::Arc; use std::sync::Arc;
use std::{process::Stdio, time::Duration}; use std::{process::Stdio, time::Duration};
use tauri::{AppHandle, Manager, path::BaseDirectory}; use tauri::{AppHandle, Manager, path::BaseDirectory};
use tauri_plugin_store::StoreExt;
use tauri_specta::Event; use tauri_specta::Event;
use tokio::{ use tokio::{
io::{AsyncBufRead, AsyncBufReadExt, BufReader}, io::{AsyncBufRead, AsyncBufReadExt, BufReader},
@@ -20,7 +19,8 @@ use tokio::{
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tracing::Instrument; use tracing::Instrument;
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY}; use crate::server::get_wsl_config;
const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode"; const CLI_BINARY_NAME: &str = "opencode";
@@ -202,16 +202,8 @@ fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
} }
fn is_wsl_enabled(app: &tauri::AppHandle) -> bool { fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
let Ok(store) = app.store(SETTINGS_STORE) else { get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
return false;
};
store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|value| value.as_bool())
.unwrap_or(false)
} }
fn shell_escape(input: &str) -> String { fn shell_escape(input: &str) -> String {
+10 -10
View File
@@ -55,18 +55,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> { pub fn get_wsl_config(_app: AppHandle) -> Result<WslConfig, String> {
let store = app // let store = app
.store(SETTINGS_STORE) // .store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?; // .map_err(|e| format!("Failed to open settings store: {}", e))?;
let enabled = store // let enabled = store
.get(WSL_ENABLED_KEY) // .get(WSL_ENABLED_KEY)
.as_ref() // .as_ref()
.and_then(|v| v.as_bool()) // .and_then(|v| v.as_bool())
.unwrap_or(false); // .unwrap_or(false);
Ok(WslConfig { enabled }) Ok(WslConfig { enabled: false })
} }
#[tauri::command] #[tauri::command]
+42 -44
View File
@@ -1,36 +1,37 @@
// @refresh reload // @refresh reload
import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web"
import { import {
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
PlatformProvider,
Platform,
useCommand,
handleNotificationClick, handleNotificationClick,
type Platform,
PlatformProvider,
ServerConnection,
useCommand,
} from "@opencode-ai/app" } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager" import { readImage } from "@tauri-apps/plugin-clipboard-manager"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { UPDATER_ENABLED } from "./updater" import { open, save } from "@tauri-apps/plugin-dialog"
import { initI18n, t } from "./i18n" import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
import { type as ostype } from "@tauri-apps/plugin-os"
import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json" import pkg from "../package.json"
import { initI18n, t } from "./i18n"
import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css" import "./styles.css"
import { commands, InitStep } from "./bindings"
import { Channel } from "@tauri-apps/api/core" import { Channel } from "@tauri-apps/api/core"
import { commands, type InitStep } from "./bindings"
import { createMenu } from "./menu" import { createMenu } from "./menu"
const root = document.getElementById("root") const root = document.getElementById("root")
@@ -58,7 +59,7 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined) await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
} }
const createPlatform = (password: Accessor<string | null>): Platform => { const createPlatform = (): Platform => {
const os = (() => { const os = (() => {
const type = ostype() const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type if (type === "macos" || type === "windows" || type === "linux") return type
@@ -344,22 +345,10 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
}, },
fetch: (input, init) => { fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) { if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input) return tauriFetch(input)
} else { } else {
const headers = new Headers(init?.headers) return tauriFetch(input, init)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
} }
}, },
@@ -417,7 +406,11 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
return new Promise<File | null>((resolve) => { return new Promise<File | null>((resolve) => {
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (!blob) return resolve(null) if (!blob) return resolve(null)
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) resolve(
new File([blob], `pasted-image-${Date.now()}.png`, {
type: "image/png",
}),
)
}, "image/png") }, "image/png")
}) })
}, },
@@ -431,9 +424,7 @@ createMenu((id) => {
void listenForDeepLinks() void listenForDeepLinks()
render(() => { render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null) const platform = createPlatform()
const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -455,9 +446,16 @@ render(() => {
<AppBaseProviders> <AppBaseProviders>
<ServerGate> <ServerGate>
{(data) => { {(data) => {
setServerPassword(data().password) const server: ServerConnection.Sidecar = {
window.__OPENCODE__ ??= {} displayName: "Local Server",
window.__OPENCODE__.serverPassword = data().password ?? undefined type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
}
function Inner() { function Inner() {
const cmd = useCommand() const cmd = useCommand()
@@ -468,7 +466,7 @@ render(() => {
} }
return ( return (
<AppInterface defaultUrl={data().url} isSidecar> <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]}>
<Inner /> <Inner />
</AppInterface> </AppInterface>
) )
+3 -1
View File
@@ -40,6 +40,7 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5", "@types/babel__core": "7.20.5",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -74,7 +75,7 @@
"@ai-sdk/vercel": "1.0.33", "@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.5.1", "@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@@ -110,6 +111,7 @@
"hono-openapi": "catalog:", "hono-openapi": "catalog:",
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
+2 -1
View File
@@ -214,7 +214,8 @@ export default {
}, },
{ {
filetype: "clojure", filetype: "clojure",
wasm: "https://github.com/sogaiu/tree-sitter-clojure/releases/download/v0.0.13/tree-sitter-clojure.wasm", // temporarily using fork to fix issues
wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
queries: { queries: {
highlights: [ highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm", "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",
+4 -3
View File
@@ -52,13 +52,13 @@ export namespace Agent {
const cfg = await Config.get() const cfg = await Config.get()
const skillDirs = await Skill.dirs() const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = PermissionNext.fromConfig({ const defaults = PermissionNext.fromConfig({
"*": "allow", "*": "allow",
doom_loop: "ask", doom_loop: "ask",
external_directory: { external_directory: {
"*": "ask", "*": "ask",
[Truncate.GLOB]: "allow", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
}, },
question: "deny", question: "deny",
plan_enter: "deny", plan_enter: "deny",
@@ -142,7 +142,8 @@ export namespace Agent {
codesearch: "allow", codesearch: "allow",
read: "allow", read: "allow",
external_directory: { external_directory: {
[Truncate.GLOB]: "allow", "*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
}, },
}), }),
user, user,
+5 -6
View File
@@ -13,6 +13,7 @@ import { Installation } from "../../installation"
import path from "path" import path from "path"
import { Global } from "../../global" import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser" import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus" import { Bus } from "../../bus"
function getAuthStatusIcon(status: MCP.AuthStatus): string { function getAuthStatusIcon(status: MCP.AuthStatus): string {
@@ -388,7 +389,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
} }
for (const candidate of candidates) { for (const candidate of candidates) {
if (await Bun.file(candidate).exists()) { if (await Filesystem.exists(candidate)) {
return candidate return candidate
} }
} }
@@ -398,11 +399,9 @@ async function resolveConfigPath(baseDir: string, global = false) {
} }
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) { async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
const file = Bun.file(configPath)
let text = "{}" let text = "{}"
if (await file.exists()) { if (await Filesystem.exists(configPath)) {
text = await file.text() text = await Filesystem.readText(configPath)
} }
// Use jsonc-parser to modify while preserving comments // Use jsonc-parser to modify while preserving comments
@@ -411,7 +410,7 @@ async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: s
}) })
const result = applyEdits(text, edits) const result = applyEdits(text, edits)
await Bun.write(configPath, result) await Filesystem.write(configPath, result)
return configPath return configPath
} }
+4 -13
View File
@@ -85,26 +85,17 @@ export const SessionListCommand = cmd({
}, },
handler: async (args) => { handler: async (args) => {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const sessions = [] const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
for await (const session of Session.list()) {
if (!session.parentID) {
sessions.push(session)
}
}
sessions.sort((a, b) => b.time.updated - a.time.updated) if (sessions.length === 0) {
const limitedSessions = args.maxCount ? sessions.slice(0, args.maxCount) : sessions
if (limitedSessions.length === 0) {
return return
} }
let output: string let output: string
if (args.format === "json") { if (args.format === "json") {
output = formatSessionJSON(limitedSessions) output = formatSessionJSON(sessions)
} else { } else {
output = formatSessionTable(limitedSessions) output = formatSessionTable(sessions)
} }
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
+35 -41
View File
@@ -255,19 +255,20 @@ export namespace Config {
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Bun.file(pkg) const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
.json() dependencies: {},
.catch(() => ({})) }))
json.dependencies = { json.dependencies = {
...json.dependencies, ...json.dependencies,
"@opencode-ai/plugin": targetVersion, "@opencode-ai/plugin": targetVersion,
} }
await Bun.write(pkg, JSON.stringify(json, null, 2)) await Filesystem.writeJson(pkg, json)
await new Promise((resolve) => setTimeout(resolve, 3000)) await new Promise((resolve) => setTimeout(resolve, 3000))
const gitignore = path.join(dir, ".gitignore") const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Bun.file(gitignore).exists() const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) if (!hasGitIgnore)
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
// Install any additional dependencies defined in the package.json // Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages // This allows local plugins and custom tools to use external packages
@@ -303,11 +304,10 @@ export namespace Config {
if (!existsSync(nodeModules)) return true if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const pkgFile = Bun.file(pkg) const pkgExists = await Filesystem.exists(pkg)
const pkgExists = await pkgFile.exists()
if (!pkgExists) return true if (!pkgExists) return true
const parsed = await pkgFile.json().catch(() => null) const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {} const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"] const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true if (!depVersion) return true
@@ -1220,7 +1220,7 @@ export namespace Config {
if (provider && model) result.model = `${provider}/${model}` if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json" result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest) result = mergeDeep(result, rest)
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result)
await fs.unlink(legacy) await fs.unlink(legacy)
}) })
.catch(() => {}) .catch(() => {})
@@ -1231,12 +1231,10 @@ export namespace Config {
async function loadFile(filepath: string): Promise<Info> { async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath }) log.info("loading", { path: filepath })
let text = await Bun.file(filepath) let text = await Filesystem.readText(filepath).catch((err: any) => {
.text() if (err.code === "ENOENT") return
.catch((err) => { throw new JsonError({ path: filepath }, { cause: err })
if (err.code === "ENOENT") return })
throw new JsonError({ path: filepath }, { cause: err })
})
if (!text) return {} if (!text) return {}
return load(text, filepath) return load(text, filepath)
} }
@@ -1263,21 +1261,19 @@ export namespace Config {
} }
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = ( const fileContent = (
await Bun.file(resolvedPath) await Filesystem.readText(resolvedPath).catch((error: any) => {
.text() const errMsg = `bad file reference: "${match}"`
.catch((error) => { if (error.code === "ENOENT") {
const errMsg = `bad file reference: "${match}"` throw new InvalidError(
if (error.code === "ENOENT") { {
throw new InvalidError( path: configFilepath,
{ message: errMsg + ` ${resolvedPath} does not exist`,
path: configFilepath, },
message: errMsg + ` ${resolvedPath} does not exist`, { cause: error },
}, )
{ cause: error }, }
) throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
} })
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
})
).trim() ).trim()
// escape newlines/quotes, strip outer quotes // escape newlines/quotes, strip outer quotes
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1)) text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
@@ -1314,7 +1310,7 @@ export namespace Config {
parsed.data.$schema = "https://opencode.ai/config.json" parsed.data.$schema = "https://opencode.ai/config.json"
// Write the $schema to the original text to preserve variables like {env:VAR} // Write the $schema to the original text to preserve variables like {env:VAR}
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(configFilepath, updated).catch(() => {}) await Filesystem.write(configFilepath, updated).catch(() => {})
} }
const data = parsed.data const data = parsed.data
if (data.plugin) { if (data.plugin) {
@@ -1370,7 +1366,7 @@ export namespace Config {
export async function update(config: Info) { export async function update(config: Info) {
const filepath = path.join(Instance.directory, "config.json") const filepath = path.join(Instance.directory, "config.json")
const existing = await loadFile(filepath) const existing = await loadFile(filepath)
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2)) await Filesystem.writeJson(filepath, mergeDeep(existing, config))
await Instance.dispose() await Instance.dispose()
} }
@@ -1441,24 +1437,22 @@ export namespace Config {
export async function updateGlobal(config: Info) { export async function updateGlobal(config: Info) {
const filepath = globalConfigFile() const filepath = globalConfigFile()
const before = await Bun.file(filepath) const before = await Filesystem.readText(filepath).catch((err: any) => {
.text() if (err.code === "ENOENT") return "{}"
.catch((err) => { throw new JsonError({ path: filepath }, { cause: err })
if (err.code === "ENOENT") return "{}" })
throw new JsonError({ path: filepath }, { cause: err })
})
const next = await (async () => { const next = await (async () => {
if (!filepath.endsWith(".jsonc")) { if (!filepath.endsWith(".jsonc")) {
const existing = parseConfig(before, filepath) const existing = parseConfig(before, filepath)
const merged = mergeDeep(existing, config) const merged = mergeDeep(existing, config)
await Bun.write(filepath, JSON.stringify(merged, null, 2)) await Filesystem.writeJson(filepath, merged)
return merged return merged
} }
const updated = patchJsonc(before, config) const updated = patchJsonc(before, config)
const merged = parseConfig(updated, filepath) const merged = parseConfig(updated, filepath)
await Bun.write(filepath, updated) await Filesystem.write(filepath, updated)
return merged return merged
})() })()
+6 -6
View File
@@ -6,6 +6,7 @@ import z from "zod"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { $ } from "bun" import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log" import { Log } from "@/util/log"
@@ -131,8 +132,7 @@ export namespace Ripgrep {
} }
const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
const file = Bun.file(filepath) if (!(await Filesystem.exists(filepath))) {
if (!(await file.exists())) {
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey] const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey }) if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
@@ -144,9 +144,9 @@ export namespace Ripgrep {
const response = await fetch(url) const response = await fetch(url)
if (!response.ok) throw new DownloadFailedError({ url, status: response.status }) if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const buffer = await response.arrayBuffer() const arrayBuffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename) const archivePath = path.join(Global.Path.bin, filename)
await Bun.write(archivePath, buffer) await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
if (config.extension === "tar.gz") { if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"] const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
@@ -166,7 +166,7 @@ export namespace Ripgrep {
}) })
} }
if (config.extension === "zip") { if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()]))) const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
const entries = await zipFileReader.getEntries() const entries = await zipFileReader.getEntries()
let rgEntry: any let rgEntry: any
for (const entry of entries) { for (const entry of entries) {
@@ -190,7 +190,7 @@ export namespace Ripgrep {
stderr: "Failed to extract rg.exe from zip archive", stderr: "Failed to extract rg.exe from zip archive",
}) })
} }
await Bun.write(filepath, await rgBlob.arrayBuffer()) await Filesystem.write(filepath, Buffer.from(await rgBlob.arrayBuffer()))
await zipFileReader.close() await zipFileReader.close()
} }
await fs.unlink(archivePath) await fs.unlink(archivePath)
+2 -1
View File
@@ -13,6 +13,7 @@ import { Installation } from "./installation"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { FormatError } from "./cli/error" import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve" import { ServeCommand } from "./cli/cmd/serve"
import { Filesystem } from "./util/filesystem"
import { DebugCommand } from "./cli/cmd/debug" import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats" import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp" import { McpCommand } from "./cli/cmd/mcp"
@@ -81,7 +82,7 @@ const cli = yargs(hideBin(process.argv))
}) })
const marker = path.join(Global.Path.data, "opencode.db") const marker = path.join(Global.Path.data, "opencode.db")
if (!(await Bun.file(marker).exists())) { if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL) process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
const width = 36 const width = 36
+1
View File
@@ -44,6 +44,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".htm": "html", ".htm": "html",
".ini": "ini", ".ini": "ini",
".java": "java", ".java": "java",
".jl": "julia",
".js": "javascript", ".js": "javascript",
".kt": "kotlin", ".kt": "kotlin",
".kts": "kotlin", ".kts": "kotlin",
+18
View File
@@ -2043,4 +2043,22 @@ export namespace LSPServer {
} }
}, },
} }
export const JuliaLS: Info = {
id: "julials",
extensions: [".jl"],
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
async spawn(root) {
const julia = Bun.which("julia")
if (!julia) {
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
return
}
return {
process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], {
cwd: root,
}),
}
},
}
} }
+13 -14
View File
@@ -1,6 +1,7 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import * as fs from "fs/promises" import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util/log" import { Log } from "../util/log"
export namespace Patch { export namespace Patch {
@@ -307,18 +308,16 @@ export namespace Patch {
content: string content: string
} }
export async function deriveNewContentsFromChunks( export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
filePath: string, // Read original file content
chunks: UpdateFileChunk[], let originalContent: string
originalContent?: string, try {
): Promise<ApplyPatchFileUpdate> { originalContent = readFileSync(filePath, "utf-8")
const content = } catch (error) {
originalContent ?? throw new Error(`Failed to read file ${filePath}: ${error}`)
(await fs.readFile(filePath, "utf-8").catch((error) => { }
throw new Error(`Failed to read file ${filePath}: ${error}`)
}))
let originalLines = content.split("\n") let originalLines = originalContent.split("\n")
// Drop trailing empty element for consistent line counting // Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
@@ -336,7 +335,7 @@ export namespace Patch {
const newContent = newLines.join("\n") const newContent = newLines.join("\n")
// Generate unified diff // Generate unified diff
const unifiedDiff = generateUnifiedDiff(content, newContent) const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
return { return {
unified_diff: unifiedDiff, unified_diff: unifiedDiff,
@@ -546,7 +545,7 @@ export namespace Patch {
break break
case "update": case "update":
const fileUpdate = await deriveNewContentsFromChunks(hunk.path, hunk.chunks) const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
if (hunk.move_path) { if (hunk.move_path) {
// Handle file move // Handle file move
@@ -642,7 +641,7 @@ export namespace Patch {
case "update": case "update":
const updatePath = path.resolve(effectiveCwd, hunk.path) const updatePath = path.resolve(effectiveCwd, hunk.path)
try { try {
const fileUpdate = await deriveNewContentsFromChunks(updatePath, hunk.chunks) const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
changes.set(resolvedPath, { changes.set(resolvedPath, {
type: "update", type: "update",
unified_diff: fileUpdate.unified_diff, unified_diff: fileUpdate.unified_diff,
+1 -1
View File
@@ -101,7 +101,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
// Apply the update chunks to get new content // Apply the update chunks to get new content
try { try {
const fileUpdate = await Patch.deriveNewContentsFromChunks(filePath, hunk.chunks, oldContent) const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content newContent = fileUpdate.content
} catch (error) { } catch (error) {
throw new Error(`apply_patch verification failed: ${error}`) throw new Error(`apply_patch verification failed: ${error}`)
+69 -10
View File
@@ -1,18 +1,76 @@
import { mkdir, readFile, writeFile } from "fs/promises"
import { existsSync, statSync } from "fs"
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"
export namespace Filesystem { export namespace Filesystem {
export const exists = (p: string) => // Fast sync version for metadata checks
Bun.file(p) export async function exists(p: string): Promise<boolean> {
.stat() return existsSync(p)
.then(() => true) }
.catch(() => false)
export async function isDir(p: string): Promise<boolean> {
try {
return statSync(p).isDirectory()
} catch {
return false
}
}
export async function size(p: string): Promise<number> {
try {
return statSync(p).size
} catch {
return 0
}
}
export async function readText(p: string): Promise<string> {
return readFile(p, "utf-8")
}
export async function readJson<T>(p: string): Promise<T> {
return JSON.parse(await readFile(p, "utf-8"))
}
export async function readBytes(p: string): Promise<Buffer> {
return readFile(p)
}
function isEnoent(e: unknown): e is { code: "ENOENT" } {
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
}
export async function write(p: string, content: string | Buffer, mode?: number): Promise<void> {
try {
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
} catch (e) {
if (isEnoent(e)) {
await mkdir(dirname(p), { recursive: true })
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
return
}
throw e
}
}
export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
return write(p, JSON.stringify(data, null, 2), mode)
}
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
export const isDir = (p: string) =>
Bun.file(p)
.stat()
.then((s) => s.isDirectory())
.catch(() => false)
/** /**
* On Windows, normalize a path to its canonical casing using the filesystem. * On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers * This is needed because Windows paths are case-insensitive but LSP servers
@@ -26,6 +84,7 @@ export namespace Filesystem {
return p return p
} }
} }
export function overlaps(a: string, b: string) { export function overlaps(a: string, b: string) {
const relA = relative(a, b) const relA = relative(a, b)
const relB = relative(b, a) const relB = relative(b, a)
@@ -75,6 +75,20 @@ test("explore agent denies edit and write", async () => {
}) })
}) })
test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
const { Truncate } = await import("../../src/tool/truncation")
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const explore = await Agent.get("explore")
expect(explore).toBeDefined()
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
},
})
})
test("general agent denies todo tools", async () => { test("general agent denies todo tools", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
+340
View File
@@ -0,0 +1,340 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("file/index Bun.file patterns", () => {
describe("File.read() - text content", () => {
test("reads text file via Bun.file().text()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "Hello World", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("Hello World")
},
})
})
test("reads with Bun.file().exists() check", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Non-existent file should return empty content
const result = await File.read("nonexistent.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
})
})
test("trims whitespace from text content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
expect(result.content).toBe("content with spaces")
},
})
})
test("handles empty text file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "empty.txt")
await fs.writeFile(filepath, "", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("empty.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
})
})
test("handles multi-line text files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "multiline.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3")
},
})
})
})
describe("File.read() - binary content", () => {
test("reads binary file via Bun.file().arrayBuffer()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "image.png")
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await fs.writeFile(filepath, binaryContent)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("image.png")
expect(result.type).toBe("text") // Images return as text with base64 encoding
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/png")
expect(result.content).toBe(binaryContent.toString("base64"))
},
})
})
test("returns empty for binary non-image files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "binary.so")
await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("binary.so")
expect(result.type).toBe("binary")
expect(result.content).toBe("")
},
})
})
})
describe("File.read() - Bun.file().type", () => {
test("detects MIME type via Bun.file().type", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.json")
await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bunFile = Bun.file(filepath)
expect(bunFile.type).toContain("application/json")
const result = await File.read("test.json")
expect(result.type).toBe("text")
},
})
})
test("handles various image MIME types", async () => {
await using tmp = await tmpdir()
const testCases = [
{ ext: "jpg", mime: "image/jpeg" },
{ ext: "png", mime: "image/png" },
{ ext: "gif", mime: "image/gif" },
{ ext: "webp", mime: "image/webp" },
]
for (const { ext, mime } of testCases) {
const filepath = path.join(tmp.path, `test.${ext}`)
await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bunFile = Bun.file(filepath)
expect(bunFile.type).toContain(mime)
},
})
}
})
})
describe("File.list() - Bun.file().exists() and .text()", () => {
test("reads .gitignore via Bun.file().exists() and .text()", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
// This is used internally in File.list()
const bunFile = Bun.file(gitignorePath)
expect(await bunFile.exists()).toBe(true)
const content = await bunFile.text()
expect(content).toContain("node_modules")
},
})
})
test("reads .ignore file similarly", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ignorePath = path.join(tmp.path, ".ignore")
await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
const bunFile = Bun.file(ignorePath)
expect(await bunFile.exists()).toBe(true)
expect(await bunFile.text()).toContain("*.log")
},
})
})
test("handles missing .gitignore gracefully", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
const bunFile = Bun.file(gitignorePath)
expect(await bunFile.exists()).toBe(false)
// File.list() should still work
const nodes = await File.list()
expect(Array.isArray(nodes)).toBe(true)
},
})
})
})
describe("File.changed() - Bun.file().text() for untracked files", () => {
test("reads untracked files via Bun.file().text()", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const untrackedPath = path.join(tmp.path, "untracked.txt")
await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
// This is how File.changed() reads untracked files
const bunFile = Bun.file(untrackedPath)
const content = await bunFile.text()
const lines = content.split("\n").length
expect(lines).toBe(2)
},
})
})
})
describe("Error handling", () => {
test("handles errors gracefully in Bun.file().text()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "readonly.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.txt"))
// Bun.file().text() on non-existent file throws
await expect(nonExistentFile.text()).rejects.toThrow()
// But File.read() handles this gracefully
const result = await File.read("does-not-exist.txt")
expect(result.content).toBe("")
},
})
})
test("handles errors in Bun.file().arrayBuffer()", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.bin"))
const buffer = await nonExistentFile.arrayBuffer().catch(() => new ArrayBuffer(0))
expect(buffer.byteLength).toBe(0)
},
})
})
test("returns empty array buffer on error for images", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "broken.png")
// Don't create the file
await Instance.provide({
directory: tmp.path,
fn: async () => {
const bunFile = Bun.file(filepath)
// File.read() handles missing images gracefully
const result = await File.read("broken.png")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
})
})
})
describe("shouldEncode() logic", () => {
test("returns encoding info for text files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "simple text", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
expect(result.encoding).toBeUndefined()
expect(result.type).toBe("text")
},
})
})
test("returns base64 encoding for images", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.jpg")
await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.jpg")
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/jpeg")
},
})
})
})
describe("Path security", () => {
test("throws for paths outside project directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
},
})
})
test("throws for paths outside project directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
},
})
})
})
})
+360
View File
@@ -0,0 +1,360 @@
import { describe, test, expect, beforeEach } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { FileTime } from "../../src/file/time"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("file/time", () => {
const sessionID = "test-session-123"
describe("read() and get()", () => {
test("stores read timestamp", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = FileTime.get(sessionID, filepath)
expect(before).toBeUndefined()
FileTime.read(sessionID, filepath)
const after = FileTime.get(sessionID, filepath)
expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0)
},
})
})
test("tracks separate timestamps per session", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read("session1", filepath)
FileTime.read("session2", filepath)
const time1 = FileTime.get("session1", filepath)
const time2 = FileTime.get("session2", filepath)
expect(time1).toBeDefined()
expect(time2).toBeDefined()
},
})
})
test("updates timestamp on subsequent reads", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
const first = FileTime.get(sessionID, filepath)!
await new Promise((resolve) => setTimeout(resolve, 10))
FileTime.read(sessionID, filepath)
const second = FileTime.get(sessionID, filepath)!
expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
},
})
})
})
describe("assert()", () => {
test("passes when file has not been modified", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
// Should not throw
await FileTime.assert(sessionID, filepath)
},
})
})
test("throws when file was not read first", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
},
})
})
test("throws when file was modified after read", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
// Wait to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 100))
// Modify file after reading
await fs.writeFile(filepath, "modified content", "utf-8")
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
},
})
})
test("includes timestamps in error message", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "modified", "utf-8")
let error: Error | undefined
try {
await FileTime.assert(sessionID, filepath)
} catch (e) {
error = e as Error
}
expect(error).toBeDefined()
expect(error!.message).toContain("Last modification:")
expect(error!.message).toContain("Last read:")
},
})
})
test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Flag } = await import("../../src/flag/flag")
const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
try {
// Should not throw even though file wasn't read
await FileTime.assert(sessionID, filepath)
} finally {
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
}
},
})
})
})
describe("withLock()", () => {
test("executes function within lock", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
return "result"
})
expect(executed).toBe(true)
},
})
})
test("returns function result", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await FileTime.withLock(filepath, async () => {
return "success"
})
expect(result).toBe("success")
},
})
})
test("serializes concurrent operations on same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const order: number[] = []
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
await new Promise((resolve) => setTimeout(resolve, 10))
order.push(2)
})
const op2 = FileTime.withLock(filepath, async () => {
order.push(3)
order.push(4)
})
await Promise.all([op1, op2])
// Operations should be serialized
expect(order).toContain(1)
expect(order).toContain(2)
expect(order).toContain(3)
expect(order).toContain(4)
},
})
})
test("allows concurrent operations on different files", async () => {
await using tmp = await tmpdir()
const filepath1 = path.join(tmp.path, "file1.txt")
const filepath2 = path.join(tmp.path, "file2.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
let started1 = false
let started2 = false
const op1 = FileTime.withLock(filepath1, async () => {
started1 = true
await new Promise((resolve) => setTimeout(resolve, 50))
expect(started2).toBe(true) // op2 should have started while op1 is running
})
const op2 = FileTime.withLock(filepath2, async () => {
started2 = true
})
await Promise.all([op1, op2])
expect(started1).toBe(true)
expect(started2).toBe(true)
},
})
})
test("releases lock even if function throws", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(
FileTime.withLock(filepath, async () => {
throw new Error("Test error")
}),
).rejects.toThrow("Test error")
// Lock should be released, subsequent operations should work
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
})
expect(executed).toBe(true)
},
})
})
test("deadlocks on nested locks (expected behavior)", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Nested locks on same file cause deadlock - this is expected
// The outer lock waits for inner to complete, but inner waits for outer to release
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Deadlock detected")), 100),
)
const nestedLock = FileTime.withLock(filepath, async () => {
return FileTime.withLock(filepath, async () => {
return "inner"
})
})
// Should timeout due to deadlock
await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
},
})
})
})
describe("stat() Bun.file pattern", () => {
test("reads file modification time via Bun.file().stat()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
const stats = await Bun.file(filepath).stat()
expect(stats.mtime).toBeInstanceOf(Date)
expect(stats.mtime.getTime()).toBeGreaterThan(0)
// FileTime.assert uses this stat internally
await FileTime.assert(sessionID, filepath)
},
})
})
test("detects modification via stat mtime", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
const originalStat = await Bun.file(filepath).stat()
// Wait and modify
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "modified", "utf-8")
const newStat = await Bun.file(filepath).stat()
expect(newStat.mtime.getTime()).toBeGreaterThan(originalStat.mtime.getTime())
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
},
})
})
})
})
+496
View File
@@ -0,0 +1,496 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time"
const ctx = {
sessionID: "test-edit-session",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
describe("tool.edit", () => {
describe("creating new files", () => {
test("creates new file when oldString is empty", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "newfile.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "",
newString: "new content",
},
ctx,
)
expect(result.metadata.diff).toContain("new content")
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("new content")
},
})
})
test("creates new file with nested directories", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "",
newString: "nested file",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("nested file")
},
})
})
test("emits add event for new files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "new.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file")
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "",
newString: "content",
},
ctx,
)
expect(events).toContain("edited")
expect(events).toContain("updated")
unsubEdited()
unsubUpdated()
},
})
})
})
describe("editing existing files", () => {
test("replaces text in existing file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "existing.txt")
await fs.writeFile(filepath, "old content here", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "old content",
newString: "new content",
},
ctx,
)
expect(result.output).toContain("Edit applied successfully")
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("new content here")
},
})
})
test("throws error when file does not exist", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nonexistent.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "old",
newString: "new",
},
ctx,
),
).rejects.toThrow("not found")
},
})
})
test("throws error when oldString equals newString", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "same",
newString: "same",
},
ctx,
),
).rejects.toThrow("identical")
},
})
})
test("throws error when oldString not found in file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "actual content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "not in file",
newString: "replacement",
},
ctx,
),
).rejects.toThrow()
},
})
})
test("throws error when file was not read first (FileTime)", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "content",
newString: "modified",
},
ctx,
),
).rejects.toThrow("You must read file")
},
})
})
test("throws error when file has been modified since read", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Read first
FileTime.read(ctx.sessionID, filepath)
// Wait a bit to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 100))
// Simulate external modification
await fs.writeFile(filepath, "modified externally", "utf-8")
// Try to edit with the new content
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "modified externally",
newString: "edited",
},
ctx,
),
).rejects.toThrow("modified since it was last read")
},
})
})
test("replaces all occurrences with replaceAll option", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "foo",
newString: "qux",
replaceAll: true,
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("qux bar qux baz qux")
},
})
})
test("emits change event for existing files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "original", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file")
const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = []
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "original",
newString: "modified",
},
ctx,
)
expect(events).toContain("edited")
expect(events).toContain("updated")
unsubEdited()
unsubUpdated()
},
})
})
})
describe("edge cases", () => {
test("handles multiline replacements", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "line2",
newString: "new line 2\nextra line",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("line1\nnew line 2\nextra line\nline3")
},
})
})
test("handles CRLF line endings", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
oldString: "old",
newString: "new",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("line1\r\nnew\r\nline3")
},
})
})
test("throws error when oldString equals newString", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
oldString: "",
newString: "",
},
ctx,
),
).rejects.toThrow("identical")
},
})
})
test("throws error when path is directory", async () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "adir")
await fs.mkdir(dirpath)
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, dirpath)
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: dirpath,
oldString: "old",
newString: "new",
},
ctx,
),
).rejects.toThrow("directory")
},
})
})
test("tracks file diff statistics", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
oldString: "line2",
newString: "new line a\nnew line b",
},
ctx,
)
expect(result.metadata.filediff).toBeDefined()
expect(result.metadata.filediff.file).toBe(filepath)
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
},
})
})
})
describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "0", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
// Two concurrent edits
const promise1 = edit.execute(
{
filePath: filepath,
oldString: "0",
newString: "1",
},
ctx,
)
// Need to read again since FileTime tracks per-session
FileTime.read(ctx.sessionID, filepath)
const promise2 = edit.execute(
{
filePath: filepath,
oldString: "0",
newString: "2",
},
ctx,
)
// Both should complete without error (though one might fail due to content mismatch)
const results = await Promise.allSettled([promise1, promise2])
expect(results.some((r) => r.status === "fulfilled")).toBe(true)
},
})
})
})
})
+341
View File
@@ -0,0 +1,341 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const ctx = {
sessionID: "test-write-session",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
describe("tool.write", () => {
describe("new file creation", () => {
test("writes content to new file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "newfile.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "Hello, World!",
},
ctx,
)
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(false)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("Hello, World!")
},
})
})
test("creates parent directories if needed", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: "nested content",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("nested content")
},
})
})
test("handles relative paths by resolving to instance directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: "relative.txt",
content: "relative content",
},
ctx,
)
const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
expect(content).toBe("relative content")
},
})
})
})
describe("existing file overwrite", () => {
test("overwrites existing file content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "existing.txt")
await fs.writeFile(filepath, "old content", "utf-8")
// First read the file to satisfy FileTime requirement
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")
FileTime.read(ctx.sessionID, filepath)
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "new content",
},
ctx,
)
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(true)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("new content")
},
})
})
test("returns diff in metadata for existing files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "old", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { FileTime } = await import("../../src/file/time")
FileTime.read(ctx.sessionID, filepath)
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "new",
},
ctx,
)
// Diff should be in metadata
expect(result.metadata).toHaveProperty("filepath", filepath)
expect(result.metadata).toHaveProperty("exists", true)
},
})
})
})
describe("file permissions", () => {
test("sets file permissions when writing sensitive data", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "sensitive.json")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: JSON.stringify({ secret: "data" }),
},
ctx,
)
// On Unix systems, check permissions
if (process.platform !== "win32") {
const stats = await fs.stat(filepath)
expect(stats.mode & 0o777).toBe(0o644)
}
},
})
})
})
describe("content types", () => {
test("writes JSON content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "data.json")
const data = { key: "value", nested: { array: [1, 2, 3] } }
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: JSON.stringify(data, null, 2),
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(JSON.parse(content)).toEqual(data)
},
})
})
test("writes binary-safe content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "binary.bin")
const content = "Hello\x00World\x01\x02\x03"
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content,
},
ctx,
)
const buf = await fs.readFile(filepath)
expect(buf.toString()).toBe(content)
},
})
})
test("writes empty content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "empty.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: "",
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("")
const stats = await fs.stat(filepath)
expect(stats.size).toBe(0)
},
})
})
test("writes multi-line content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "multiline.txt")
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content: lines,
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe(lines)
},
})
})
test("handles different line endings", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "crlf.txt")
const content = "Line 1\r\nLine 2\r\nLine 3"
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await write.execute(
{
filePath: filepath,
content,
},
ctx,
)
const buf = await fs.readFile(filepath)
expect(buf.toString()).toBe(content)
},
})
})
})
describe("error handling", () => {
test("throws error for paths outside project", async () => {
await using tmp = await tmpdir()
const outsidePath = "/etc/passwd"
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
await expect(
write.execute(
{
filePath: outsidePath,
content: "test",
},
ctx,
),
).rejects.toThrow()
},
})
})
})
describe("title generation", () => {
test("returns relative path as title", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
await fs.mkdir(path.dirname(filepath), { recursive: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const write = await WriteTool.init()
const result = await write.execute(
{
filePath: filepath,
content: "export const Button = () => {}",
},
ctx,
)
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
},
})
})
})
})
+274 -25
View File
@@ -1,39 +1,288 @@
import { describe, expect, test } from "bun:test" import { describe, test, expect } from "bun:test"
import os from "node:os" import path from "path"
import path from "node:path" import fs from "fs/promises"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
describe("util.filesystem", () => { describe("filesystem", () => {
test("exists() is true for files and directories", async () => { describe("exists()", () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-")) test("returns true for existing file", async () => {
const dir = path.join(tmp, "dir") await using tmp = await tmpdir()
const file = path.join(tmp, "file.txt") const filepath = path.join(tmp.path, "test.txt")
const missing = path.join(tmp, "missing") await fs.writeFile(filepath, "content", "utf-8")
await mkdir(dir, { recursive: true }) expect(await Filesystem.exists(filepath)).toBe(true)
await Bun.write(file, "hello") })
const cases = await Promise.all([Filesystem.exists(dir), Filesystem.exists(file), Filesystem.exists(missing)]) test("returns false for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.txt")
expect(cases).toEqual([true, true, false]) expect(await Filesystem.exists(filepath)).toBe(false)
})
await rm(tmp, { recursive: true, force: true }) test("returns true for existing directory", async () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "subdir")
await fs.mkdir(dirpath)
expect(await Filesystem.exists(dirpath)).toBe(true)
})
}) })
test("isDir() is true only for directories", async () => { describe("isDir()", () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-")) test("returns true for directory", async () => {
const dir = path.join(tmp, "dir") await using tmp = await tmpdir()
const file = path.join(tmp, "file.txt") const dirpath = path.join(tmp.path, "testdir")
const missing = path.join(tmp, "missing") await fs.mkdir(dirpath)
await mkdir(dir, { recursive: true }) expect(await Filesystem.isDir(dirpath)).toBe(true)
await Bun.write(file, "hello") })
const cases = await Promise.all([Filesystem.isDir(dir), Filesystem.isDir(file), Filesystem.isDir(missing)]) test("returns false for file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
await fs.writeFile(filepath, "content", "utf-8")
expect(cases).toEqual([true, false, false]) expect(await Filesystem.isDir(filepath)).toBe(false)
})
await rm(tmp, { recursive: true, force: true }) test("returns false for non-existent path", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist")
expect(await Filesystem.isDir(filepath)).toBe(false)
})
})
describe("size()", () => {
test("returns file size", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
const content = "Hello, World!"
await fs.writeFile(filepath, content, "utf-8")
expect(await Filesystem.size(filepath)).toBe(content.length)
})
test("returns 0 for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.txt")
expect(await Filesystem.size(filepath)).toBe(0)
})
test("returns directory size", async () => {
await using tmp = await tmpdir()
const dirpath = path.join(tmp.path, "testdir")
await fs.mkdir(dirpath)
// Directories have size on some systems
const size = await Filesystem.size(dirpath)
expect(typeof size).toBe("number")
})
})
describe("readText()", () => {
test("reads file content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
const content = "Hello, World!"
await fs.writeFile(filepath, content, "utf-8")
expect(await Filesystem.readText(filepath)).toBe(content)
})
test("throws for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.txt")
await expect(Filesystem.readText(filepath)).rejects.toThrow()
})
test("reads UTF-8 content correctly", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "unicode.txt")
const content = "Hello 世界 🌍"
await fs.writeFile(filepath, content, "utf-8")
expect(await Filesystem.readText(filepath)).toBe(content)
})
})
describe("readJson()", () => {
test("reads and parses JSON", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.json")
const data = { key: "value", nested: { array: [1, 2, 3] } }
await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
const result: typeof data = await Filesystem.readJson(filepath)
expect(result).toEqual(data)
})
test("throws for invalid JSON", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "invalid.json")
await fs.writeFile(filepath, "{ invalid json", "utf-8")
await expect(Filesystem.readJson(filepath)).rejects.toThrow()
})
test("throws for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.json")
await expect(Filesystem.readJson(filepath)).rejects.toThrow()
})
test("returns typed data", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "typed.json")
interface Config {
name: string
version: number
}
const data: Config = { name: "test", version: 1 }
await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
const result = await Filesystem.readJson<Config>(filepath)
expect(result.name).toBe("test")
expect(result.version).toBe(1)
})
})
describe("readBytes()", () => {
test("reads file as buffer", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
const content = "Hello, World!"
await fs.writeFile(filepath, content, "utf-8")
const buffer = await Filesystem.readBytes(filepath)
expect(buffer).toBeInstanceOf(Buffer)
expect(buffer.toString("utf-8")).toBe(content)
})
test("throws for non-existent file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "does-not-exist.bin")
await expect(Filesystem.readBytes(filepath)).rejects.toThrow()
})
})
describe("write()", () => {
test("writes text content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
const content = "Hello, World!"
await Filesystem.write(filepath, content)
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
test("writes buffer content", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.bin")
const content = Buffer.from([0x00, 0x01, 0x02, 0x03])
await Filesystem.write(filepath, content)
const read = await fs.readFile(filepath)
expect(read).toEqual(content)
})
test("writes with permissions", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "protected.txt")
const content = "secret"
await Filesystem.write(filepath, content, 0o600)
const stats = await fs.stat(filepath)
// Check permissions on Unix
if (process.platform !== "win32") {
expect(stats.mode & 0o777).toBe(0o600)
}
})
test("creates parent directories", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
const content = "nested content"
await Filesystem.write(filepath, content)
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
})
describe("writeJson()", () => {
test("writes JSON data", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "data.json")
const data = { key: "value", number: 42 }
await Filesystem.writeJson(filepath, data)
const content = await fs.readFile(filepath, "utf-8")
expect(JSON.parse(content)).toEqual(data)
})
test("writes formatted JSON", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "pretty.json")
const data = { key: "value" }
await Filesystem.writeJson(filepath, data)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toContain("\n")
expect(content).toContain(" ")
})
test("writes with permissions", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "config.json")
const data = { secret: "data" }
await Filesystem.writeJson(filepath, data, 0o600)
const stats = await fs.stat(filepath)
if (process.platform !== "win32") {
expect(stats.mode & 0o777).toBe(0o600)
}
})
})
describe("mimeType()", () => {
test("returns correct MIME type for JSON", () => {
expect(Filesystem.mimeType("test.json")).toContain("application/json")
})
test("returns correct MIME type for JavaScript", () => {
expect(Filesystem.mimeType("test.js")).toContain("javascript")
})
test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
const mime = Filesystem.mimeType("test.ts")
// .ts is ambiguous: TypeScript vs MPEG-2 TS video
expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
})
test("returns correct MIME type for images", () => {
expect(Filesystem.mimeType("test.png")).toContain("image/png")
expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
})
test("returns default for unknown extension", () => {
expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
})
test("handles files without extension", () => {
expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
})
}) })
}) })
+12 -2
View File
@@ -12,8 +12,18 @@
".": "./src/index.ts", ".": "./src/index.ts",
"./client": "./src/client.ts", "./client": "./src/client.ts",
"./server": "./src/server.ts", "./server": "./src/server.ts",
"./v2": "./src/v2/index.ts", "./v2": {
"./v2/client": "./src/v2/client.ts", "types": "./dist/src/v2/index.d.ts",
"default": "./src/v2/index.ts"
},
"./v2/client": {
"types": "./dist/src/v2/client.d.ts",
"default": "./src/v2/client.ts"
},
"./v2/gen/client": {
"types": "./dist/src/v2/gen/client/index.d.ts",
"default": "./src/v2/gen/client/index.ts"
},
"./v2/server": "./src/v2/server.ts" "./v2/server": "./src/v2/server.ts"
}, },
"files": [ "files": [
+3 -3
View File
@@ -6,8 +6,8 @@
"module": "nodenext", "module": "nodenext",
"declaration": true, "declaration": true,
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"] "lib": ["es2022", "dom", "dom.iterable"],
"composite": true
}, },
"include": ["src"], "include": ["src"]
"exclude": ["src/gen"]
} }
+38 -2
View File
@@ -1,4 +1,4 @@
import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { Collapsible } from "./collapsible" import { Collapsible } from "./collapsible"
import type { IconProps } from "./icon" import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer" import { TextShimmer } from "./text-shimmer"
@@ -27,18 +27,52 @@ export interface BasicToolProps {
hideDetails?: boolean hideDetails?: boolean
defaultOpen?: boolean defaultOpen?: boolean
forceOpen?: boolean forceOpen?: boolean
defer?: boolean
locked?: boolean locked?: boolean
onSubtitleClick?: () => void onSubtitleClick?: () => void
} }
export function BasicTool(props: BasicToolProps) { export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [ready, setReady] = createSignal(open())
const pending = () => props.status === "pending" || props.status === "running" const pending = () => props.status === "pending" || props.status === "running"
let frame: number | undefined
const cancel = () => {
if (frame === undefined) return
cancelAnimationFrame(frame)
frame = undefined
}
onCleanup(cancel)
createEffect(() => { createEffect(() => {
if (props.forceOpen) setOpen(true) if (props.forceOpen) setOpen(true)
}) })
createEffect(
on(
open,
(value) => {
if (!props.defer) return
if (!value) {
cancel()
setReady(false)
return
}
cancel()
frame = requestAnimationFrame(() => {
frame = undefined
if (!open()) return
setReady(true)
})
},
{ defer: true },
),
)
const handleOpenChange = (value: boolean) => { const handleOpenChange = (value: boolean) => {
if (pending()) return if (pending()) return
if (props.locked && !value) return if (props.locked && !value) return
@@ -114,7 +148,9 @@ export function BasicTool(props: BasicToolProps) {
</div> </div>
</Collapsible.Trigger> </Collapsible.Trigger>
<Show when={props.children && !props.hideDetails}> <Show when={props.children && !props.hideDetails}>
<Collapsible.Content>{props.children}</Collapsible.Content> <Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>
</Show> </Show>
</Collapsible> </Collapsible>
) )
-12
View File
@@ -23,15 +23,3 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
} }
[data-component="filetree"] .filetree-iconpair .filetree-icon--color {
opacity: 0;
}
[data-component="filetree"]:hover .filetree-iconpair .filetree-icon--color {
opacity: 1;
}
[data-component="filetree"]:hover .filetree-iconpair .filetree-icon--mono {
opacity: 0;
}
+56 -49
View File
@@ -326,8 +326,7 @@
} }
[data-slot="collapsible-content"]:has([data-component="edit-content"]), [data-slot="collapsible-content"]:has([data-component="edit-content"]),
[data-slot="collapsible-content"]:has([data-component="write-content"]), [data-slot="collapsible-content"]:has([data-component="write-content"]) {
[data-slot="collapsible-content"]:has([data-component="apply-patch-files"]) {
border: 1px solid var(--border-weak-base); border: 1px solid var(--border-weak-base);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
@@ -1219,64 +1218,72 @@
} }
} }
[data-component="apply-patch-files"] { [data-component="accordion"][data-scope="apply-patch"] {
display: flex; [data-slot="accordion-trigger"] {
flex-direction: column; background-color: var(--background-stronger) !important;
}
[data-component="apply-patch-file"] {
display: flex;
flex-direction: column;
[data-slot="apply-patch-file-header"] {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: transparent;
} }
[data-slot="apply-patch-file-action"] { [data-slot="apply-patch-trigger-content"] {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
}
[data-slot="apply-patch-file-info"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
[data-slot="apply-patch-file-name-container"] {
display: flex;
flex-grow: 1;
min-width: 0;
}
[data-slot="apply-patch-directory"] {
color: var(--text-base);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="apply-patch-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="apply-patch-trigger-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
[data-slot="apply-patch-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);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-base);
flex-shrink: 0;
&[data-type="delete"] {
color: var(--text-critical-base);
}
&[data-type="add"] {
color: var(--text-success-base);
}
&[data-type="move"] {
color: var(--text-warning-base);
}
} }
[data-slot="apply-patch-file-path"] { [data-slot="apply-patch-change"][data-type="added"] {
font-family: var(--font-family-mono); color: var(--icon-diff-add-base);
font-size: var(--font-size-small);
color: var(--text-weak);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-grow: 1;
} }
[data-slot="apply-patch-deletion-count"] { [data-slot="apply-patch-change"][data-type="removed"] {
font-family: var(--font-family-mono); color: var(--icon-diff-delete-base);
font-size: var(--font-size-small);
color: var(--text-critical-base);
flex-shrink: 0;
} }
}
[data-component="apply-patch-file"] + [data-component="apply-patch-file"] { [data-slot="apply-patch-change"][data-type="modified"] {
border-top: 1px solid var(--border-weaker-base); color: var(--icon-diff-modified-base);
}
} }
[data-component="apply-patch-file-diff"] { [data-component="apply-patch-file-diff"] {
+97 -65
View File
@@ -35,9 +35,11 @@ import { useDialog } from "../context/dialog"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { BasicTool } from "./basic-tool" import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool" import { GenericTool } from "./basic-tool"
import { Accordion } from "./accordion"
import { Button } from "./button" import { Button } from "./button"
import { Card } from "./card" import { Card } from "./card"
import { Collapsible } from "./collapsible" import { Collapsible } from "./collapsible"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon" import { Icon } from "./icon"
import { Checkbox } from "./checkbox" import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes" import { DiffChanges } from "./diff-changes"
@@ -670,13 +672,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
const provider = createMemo(() => {
const id = props.message.model?.providerID
if (!id) return ""
const match = data.store.provider?.all?.find((p) => p.id === id)
return match?.name ?? id
})
const model = createMemo(() => { const model = createMemo(() => {
const providerID = props.message.model?.providerID const providerID = props.message.model?.providerID
const modelID = props.message.model?.modelID const modelID = props.message.model?.modelID
@@ -697,7 +692,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const metaHead = createMemo(() => { const metaHead = createMemo(() => {
const agent = props.message.agent const agent = props.message.agent
const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", provider(), model()] const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
}) })
@@ -1053,13 +1048,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
) )
const provider = createMemo(() => {
if (props.message.role !== "assistant") return ""
const id = (props.message as AssistantMessage).providerID
const match = data.store.provider?.all?.find((p) => p.id === id)
return match?.name ?? id
})
const model = createMemo(() => { const model = createMemo(() => {
if (props.message.role !== "assistant") return "" if (props.message.role !== "assistant") return ""
const message = props.message as AssistantMessage const message = props.message as AssistantMessage
@@ -1074,9 +1062,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
if (typeof completed !== "number") return "" if (typeof completed !== "number") return ""
const ms = completed - message.time.created const ms = completed - message.time.created
if (!(ms >= 0)) return "" if (!(ms >= 0)) return ""
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` const total = Math.round(ms / 1000)
const minutes = Math.floor(ms / 60_000) if (total < 60) return `${total}s`
const seconds = Math.round((ms - minutes * 60_000) / 1000) const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${minutes}m ${seconds}s` return `${minutes}m ${seconds}s`
}) })
@@ -1085,7 +1074,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const agent = (props.message as AssistantMessage).agent const agent = (props.message as AssistantMessage).agent
const items = [ const items = [
agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
provider(),
model(), model(),
duration(), duration(),
interrupted() ? i18n.t("ui.message.interrupted") : "", interrupted() ? i18n.t("ui.message.interrupted") : "",
@@ -1482,6 +1470,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer
trigger={ trigger={
<div data-component="edit-trigger"> <div data-component="edit-trigger">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title-area">
@@ -1542,6 +1531,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer
trigger={ trigger={
<div data-component="write-trigger"> <div data-component="write-trigger">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title-area">
@@ -1602,6 +1592,16 @@ ToolRegistry.register({
const i18n = useI18n() const i18n = useI18n()
const diffComponent = useDiffComponent() const diffComponent = useDiffComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
const [expanded, setExpanded] = createSignal<string[]>([])
let seeded = false
createEffect(() => {
const list = files()
if (list.length === 0) return
if (seeded) return
seeded = true
setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath))
})
const subtitle = createMemo(() => { const subtitle = createMemo(() => {
const count = files().length const count = files().length
@@ -1613,60 +1613,92 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer
trigger={{ trigger={{
title: i18n.t("ui.tool.patch"), title: i18n.t("ui.tool.patch"),
subtitle: subtitle(), subtitle: subtitle(),
}} }}
> >
<Show when={files().length > 0}> <Show when={files().length > 0}>
<div data-component="apply-patch-files"> <Accordion
multiple
data-scope="apply-patch"
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}> <For each={files()}>
{(file) => ( {(file) => {
<div data-component="apply-patch-file"> const active = createMemo(() => expanded().includes(file.filePath))
<div data-slot="apply-patch-file-header"> const [visible, setVisible] = createSignal(false)
<Switch>
<Match when={file.type === "delete"}> createEffect(() => {
<span data-slot="apply-patch-file-action" data-type="delete"> if (!active()) {
{i18n.t("ui.patch.action.deleted")} setVisible(false)
</span> return
</Match> }
<Match when={file.type === "add"}>
<span data-slot="apply-patch-file-action" data-type="add"> requestAnimationFrame(() => {
{i18n.t("ui.patch.action.created")} if (!active()) return
</span> setVisible(true)
</Match> })
<Match when={file.type === "move"}> })
<span data-slot="apply-patch-file-action" data-type="move">
{i18n.t("ui.patch.action.moved")} return (
</span> <Accordion.Item value={file.filePath} data-type={file.type}>
</Match> <Accordion.Header>
<Match when={file.type === "update"}> <Accordion.Trigger>
<span data-slot="apply-patch-file-action" data-type="update"> <div data-slot="apply-patch-trigger-content">
{i18n.t("ui.patch.action.patched")} <div data-slot="apply-patch-file-info">
</span> <FileIcon node={{ path: file.relativePath, type: "file" }} />
</Match> <div data-slot="apply-patch-file-name-container">
</Switch> <Show when={file.relativePath.includes("/")}>
<span data-slot="apply-patch-file-path">{file.relativePath}</span> <span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
<Show when={file.type !== "delete"}> </Show>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} /> <span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
</Show> </div>
<Show when={file.type === "delete"}> </div>
<span data-slot="apply-patch-deletion-count">-{file.deletions}</span> <div data-slot="apply-patch-trigger-actions">
</Show> <Switch>
</div> <Match when={file.type === "add"}>
<Show when={file.type !== "delete"}> <span data-slot="apply-patch-change" data-type="added">
<div data-component="apply-patch-file-diff"> {i18n.t("ui.patch.action.created")}
<Dynamic </span>
component={diffComponent} </Match>
before={{ name: file.filePath, contents: file.before }} <Match when={file.type === "delete"}>
after={{ name: file.filePath, contents: file.after }} <span data-slot="apply-patch-change" data-type="removed">
/> {i18n.t("ui.patch.action.deleted")}
</div> </span>
</Show> </Match>
</div> <Match when={file.type === "move"}>
)} <span data-slot="apply-patch-change" data-type="modified">
{i18n.t("ui.patch.action.moved")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Show when={visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={diffComponent}
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For> </For>
</div> </Accordion>
</Show> </Show>
</BasicTool> </BasicTool>
) )
+7 -28
View File
@@ -17,25 +17,7 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs" import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
const MAX_DIFF_LINES = 20_000 const MAX_DIFF_CHANGED_LINES = 500
const MAX_DIFF_BYTES = 2_000_000
function linesOver(text: string, max: number) {
let lines = 1
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) !== 10) continue
lines++
if (lines > max) return true
}
return lines > max
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round((bytes / 1024) * 10) / 10} KB`
return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`
}
export type SessionReviewDiffStyle = "unified" | "split" export type SessionReviewDiffStyle = "unified" | "split"
@@ -354,18 +336,13 @@ export const SessionReview = (props: SessionReviewProps) => {
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 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
const before = beforeText()
const after = afterText()
if (before.length > MAX_DIFF_BYTES || after.length > MAX_DIFF_BYTES) return true
if (linesOver(before, MAX_DIFF_LINES) || linesOver(after, MAX_DIFF_LINES)) return true
return false
}) })
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0) const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
@@ -636,8 +613,10 @@ export const SessionReview = (props: SessionReviewProps) => {
{i18n.t("ui.sessionReview.largeDiff.title")} {i18n.t("ui.sessionReview.largeDiff.title")}
</div> </div>
<div data-slot="session-review-large-diff-meta"> <div data-slot="session-review-large-diff-meta">
Limit: {MAX_DIFF_LINES.toLocaleString()} lines / {formatBytes(MAX_DIFF_BYTES)}. {i18n.t("ui.sessionReview.largeDiff.meta", {
Current: {formatBytes(Math.max(beforeText().length, afterText().length))}. limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
current: changedLines().toLocaleString(),
})}
</div> </div>
<div data-slot="session-review-large-diff-actions"> <div data-slot="session-review-large-diff-actions">
<Button size="normal" variant="secondary" onClick={() => setForce(true)}> <Button size="normal" variant="secondary" onClick={() => setForce(true)}>
+30 -9
View File
@@ -130,19 +130,13 @@
gap: 12px; gap: 12px;
} }
[data-component="session-turn-diff"] { [data-slot="session-turn-diff-trigger"] {
border: 1px solid var(--border-weaker-base);
border-radius: var(--radius-md);
overflow: clip;
}
[data-slot="session-turn-diff-header"] {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 6px 10px; width: 100%;
border-bottom: 1px solid var(--border-weaker-base); min-width: 0;
} }
[data-slot="session-turn-diff-path"] { [data-slot="session-turn-diff-path"] {
@@ -166,9 +160,36 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
[data-slot="session-turn-diff-meta"] {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 10px;
}
[data-slot="session-turn-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-turn-diff-chevron"] {
transform: rotate(0deg);
}
[data-slot="session-turn-diff-view"] { [data-slot="session-turn-diff-view"] {
background-color: var(--surface-inset-base); background-color: var(--surface-inset-base);
width: 100%; width: 100%;
min-width: 0; min-width: 0;
max-height: 420px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-slot="session-turn-diff-view"]::-webkit-scrollbar {
display: none;
} }
} }
+85 -26
View File
@@ -4,12 +4,14 @@ import { useDiffComponent } from "../context/diff"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createMemo, createSignal, For, ParentProps, Show } from "solid-js" import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { AssistantParts, Message } from "./message-part" import { AssistantParts, Message } from "./message-part"
import { Card } from "./card" import { Card } from "./card"
import { Accordion } from "./accordion"
import { Collapsible } from "./collapsible" import { Collapsible } from "./collapsible"
import { DiffChanges } from "./diff-changes" import { DiffChanges } from "./diff-changes"
import { Icon } from "./icon"
import { TextShimmer } from "./text-shimmer" import { TextShimmer } from "./text-shimmer"
import { createAutoScroll } from "../hooks" import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
@@ -175,6 +177,17 @@ export function SessionTurn(
}) })
const edited = createMemo(() => diffs().length) const edited = createMemo(() => diffs().length)
const [open, setOpen] = createSignal(false) const [open, setOpen] = createSignal(false)
const [expanded, setExpanded] = createSignal<string[]>([])
createEffect(
on(
open,
(value, prev) => {
if (!value && prev) setExpanded([])
},
{ defer: true },
),
)
const assistantMessages = createMemo( const assistantMessages = createMemo(
() => { () => {
@@ -280,7 +293,7 @@ export function SessionTurn(
/> />
</div> </div>
</Show> </Show>
<Show when={edited() > 0}> <Show when={edited() > 0 && !working()}>
<div data-slot="session-turn-diffs"> <div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost"> <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger> <Collapsible.Trigger>
@@ -302,30 +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">
<For each={diffs()}> <Accordion
{(diff) => ( multiple
<div data-component="session-turn-diff"> value={expanded()}
<div data-slot="session-turn-diff-header"> onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
<span data-slot="session-turn-diff-path"> >
<Show when={diff.file.includes("/")}> <For each={diffs()}>
<span data-slot="session-turn-diff-directory">{getDirectory(diff.file)}</span> {(diff) => {
</Show> const active = createMemo(() => expanded().includes(diff.file))
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span> const [visible, setVisible] = createSignal(false)
</span>
<span data-slot="session-turn-diff-changes"> createEffect(
<DiffChanges changes={diff} /> on(
</span> active,
</div> (value) => {
<div data-slot="session-turn-diff-view"> if (!value) {
<Dynamic setVisible(false)
component={diffComponent} return
before={{ name: diff.file, contents: diff.before }} }
after={{ name: diff.file, contents: diff.after }}
/> requestAnimationFrame(() => {
</div> if (!active()) return
</div> setVisible(true)
)} })
</For> },
{ defer: true },
),
)
return (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">
{getDirectory(diff.file)}
</span>
</Show>
<span data-slot="session-turn-diff-filename">
{getFilename(diff.file)}
</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={diffComponent}
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div> </div>
</Show> </Show>
</Collapsible.Content> </Collapsible.Content>
+1 -1
View File
@@ -11,7 +11,7 @@ export const dict = {
"ui.sessionReview.image.loading": "جار التحميل...", "ui.sessionReview.image.loading": "جار التحميل...",
"ui.sessionReview.image.placeholder": "صورة", "ui.sessionReview.image.placeholder": "صورة",
"ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه", "ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه",
"ui.sessionReview.largeDiff.meta": "الحد: {{lines}} سطر / {{limit}}. الحالي: {{current}}.", "ui.sessionReview.largeDiff.meta": "الحد: {{limit}} سطرًا متغيرًا. الحالي: {{current}} سطرًا متغيرًا.",
"ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال", "ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال",
"ui.lineComment.label.prefix": "تعليق على ", "ui.lineComment.label.prefix": "تعليق على ",
+1 -1
View File
@@ -11,7 +11,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Carregando...", "ui.sessionReview.image.loading": "Carregando...",
"ui.sessionReview.image.placeholder": "Imagem", "ui.sessionReview.image.placeholder": "Imagem",
"ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar", "ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar",
"ui.sessionReview.largeDiff.meta": "Limite: {{lines}} linhas / {{limit}}. Atual: {{current}}.", "ui.sessionReview.largeDiff.meta": "Limite: {{limit}} linhas alteradas. Atual: {{current}} linhas alteradas.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim", "ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim",
"ui.lineComment.label.prefix": "Comentar em ", "ui.lineComment.label.prefix": "Comentar em ",
+1 -1
View File
@@ -15,7 +15,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Učitavanje...", "ui.sessionReview.image.loading": "Učitavanje...",
"ui.sessionReview.image.placeholder": "Slika", "ui.sessionReview.image.placeholder": "Slika",
"ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz", "ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz",
"ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linija / {{limit}}. Trenutno: {{current}}.", "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} izmijenjenih linija. Trenutno: {{current}} izmijenjenih linija.",
"ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno", "ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno",
"ui.lineComment.label.prefix": "Komentar na ", "ui.lineComment.label.prefix": "Komentar na ",
+1 -1
View File
@@ -12,7 +12,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Indlæser...", "ui.sessionReview.image.loading": "Indlæser...",
"ui.sessionReview.image.placeholder": "Billede", "ui.sessionReview.image.placeholder": "Billede",
"ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist", "ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist",
"ui.sessionReview.largeDiff.meta": "Grænse: {{lines}} linjer / {{limit}}. Nuværende: {{current}}.", "ui.sessionReview.largeDiff.meta": "Grænse: {{limit}} ændrede linjer. Nuværende: {{current}} ændrede linjer.",
"ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel", "ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel",
"ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
+1 -1
View File
@@ -16,7 +16,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Wird geladen...", "ui.sessionReview.image.loading": "Wird geladen...",
"ui.sessionReview.image.placeholder": "Bild", "ui.sessionReview.image.placeholder": "Bild",
"ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern", "ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern",
"ui.sessionReview.largeDiff.meta": "Limit: {{lines}} Zeilen / {{limit}}. Aktuell: {{current}}.", "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} geänderte Zeilen. Aktuell: {{current}} geänderte Zeilen.",
"ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern", "ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern",
"ui.lineComment.label.prefix": "Kommentar zu ", "ui.lineComment.label.prefix": "Kommentar zu ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
+1 -1
View File
@@ -11,7 +11,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Loading...", "ui.sessionReview.image.loading": "Loading...",
"ui.sessionReview.image.placeholder": "Image", "ui.sessionReview.image.placeholder": "Image",
"ui.sessionReview.largeDiff.title": "Diff too large to render", "ui.sessionReview.largeDiff.title": "Diff too large to render",
"ui.sessionReview.largeDiff.meta": "Limit: {{lines}} lines / {{limit}}. Current: {{current}}.", "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.",
"ui.sessionReview.largeDiff.renderAnyway": "Render anyway", "ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
"ui.lineComment.label.prefix": "Comment on ", "ui.lineComment.label.prefix": "Comment on ",
+1 -1
View File
@@ -11,7 +11,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Cargando...", "ui.sessionReview.image.loading": "Cargando...",
"ui.sessionReview.image.placeholder": "Imagen", "ui.sessionReview.image.placeholder": "Imagen",
"ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar", "ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar",
"ui.sessionReview.largeDiff.meta": "Límite: {{lines}} líneas / {{limit}}. Actual: {{current}}.", "ui.sessionReview.largeDiff.meta": "Límite: {{limit}} líneas modificadas. Actual: {{current}} líneas modificadas.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos", "ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos",
"ui.lineComment.label.prefix": "Comentar en ", "ui.lineComment.label.prefix": "Comentar en ",
+1 -1
View File
@@ -11,7 +11,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Chargement...", "ui.sessionReview.image.loading": "Chargement...",
"ui.sessionReview.image.placeholder": "Image", "ui.sessionReview.image.placeholder": "Image",
"ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché", "ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché",
"ui.sessionReview.largeDiff.meta": "Limite : {{lines}} lignes / {{limit}}. Actuel : {{current}}.", "ui.sessionReview.largeDiff.meta": "Limite : {{limit}} lignes modifiées. Actuel : {{current}} lignes modifiées.",
"ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même", "ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même",
"ui.lineComment.label.prefix": "Commenter sur ", "ui.lineComment.label.prefix": "Commenter sur ",
+1 -1
View File
@@ -12,7 +12,7 @@ export const dict = {
"ui.sessionReview.image.loading": "読み込み中...", "ui.sessionReview.image.loading": "読み込み中...",
"ui.sessionReview.image.placeholder": "画像", "ui.sessionReview.image.placeholder": "画像",
"ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません", "ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません",
"ui.sessionReview.largeDiff.meta": "上限: {{lines}} 行 / {{limit}}。現在: {{current}}。", "ui.sessionReview.largeDiff.meta": "上限: {{limit}} 変更行。現在: {{current}} 変更行。",
"ui.sessionReview.largeDiff.renderAnyway": "それでも表示する", "ui.sessionReview.largeDiff.renderAnyway": "それでも表示する",
"ui.lineComment.label.prefix": "", "ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "へのコメント", "ui.lineComment.label.suffix": "へのコメント",
+1 -1
View File
@@ -11,7 +11,7 @@ export const dict = {
"ui.sessionReview.image.loading": "로딩 중...", "ui.sessionReview.image.loading": "로딩 중...",
"ui.sessionReview.image.placeholder": "이미지", "ui.sessionReview.image.placeholder": "이미지",
"ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다", "ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다",
"ui.sessionReview.largeDiff.meta": "제한: {{lines}}줄 / {{limit}}. 현재: {{current}}.", "ui.sessionReview.largeDiff.meta": "제한: {{limit}} 변경 줄. 현재: {{current}} 변경 줄.",
"ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링", "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링",
"ui.lineComment.label.prefix": "", "ui.lineComment.label.prefix": "",
+1 -1
View File
@@ -14,7 +14,7 @@ export const dict: Record<Keys, string> = {
"ui.sessionReview.image.loading": "Laster...", "ui.sessionReview.image.loading": "Laster...",
"ui.sessionReview.image.placeholder": "Bilde", "ui.sessionReview.image.placeholder": "Bilde",
"ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi", "ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
"ui.sessionReview.largeDiff.meta": "Grense: {{lines}} linjer / {{limit}}. Nåværende: {{current}}.", "ui.sessionReview.largeDiff.meta": "Grense: {{limit}} endrede linjer. Nåværende: {{current}} endrede linjer.",
"ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel", "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel",
"ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.prefix": "Kommenter på ",
+1 -1
View File
@@ -12,7 +12,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Ładowanie...", "ui.sessionReview.image.loading": "Ładowanie...",
"ui.sessionReview.image.placeholder": "Obraz", "ui.sessionReview.image.placeholder": "Obraz",
"ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować", "ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować",
"ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linii / {{limit}}. Obecnie: {{current}}.", "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} zmienionych linii. Obecnie: {{current}} zmienionych linii.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to", "ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to",
"ui.lineComment.label.prefix": "Komentarz do ", "ui.lineComment.label.prefix": "Komentarz do ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
+1 -1
View File
@@ -12,7 +12,7 @@ export const dict = {
"ui.sessionReview.image.loading": "Загрузка...", "ui.sessionReview.image.loading": "Загрузка...",
"ui.sessionReview.image.placeholder": "Изображение", "ui.sessionReview.image.placeholder": "Изображение",
"ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения", "ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения",
"ui.sessionReview.largeDiff.meta": "Лимит: {{lines}} строк / {{limit}}. Текущий: {{current}}.", "ui.sessionReview.largeDiff.meta": "Лимит: {{limit}} изменённых строк. Текущий: {{current}} изменённых строк.",
"ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно", "ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно",
"ui.lineComment.label.prefix": "Комментарий к ", "ui.lineComment.label.prefix": "Комментарий к ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
+2 -1
View File
@@ -11,7 +11,8 @@ export const dict = {
"ui.sessionReview.image.loading": "กำลังโหลด...", "ui.sessionReview.image.loading": "กำลังโหลด...",
"ui.sessionReview.image.placeholder": "รูปภาพ", "ui.sessionReview.image.placeholder": "รูปภาพ",
"ui.sessionReview.largeDiff.title": "Diff มีขนาดใหญ่เกินไปจนไม่สามารถแสดงผลได้", "ui.sessionReview.largeDiff.title": "Diff มีขนาดใหญ่เกินไปจนไม่สามารถแสดงผลได้",
"ui.sessionReview.largeDiff.meta": "ขีดจำกัด: {{lines}} บรรทัด / {{limit}}. ปัจจุบัน: {{current}}.", "ui.sessionReview.largeDiff.meta":
"ขีดจำกัด: {{limit}} บรรทัดที่เปลี่ยนแปลง. ปัจจุบัน: {{current}} บรรทัดที่เปลี่ยนแปลง.",
"ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป", "ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป",
"ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ", "ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
+1 -1
View File
@@ -15,7 +15,7 @@ export const dict = {
"ui.sessionReview.image.loading": "加载中...", "ui.sessionReview.image.loading": "加载中...",
"ui.sessionReview.image.placeholder": "图片", "ui.sessionReview.image.placeholder": "图片",
"ui.sessionReview.largeDiff.title": "差异过大,无法渲染", "ui.sessionReview.largeDiff.title": "差异过大,无法渲染",
"ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。当前:{{current}}。", "ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行变更。当前:{{current}} 行变更。",
"ui.sessionReview.largeDiff.renderAnyway": "仍然渲染", "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
"ui.lineComment.label.prefix": "评论 ", "ui.lineComment.label.prefix": "评论 ",
+1 -1
View File
@@ -15,7 +15,7 @@ export const dict = {
"ui.sessionReview.image.loading": "載入中...", "ui.sessionReview.image.loading": "載入中...",
"ui.sessionReview.image.placeholder": "圖片", "ui.sessionReview.image.placeholder": "圖片",
"ui.sessionReview.largeDiff.title": "差異過大,無法渲染", "ui.sessionReview.largeDiff.title": "差異過大,無法渲染",
"ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。目前:{{current}}。", "ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行變更。目前:{{current}} 行變更。",
"ui.sessionReview.largeDiff.renderAnyway": "仍然渲染", "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
"ui.lineComment.label.prefix": "評論 ", "ui.lineComment.label.prefix": "評論 ",
+1
View File
@@ -27,6 +27,7 @@ OpenCode comes with several built-in LSP servers for popular languages:
| gopls | .go | `go` command available | | gopls | .go | `go` command available |
| hls | .hs, .lhs | `haskell-language-server-wrapper` command available | | hls | .hs, .lhs | `haskell-language-server-wrapper` command available |
| jdtls | .java | `Java SDK (version 21+)` installed | | jdtls | .java | `Java SDK (version 21+)` installed |
| julials | .jl | `julia` and `LanguageServer.jl` installed |
| kotlin-ls | .kt, .kts | Auto-installs for Kotlin projects | | kotlin-ls | .kt, .kts | Auto-installs for Kotlin projects |
| lua-ls | .lua | Auto-installs for Lua projects | | lua-ls | .lua | Auto-installs for Lua projects |
| nixd | .nix | `nixd` command available | | nixd | .nix | `nixd` command available |
+8 -3
View File
@@ -128,10 +128,15 @@ async function main() {
await $`git fetch origin beta` await $`git fetch origin beta`
const localTree = await $`git rev-parse beta^{tree}`.text() const localTree = await $`git rev-parse beta^{tree}`.text()
const remoteTree = await $`git rev-parse origin/beta^{tree}`.text() const remoteTrees = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
if (localTree.trim() === remoteTree.trim()) { const matchIdx = remoteTrees.indexOf(localTree.trim())
console.log("Beta branch has identical contents, no push needed") if (matchIdx !== -1) {
if (matchIdx !== 0) {
console.log(`Beta branch contains this sync, but additional commits exist after it. Leaving beta branch as is.`)
} else {
console.log("Beta branch has identical contents, no push needed")
}
return return
} }