Compare commits
63
Commits
temp
..
migrate-mcp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9291fb7ffe | ||
|
|
3d189b42a3 | ||
|
|
91a3ee642d | ||
|
|
37b24f4870 | ||
|
|
38572b8175 | ||
|
|
fc1addb8f4 | ||
|
|
83b7d8e04c | ||
|
|
d27dbfe062 | ||
|
|
ad30e5b378 | ||
|
|
f8904e3972 | ||
|
|
4a5823562c | ||
|
|
3aaf29b693 | ||
|
|
6b29896a35 | ||
|
|
1bb8574179 | ||
|
|
2611c35acc | ||
|
|
00c238777a | ||
|
|
e4b548fa76 | ||
|
|
e132dd2c70 | ||
|
|
fbe9669c57 | ||
|
|
c34ad7223a | ||
|
|
cc86a64bb5 | ||
|
|
3394402aef | ||
|
|
6cd3a59022 | ||
|
|
5aeb305344 | ||
|
|
6eb043aedb | ||
|
|
e96f6385c2 | ||
|
|
1109a282e0 | ||
|
|
25f3eef957 | ||
|
|
0ca75544ab | ||
|
|
572a037e5d | ||
|
|
ad92181fa7 | ||
|
|
c56f4aa5d8 | ||
|
|
a344a766fd | ||
|
|
bca793d064 | ||
|
|
ad3c192837 | ||
|
|
5512231ca8 | ||
|
|
bad394cd49 | ||
|
|
3b97580621 | ||
|
|
cb88fe26aa | ||
|
|
e345b89ce5 | ||
|
|
26c7b240ba | ||
|
|
d327a2b1cf | ||
|
|
c1b03b728a | ||
|
|
2a2437bf22 | ||
|
|
4ccb82e81a | ||
|
|
92912219df | ||
|
|
bab3124e8b | ||
|
|
7a66ec6bc9 | ||
|
|
3a505b2691 | ||
|
|
20f43372f6 | ||
|
|
fb79dd7bf8 | ||
|
|
4025b655a4 | ||
|
|
7379903568 | ||
|
|
a685e7a805 | ||
|
|
ce7484b4f5 | ||
|
|
0bc1dcbe1b | ||
|
|
a69b339baf | ||
|
|
26f835cdd2 | ||
|
|
bd3d1413fd | ||
|
|
2c17a980ff | ||
|
|
b784c923a8 | ||
|
|
ea96f898c0 | ||
|
|
47435f6e17 |
@@ -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
|
|
||||||
@@ -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 ==="
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ Examples:
|
|||||||
- Provider integration issues
|
- Provider integration issues
|
||||||
- New, broken, or poor-quality models
|
- New, broken, or poor-quality models
|
||||||
|
|
||||||
|
#### acp
|
||||||
|
|
||||||
|
If the issue mentions acp support, assign acp label.
|
||||||
|
|
||||||
#### docs
|
#### docs
|
||||||
|
|
||||||
Add if the issue requests better documentation or docs updates.
|
Add if the issue requests better documentation or docs updates.
|
||||||
@@ -130,3 +134,7 @@ Determinism rules:
|
|||||||
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
|
||||||
|
|
||||||
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||||
|
|
||||||
|
ACP:
|
||||||
|
|
||||||
|
- rekram1-node (assign any acp issues to rekram1-node)
|
||||||
|
|||||||
@@ -68,17 +68,7 @@ export default tool({
|
|||||||
results.push("Dropped label: nix (issue does not mention nix)")
|
results.push("Dropped label: nix (issue does not mention nix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignee = nix
|
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||||
? "rekram1-node"
|
|
||||||
: web
|
|
||||||
? pick(TEAM.desktop)
|
|
||||||
: args.assignee === "jlongster"
|
|
||||||
? "thdxr"
|
|
||||||
: args.assignee
|
|
||||||
|
|
||||||
if (args.assignee === "jlongster" && assignee === "thdxr") {
|
|
||||||
results.push("Remapped assignee: jlongster -> thdxr (jlongster not assignable yet)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (labels.includes("zen") && !zen) {
|
if (labels.includes("zen") && !zen) {
|
||||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"format_on_save": "on",
|
||||||
|
"formatter": {
|
||||||
|
"external": {
|
||||||
|
"command": "bunx",
|
||||||
|
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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+
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -71,13 +71,13 @@ const kindLabel = (kind: Kind) => {
|
|||||||
const kindTextColor = (kind: Kind) => {
|
const kindTextColor = (kind: Kind) => {
|
||||||
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||||
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||||
return "color: var(--icon-warning-active)"
|
return "color: var(--icon-diff-modified-base)"
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindDotColor = (kind: Kind) => {
|
const kindDotColor = (kind: Kind) => {
|
||||||
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||||
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||||
return "background-color: var(--icon-warning-active)"
|
return "background-color: var(--icon-diff-modified-base)"
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||||
@@ -447,12 +447,13 @@ export default function FileTree(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||||
<For each={nodes()}>
|
<For each={nodes()}>
|
||||||
{(node) => {
|
{(node) => {
|
||||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||||
const deep = () => deeps().get(node.path) ?? -1
|
const deep = () => deeps().get(node.path) ?? -1
|
||||||
const kind = () => visibleKind(node, kinds(), marks())
|
const kind = () => visibleKind(node, kinds(), marks())
|
||||||
|
const active = () => !!kind() && !node.ignored
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -530,7 +531,37 @@ export default function FileTree(props: {
|
|||||||
onClick={() => props.onFileClick?.(node)}
|
onClick={() => props.onFileClick?.(node)}
|
||||||
>
|
>
|
||||||
<div class="w-4 shrink-0" />
|
<div class="w-4 shrink-0" />
|
||||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
<Switch>
|
||||||
|
<Match when={node.ignored}>
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono"
|
||||||
|
style="color: var(--icon-weak-base)"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={active()}>
|
||||||
|
<FileIcon
|
||||||
|
node={node}
|
||||||
|
class="size-4 filetree-icon filetree-icon--mono"
|
||||||
|
style={kindTextColor(kind()!)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
<Match when={!node.ignored}>
|
||||||
|
<span class="filetree-iconpair size-4">
|
||||||
|
<FileIcon
|
||||||
|
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>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</FileTreeNode>
|
</FileTreeNode>
|
||||||
</FileTreeNodeTooltip>
|
</FileTreeNodeTooltip>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -109,6 +109,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
let slashPopoverRef!: HTMLDivElement
|
let slashPopoverRef!: HTMLDivElement
|
||||||
|
|
||||||
const mirror = { input: false }
|
const mirror = { input: false }
|
||||||
|
const inset = 44
|
||||||
|
|
||||||
const scrollCursorIntoView = () => {
|
const scrollCursorIntoView = () => {
|
||||||
const container = scrollRef
|
const container = scrollRef
|
||||||
@@ -118,7 +119,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
if (!editorRef.contains(range.startContainer)) return
|
if (!editorRef.contains(range.startContainer)) return
|
||||||
|
|
||||||
const rect = range.getBoundingClientRect()
|
const cursor = getCursorPosition(editorRef)
|
||||||
|
const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
|
||||||
|
if (cursor >= length) {
|
||||||
|
container.scrollTop = container.scrollHeight
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
|
||||||
if (!rect.height) return
|
if (!rect.height) return
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect()
|
const containerRect = container.getBoundingClientRect()
|
||||||
@@ -131,8 +139,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bottom > container.scrollTop + container.clientHeight - padding) {
|
if (bottom > container.scrollTop + container.clientHeight - inset) {
|
||||||
container.scrollTop = bottom - container.clientHeight + padding
|
container.scrollTop = bottom - container.clientHeight + inset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +250,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return messages.some((m) => m.role === "user")
|
return messages.some((m) => m.role === "user")
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_HISTORY = 100
|
|
||||||
const [history, setHistory] = persisted(
|
const [history, setHistory] = persisted(
|
||||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
@@ -312,6 +319,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
requestAnimationFrame(() => editorRef?.focus())
|
requestAnimationFrame(() => editorRef?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shellModeKey = "mod+shift+x"
|
||||||
|
const normalModeKey = "mod+shift+e"
|
||||||
|
|
||||||
command.register("prompt-input", () => [
|
command.register("prompt-input", () => [
|
||||||
{
|
{
|
||||||
id: "file.attach",
|
id: "file.attach",
|
||||||
@@ -321,6 +331,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
disabled: store.mode !== "normal",
|
disabled: store.mode !== "normal",
|
||||||
onSelect: pick,
|
onSelect: pick,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: shellModeKey,
|
||||||
|
disabled: store.mode === "shell",
|
||||||
|
onSelect: () => setMode("shell"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: normalModeKey,
|
||||||
|
disabled: store.mode === "normal",
|
||||||
|
onSelect: () => setMode("normal"),
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const closePopover = () => setStore("popover", null)
|
const closePopover = () => setStore("popover", null)
|
||||||
@@ -1060,8 +1086,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
|
class="relative"
|
||||||
ref={(el) => (scrollRef = el)}
|
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
const target = e.target
|
const target = e.target
|
||||||
if (!(target instanceof HTMLElement)) return
|
if (!(target instanceof HTMLElement)) return
|
||||||
@@ -1075,151 +1100,153 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
editorRef?.focus()
|
editorRef?.focus()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
||||||
data-component="prompt-input"
|
|
||||||
ref={(el) => {
|
|
||||||
editorRef = el
|
|
||||||
props.ref?.(el)
|
|
||||||
}}
|
|
||||||
role="textbox"
|
|
||||||
aria-multiline="true"
|
|
||||||
aria-label={placeholder()}
|
|
||||||
contenteditable="true"
|
|
||||||
autocapitalize="off"
|
|
||||||
autocorrect="off"
|
|
||||||
spellcheck={false}
|
|
||||||
onInput={handleInput}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
onCompositionStart={() => setComposing(true)}
|
|
||||||
onCompositionEnd={() => setComposing(false)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
classList={{
|
|
||||||
"select-text": true,
|
|
||||||
"w-full pl-3 pr-2 pt-2 pb-12 text-14-regular leading-[var(--line-height-large)] text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
|
||||||
"[&_[data-type=file]]:text-syntax-property": true,
|
|
||||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
|
||||||
"font-mono!": store.mode === "shell",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Show when={!prompt.dirty()}>
|
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-12 text-14-regular leading-[var(--line-height-large)] text-text-weak pointer-events-none whitespace-nowrap truncate"
|
data-component="prompt-input"
|
||||||
classList={{ "font-mono!": store.mode === "shell" }}
|
ref={(el) => {
|
||||||
|
editorRef = el
|
||||||
|
props.ref?.(el)
|
||||||
|
}}
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label={placeholder()}
|
||||||
|
contenteditable="true"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={handleInput}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onCompositionStart={() => setComposing(true)}
|
||||||
|
onCompositionEnd={() => setComposing(false)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
classList={{
|
||||||
|
"select-text": true,
|
||||||
|
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
|
"[&_[data-type=file]]:text-syntax-property": true,
|
||||||
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
|
"font-mono!": store.mode === "shell",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Show when={!prompt.dirty()}>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||||
|
classList={{ "font-mono!": store.mode === "shell" }}
|
||||||
|
>
|
||||||
|
{placeholder()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||||
|
class="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.currentTarget.files?.[0]
|
||||||
|
if (file) addImageAttachment(file)
|
||||||
|
e.currentTarget.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden={store.mode !== "normal"}
|
||||||
|
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
||||||
|
classList={{
|
||||||
|
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
||||||
|
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{placeholder()}
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
title={language.t("prompt.action.attachFile")}
|
||||||
|
keybind={command.keybind("file.attach")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-attach"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-8 p-0"
|
||||||
|
onClick={pick}
|
||||||
|
disabled={store.mode !== "normal"}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
aria-label={language.t("prompt.action.attachFile")}
|
||||||
|
>
|
||||||
|
<Icon name="plus" class="size-4.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
inactive={!prompt.dirty() && !working()}
|
||||||
|
value={
|
||||||
|
<Switch>
|
||||||
|
<Match when={working()}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
|
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
data-action="prompt-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
|
variant="primary"
|
||||||
|
class="size-8"
|
||||||
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
|
||||||
|
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||||
|
<div class="pointer-events-auto">
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
|
keybind={command.keybind("permissions.autoaccept")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-permissions"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||||
|
classList={{
|
||||||
|
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||||
|
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
|
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||||
|
}}
|
||||||
|
aria-label={
|
||||||
|
permission.isAutoAccepting(params.id!, sdk.directory)
|
||||||
|
? language.t("command.permissions.autoaccept.disable")
|
||||||
|
: language.t("command.permissions.autoaccept.enable")
|
||||||
|
}
|
||||||
|
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="chevron-double-right"
|
||||||
|
size="small"
|
||||||
|
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
|
||||||
class="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.currentTarget.files?.[0]
|
|
||||||
if (file) addImageAttachment(file)
|
|
||||||
e.currentTarget.value = ""
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
aria-hidden={store.mode !== "normal"}
|
|
||||||
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
|
||||||
classList={{
|
|
||||||
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
|
||||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
title={language.t("prompt.action.attachFile")}
|
|
||||||
keybind={command.keybind("file.attach")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
data-action="prompt-attach"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-8 p-0"
|
|
||||||
onClick={pick}
|
|
||||||
disabled={store.mode !== "normal"}
|
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
|
||||||
aria-label={language.t("prompt.action.attachFile")}
|
|
||||||
>
|
|
||||||
<Icon name="plus" class="size-4.5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
placement="top"
|
|
||||||
inactive={!prompt.dirty() && !working()}
|
|
||||||
value={
|
|
||||||
<Switch>
|
|
||||||
<Match when={working()}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
|
||||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
|
||||||
<Icon name="enter" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
data-action="prompt-submit"
|
|
||||||
type="submit"
|
|
||||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
|
||||||
variant="primary"
|
|
||||||
class="size-8"
|
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
|
|
||||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
|
||||||
<div class="pointer-events-auto">
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
data-action="prompt-permissions"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
|
||||||
classList={{
|
|
||||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
|
||||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
|
||||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
|
||||||
}}
|
|
||||||
aria-label={
|
|
||||||
permission.isAutoAccepting(params.id!, sdk.directory)
|
|
||||||
? language.t("command.permissions.autoaccept.disable")
|
|
||||||
: language.t("command.permissions.autoaccept.enable")
|
|
||||||
}
|
|
||||||
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name="chevron-double-right"
|
|
||||||
size="small"
|
|
||||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</form>
|
</form>
|
||||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||||
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
|
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
|
||||||
@@ -1331,29 +1358,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="shrink-0" data-component="prompt-mode-toggle">
|
||||||
<div class="shrink-0">
|
<RadioGroup
|
||||||
<div data-component="prompt-mode-toggle">
|
options={["shell", "normal"] as const}
|
||||||
<RadioGroup
|
current={store.mode}
|
||||||
options={["shell", "normal"] as const}
|
value={(mode) => mode}
|
||||||
current={store.mode}
|
label={(mode) => (
|
||||||
onSelect={(mode) => mode && setMode(mode)}
|
<TooltipKeybind
|
||||||
label={(mode) => (
|
placement="top"
|
||||||
<div class="flex size-full items-center justify-center">
|
gutter={4}
|
||||||
<Icon
|
title={language.t(mode === "shell" ? "command.prompt.mode.shell" : "command.prompt.mode.normal")}
|
||||||
name={mode === "shell" ? "console" : "prompt"}
|
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
||||||
class="size-[18px]"
|
class="size-full flex items-center justify-center"
|
||||||
classList={{
|
>
|
||||||
"text-icon-strong-base": mode === "shell" && store.mode === "shell",
|
<Icon
|
||||||
"text-icon-interactive-base": mode === "normal" && store.mode === "normal",
|
name={mode === "shell" ? "console" : "prompt"}
|
||||||
"text-icon-weak": store.mode !== mode,
|
class="size-[18px]"
|
||||||
}}
|
classList={{
|
||||||
/>
|
"text-icon-strong-base": store.mode === mode,
|
||||||
<span class="sr-only">{mode === "shell" ? language.t("prompt.mode.shell") : mode}</span>
|
"text-icon-weak": store.mode !== mode,
|
||||||
</div>
|
}}
|
||||||
)}
|
/>
|
||||||
/>
|
</TooltipKeybind>
|
||||||
</div>
|
)}
|
||||||
|
onSelect={(mode) => mode && setMode(mode)}
|
||||||
|
fill
|
||||||
|
pad="none"
|
||||||
|
class="w-[68px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
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 { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
@@ -232,9 +233,11 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-component="question-prompt" ref={(el) => (root = el)}>
|
<DockPrompt
|
||||||
<div data-slot="question-body">
|
kind="question"
|
||||||
<div data-slot="question-header">
|
ref={(el) => (root = el)}
|
||||||
|
header={
|
||||||
|
<>
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
<div data-slot="question-progress">
|
<div data-slot="question-progress">
|
||||||
<For each={questions()}>
|
<For each={questions()}>
|
||||||
@@ -254,172 +257,169 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
<div data-slot="question-content">
|
footer={
|
||||||
<div data-slot="question-text">{question()?.question}</div>
|
<>
|
||||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
||||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
{language.t("ui.common.dismiss")}
|
||||||
</Show>
|
</Button>
|
||||||
<div data-slot="question-options">
|
<div data-slot="question-footer-actions">
|
||||||
<For each={options()}>
|
<Show when={store.tab > 0}>
|
||||||
{(opt, i) => {
|
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
||||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
{language.t("ui.common.back")}
|
||||||
return (
|
</Button>
|
||||||
<button
|
</Show>
|
||||||
data-slot="question-option"
|
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
||||||
data-picked={picked()}
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
</Button>
|
||||||
aria-checked={picked()}
|
</div>
|
||||||
disabled={store.sending}
|
</>
|
||||||
onClick={() => selectOption(i())}
|
}
|
||||||
>
|
>
|
||||||
<span data-slot="question-option-check" aria-hidden="true">
|
<div data-slot="question-text">{question()?.question}</div>
|
||||||
<span
|
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||||
data-slot="question-option-box"
|
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||||
data-type={multi() ? "checkbox" : "radio"}
|
</Show>
|
||||||
data-picked={picked()}
|
<div data-slot="question-options">
|
||||||
>
|
<For each={options()}>
|
||||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
{(opt, i) => {
|
||||||
<Icon name="check-small" size="small" />
|
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||||
</Show>
|
return (
|
||||||
</span>
|
<button
|
||||||
</span>
|
|
||||||
<span data-slot="question-option-main">
|
|
||||||
<span data-slot="option-label">{opt.label}</span>
|
|
||||||
<Show when={opt.description}>
|
|
||||||
<span data-slot="option-description">{opt.description}</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={store.editing}
|
|
||||||
fallback={
|
|
||||||
<button
|
|
||||||
data-slot="question-option"
|
|
||||||
data-custom="true"
|
|
||||||
data-picked={on()}
|
|
||||||
role={multi() ? "checkbox" : "radio"}
|
|
||||||
aria-checked={on()}
|
|
||||||
disabled={store.sending}
|
|
||||||
onClick={customOpen}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-slot="question-option-check"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
customToggle()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
|
||||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
|
||||||
<Icon name="check-small" size="small" />
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span data-slot="question-option-main">
|
|
||||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
|
||||||
<span data-slot="option-description">
|
|
||||||
{input() || language.t("ui.question.custom.placeholder")}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-picked={picked()}
|
||||||
data-picked={on()}
|
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={picked()}
|
||||||
onMouseDown={(e) => {
|
disabled={store.sending}
|
||||||
if (store.sending) {
|
onClick={() => selectOption(i())}
|
||||||
e.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.target instanceof HTMLTextAreaElement) return
|
|
||||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
|
||||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
|
||||||
}}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
commitCustom()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span data-slot="question-option-check" aria-hidden="true">
|
||||||
data-slot="question-option-check"
|
<span
|
||||||
aria-hidden="true"
|
data-slot="question-option-box"
|
||||||
onClick={(e) => {
|
data-type={multi() ? "checkbox" : "radio"}
|
||||||
e.preventDefault()
|
data-picked={picked()}
|
||||||
e.stopPropagation()
|
>
|
||||||
customToggle()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
|
||||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
<Icon name="check-small" size="small" />
|
<Icon name="check-small" size="small" />
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span data-slot="question-option-main">
|
<span data-slot="question-option-main">
|
||||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
<span data-slot="option-label">{opt.label}</span>
|
||||||
<textarea
|
<Show when={opt.description}>
|
||||||
ref={(el) =>
|
<span data-slot="option-description">{opt.description}</span>
|
||||||
setTimeout(() => {
|
</Show>
|
||||||
el.focus()
|
|
||||||
el.style.height = "0px"
|
|
||||||
el.style.height = `${el.scrollHeight}px`
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
data-slot="question-custom-input"
|
|
||||||
placeholder={language.t("ui.question.custom.placeholder")}
|
|
||||||
value={input()}
|
|
||||||
rows={1}
|
|
||||||
disabled={store.sending}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
|
||||||
setStore("editing", false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key !== "Enter" || e.shiftKey) return
|
|
||||||
e.preventDefault()
|
|
||||||
commitCustom()
|
|
||||||
}}
|
|
||||||
onInput={(e) => {
|
|
||||||
customUpdate(e.currentTarget.value)
|
|
||||||
e.currentTarget.style.height = "0px"
|
|
||||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</form>
|
</button>
|
||||||
</Show>
|
)
|
||||||
</div>
|
}}
|
||||||
</div>
|
</For>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-slot="question-footer">
|
<Show
|
||||||
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
when={store.editing}
|
||||||
{language.t("ui.common.dismiss")}
|
fallback={
|
||||||
</Button>
|
<button
|
||||||
<div data-slot="question-footer-actions">
|
data-slot="question-option"
|
||||||
<Show when={store.tab > 0}>
|
data-custom="true"
|
||||||
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
data-picked={on()}
|
||||||
{language.t("ui.common.back")}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
</Button>
|
aria-checked={on()}
|
||||||
</Show>
|
disabled={store.sending}
|
||||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
onClick={customOpen}
|
||||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
>
|
||||||
</Button>
|
<span
|
||||||
</div>
|
data-slot="question-option-check"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
customToggle()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||||
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
|
<Icon name="check-small" size="small" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||||
|
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
data-slot="question-option"
|
||||||
|
data-custom="true"
|
||||||
|
data-picked={on()}
|
||||||
|
role={multi() ? "checkbox" : "radio"}
|
||||||
|
aria-checked={on()}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (store.sending) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.target instanceof HTMLTextAreaElement) return
|
||||||
|
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||||
|
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||||
|
}}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
commitCustom()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="question-option-check"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
customToggle()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||||
|
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
|
<Icon name="check-small" size="small" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||||
|
<textarea
|
||||||
|
ref={(el) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
el.focus()
|
||||||
|
el.style.height = "0px"
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
data-slot="question-custom-input"
|
||||||
|
placeholder={language.t("ui.question.custom.placeholder")}
|
||||||
|
value={input()}
|
||||||
|
rows={1}
|
||||||
|
disabled={store.sending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
setStore("editing", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
|
e.preventDefault()
|
||||||
|
commitCustom()
|
||||||
|
}}
|
||||||
|
onInput={(e) => {
|
||||||
|
customUpdate(e.currentTarget.value)
|
||||||
|
e.currentTarget.style.height = "0px"
|
||||||
|
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DockPrompt>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ export function SessionHeader() {
|
|||||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="rounded-none h-full py-0 pr-2 pl-0.5 gap-1.5 border-none shadow-none"
|
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
||||||
onClick={copyPath}
|
onClick={copyPath}
|
||||||
aria-label={language.t("session.header.open.copyPath")}
|
aria-label={language.t("session.header.open.copyPath")}
|
||||||
>
|
>
|
||||||
@@ -468,7 +468,7 @@ export function SessionHeader() {
|
|||||||
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
trigger={language.t("session.share.action.share")}
|
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -196,7 +203,7 @@ export function StatusPopover() {
|
|||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
class:
|
class:
|
||||||
"rounded-md h-[24px] pr-2 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-hover",
|
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-hover",
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -346,7 +354,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
ghostty = g
|
ghostty = g
|
||||||
term = t
|
term = t
|
||||||
output = terminalWriter((data) => t.write(data))
|
output = terminalWriter((data, done) => t.write(data, done))
|
||||||
|
|
||||||
t.attachCustomKeyEventHandler((event) => {
|
t.attachCustomKeyEventHandler((event) => {
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase()
|
||||||
@@ -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
|
||||||
@@ -520,9 +532,19 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
disposed = true
|
disposed = true
|
||||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||||
output?.flush()
|
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
|
||||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
|
||||||
cleanup()
|
const finalize = () => {
|
||||||
|
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
finalize()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output.flush(finalize)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
|
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
|
||||||
"command.model.variant.cycle": "تغيير جهد التفكير",
|
"command.model.variant.cycle": "تغيير جهد التفكير",
|
||||||
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
||||||
|
"command.prompt.mode.shell": "التبديل إلى وضع Shell",
|
||||||
|
"command.prompt.mode.normal": "التبديل إلى وضع Prompt",
|
||||||
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
||||||
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
||||||
"command.workspace.toggle": "تبديل مساحات العمل",
|
"command.workspace.toggle": "تبديل مساحات العمل",
|
||||||
@@ -210,6 +212,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
|
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
|
||||||
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
|
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc للخروج",
|
"prompt.mode.shell.exit": "esc للخروج",
|
||||||
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
|
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
|
||||||
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
|
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Mudar para o agente anterior",
|
"command.agent.cycle.reverse.description": "Mudar para o agente anterior",
|
||||||
"command.model.variant.cycle": "Alternar nível de raciocínio",
|
"command.model.variant.cycle": "Alternar nível de raciocínio",
|
||||||
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
||||||
|
"command.prompt.mode.shell": "Alternar para o modo Shell",
|
||||||
|
"command.prompt.mode.normal": "Alternar para o modo Prompt",
|
||||||
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
||||||
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
||||||
"command.workspace.toggle": "Alternar espaços de trabalho",
|
"command.workspace.toggle": "Alternar espaços de trabalho",
|
||||||
@@ -210,6 +212,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Resumir comentários…",
|
"prompt.placeholder.summarizeComments": "Resumir comentários…",
|
||||||
"prompt.placeholder.summarizeComment": "Resumir comentário…",
|
"prompt.placeholder.summarizeComment": "Resumir comentário…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc para sair",
|
"prompt.mode.shell.exit": "esc para sair",
|
||||||
"prompt.example.1": "Corrigir um TODO no código",
|
"prompt.example.1": "Corrigir um TODO no código",
|
||||||
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
|
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
|
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
|
||||||
"command.model.variant.cycle": "Promijeni nivo razmišljanja",
|
"command.model.variant.cycle": "Promijeni nivo razmišljanja",
|
||||||
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
|
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
|
||||||
|
"command.prompt.mode.shell": "Prebaci na Shell način",
|
||||||
|
"command.prompt.mode.normal": "Prebaci na Prompt način",
|
||||||
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
|
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
|
||||||
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
|
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
|
||||||
"command.workspace.toggle": "Prikaži/sakrij radne prostore",
|
"command.workspace.toggle": "Prikaži/sakrij radne prostore",
|
||||||
@@ -228,6 +230,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
|
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
|
||||||
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
|
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc za izlaz",
|
"prompt.mode.shell.exit": "esc za izlaz",
|
||||||
|
|
||||||
"prompt.example.1": "Popravi TODO u bazi koda",
|
"prompt.example.1": "Popravi TODO u bazi koda",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Skift til forrige agent",
|
"command.agent.cycle.reverse.description": "Skift til forrige agent",
|
||||||
"command.model.variant.cycle": "Skift tænkeindsats",
|
"command.model.variant.cycle": "Skift tænkeindsats",
|
||||||
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
|
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
|
||||||
|
"command.prompt.mode.shell": "Skift til shell-tilstand",
|
||||||
|
"command.prompt.mode.normal": "Skift til prompt-tilstand",
|
||||||
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
|
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
|
||||||
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
|
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
|
||||||
"command.workspace.toggle": "Skift arbejdsområder",
|
"command.workspace.toggle": "Skift arbejdsområder",
|
||||||
@@ -226,6 +228,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
|
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
|
||||||
"prompt.placeholder.summarizeComment": "Opsummér kommentar…",
|
"prompt.placeholder.summarizeComment": "Opsummér kommentar…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc for at afslutte",
|
"prompt.mode.shell.exit": "esc for at afslutte",
|
||||||
|
|
||||||
"prompt.example.1": "Ret en TODO i koden",
|
"prompt.example.1": "Ret en TODO i koden",
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Zum vorherigen Agenten wechseln",
|
"command.agent.cycle.reverse.description": "Zum vorherigen Agenten wechseln",
|
||||||
"command.model.variant.cycle": "Denkaufwand wechseln",
|
"command.model.variant.cycle": "Denkaufwand wechseln",
|
||||||
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
|
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
|
||||||
|
"command.prompt.mode.shell": "In den Shell-Modus wechseln",
|
||||||
|
"command.prompt.mode.normal": "In den Prompt-Modus wechseln",
|
||||||
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
|
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
|
||||||
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
|
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
|
||||||
"command.workspace.toggle": "Arbeitsbereiche umschalten",
|
"command.workspace.toggle": "Arbeitsbereiche umschalten",
|
||||||
@@ -215,6 +217,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
|
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
|
||||||
"prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…",
|
"prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc zum Verlassen",
|
"prompt.mode.shell.exit": "esc zum Verlassen",
|
||||||
"prompt.example.1": "Ein TODO in der Codebasis beheben",
|
"prompt.example.1": "Ein TODO in der Codebasis beheben",
|
||||||
"prompt.example.2": "Was ist der Tech-Stack dieses Projekts?",
|
"prompt.example.2": "Was ist der Tech-Stack dieses Projekts?",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Switch to the previous agent",
|
"command.agent.cycle.reverse.description": "Switch to the previous agent",
|
||||||
"command.model.variant.cycle": "Cycle thinking effort",
|
"command.model.variant.cycle": "Cycle thinking effort",
|
||||||
"command.model.variant.cycle.description": "Switch to the next effort level",
|
"command.model.variant.cycle.description": "Switch to the next effort level",
|
||||||
|
"command.prompt.mode.shell": "Switch to shell mode",
|
||||||
|
"command.prompt.mode.normal": "Switch to prompt mode",
|
||||||
"command.permissions.autoaccept.enable": "Auto-accept edits",
|
"command.permissions.autoaccept.enable": "Auto-accept edits",
|
||||||
"command.permissions.autoaccept.disable": "Stop auto-accepting edits",
|
"command.permissions.autoaccept.disable": "Stop auto-accepting edits",
|
||||||
"command.workspace.toggle": "Toggle workspaces",
|
"command.workspace.toggle": "Toggle workspaces",
|
||||||
@@ -228,6 +230,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Summarize comments…",
|
"prompt.placeholder.summarizeComments": "Summarize comments…",
|
||||||
"prompt.placeholder.summarizeComment": "Summarize comment…",
|
"prompt.placeholder.summarizeComment": "Summarize comment…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc to exit",
|
"prompt.mode.shell.exit": "esc to exit",
|
||||||
|
|
||||||
"prompt.example.1": "Fix a TODO in the codebase",
|
"prompt.example.1": "Fix a TODO in the codebase",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Cambiar al agente anterior",
|
"command.agent.cycle.reverse.description": "Cambiar al agente anterior",
|
||||||
"command.model.variant.cycle": "Alternar esfuerzo de pensamiento",
|
"command.model.variant.cycle": "Alternar esfuerzo de pensamiento",
|
||||||
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
|
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
|
||||||
|
"command.prompt.mode.shell": "Cambiar al modo Shell",
|
||||||
|
"command.prompt.mode.normal": "Cambiar al modo Prompt",
|
||||||
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
|
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
|
||||||
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
|
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
|
||||||
"command.workspace.toggle": "Alternar espacios de trabajo",
|
"command.workspace.toggle": "Alternar espacios de trabajo",
|
||||||
@@ -227,6 +229,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Resumir comentarios…",
|
"prompt.placeholder.summarizeComments": "Resumir comentarios…",
|
||||||
"prompt.placeholder.summarizeComment": "Resumir comentario…",
|
"prompt.placeholder.summarizeComment": "Resumir comentario…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc para salir",
|
"prompt.mode.shell.exit": "esc para salir",
|
||||||
|
|
||||||
"prompt.example.1": "Arreglar un TODO en el código",
|
"prompt.example.1": "Arreglar un TODO en el código",
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Passer à l'agent précédent",
|
"command.agent.cycle.reverse.description": "Passer à l'agent précédent",
|
||||||
"command.model.variant.cycle": "Changer l'effort de réflexion",
|
"command.model.variant.cycle": "Changer l'effort de réflexion",
|
||||||
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
|
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
|
||||||
|
"command.prompt.mode.shell": "Passer en mode Shell",
|
||||||
|
"command.prompt.mode.normal": "Passer en mode Prompt",
|
||||||
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
|
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
|
||||||
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
|
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
|
||||||
"command.workspace.toggle": "Basculer les espaces de travail",
|
"command.workspace.toggle": "Basculer les espaces de travail",
|
||||||
@@ -210,6 +212,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",
|
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",
|
||||||
"prompt.placeholder.summarizeComment": "Résumer le commentaire…",
|
"prompt.placeholder.summarizeComment": "Résumer le commentaire…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc pour quitter",
|
"prompt.mode.shell.exit": "esc pour quitter",
|
||||||
"prompt.example.1": "Corriger un TODO dans la base de code",
|
"prompt.example.1": "Corriger un TODO dans la base de code",
|
||||||
"prompt.example.2": "Quelle est la pile technique de ce projet ?",
|
"prompt.example.2": "Quelle est la pile technique de ce projet ?",
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "前のエージェントに切り替え",
|
"command.agent.cycle.reverse.description": "前のエージェントに切り替え",
|
||||||
"command.model.variant.cycle": "思考レベルの切り替え",
|
"command.model.variant.cycle": "思考レベルの切り替え",
|
||||||
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
|
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
|
||||||
|
"command.prompt.mode.shell": "シェルモードに切り替える",
|
||||||
|
"command.prompt.mode.normal": "プロンプトモードに切り替える",
|
||||||
"command.permissions.autoaccept.enable": "編集を自動承認",
|
"command.permissions.autoaccept.enable": "編集を自動承認",
|
||||||
"command.permissions.autoaccept.disable": "編集の自動承認を停止",
|
"command.permissions.autoaccept.disable": "編集の自動承認を停止",
|
||||||
"command.workspace.toggle": "ワークスペースを切り替え",
|
"command.workspace.toggle": "ワークスペースを切り替え",
|
||||||
@@ -209,6 +211,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "コメントを要約…",
|
"prompt.placeholder.summarizeComments": "コメントを要約…",
|
||||||
"prompt.placeholder.summarizeComment": "コメントを要約…",
|
"prompt.placeholder.summarizeComment": "コメントを要約…",
|
||||||
"prompt.mode.shell": "シェル",
|
"prompt.mode.shell": "シェル",
|
||||||
|
"prompt.mode.normal": "プロンプト",
|
||||||
"prompt.mode.shell.exit": "escで終了",
|
"prompt.mode.shell.exit": "escで終了",
|
||||||
"prompt.example.1": "コードベースのTODOを修正",
|
"prompt.example.1": "コードベースのTODOを修正",
|
||||||
"prompt.example.2": "このプロジェクトの技術スタックは何ですか?",
|
"prompt.example.2": "このプロジェクトの技術スタックは何ですか?",
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "이전 에이전트로 전환",
|
"command.agent.cycle.reverse.description": "이전 에이전트로 전환",
|
||||||
"command.model.variant.cycle": "생각 수준 순환",
|
"command.model.variant.cycle": "생각 수준 순환",
|
||||||
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
|
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
|
||||||
|
"command.prompt.mode.shell": "셸 모드로 전환",
|
||||||
|
"command.prompt.mode.normal": "프롬프트 모드로 전환",
|
||||||
"command.permissions.autoaccept.enable": "편집 자동 수락",
|
"command.permissions.autoaccept.enable": "편집 자동 수락",
|
||||||
"command.permissions.autoaccept.disable": "편집 자동 수락 중지",
|
"command.permissions.autoaccept.disable": "편집 자동 수락 중지",
|
||||||
"command.workspace.toggle": "작업 공간 전환",
|
"command.workspace.toggle": "작업 공간 전환",
|
||||||
@@ -213,6 +215,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "댓글 요약…",
|
"prompt.placeholder.summarizeComments": "댓글 요약…",
|
||||||
"prompt.placeholder.summarizeComment": "댓글 요약…",
|
"prompt.placeholder.summarizeComment": "댓글 요약…",
|
||||||
"prompt.mode.shell": "셸",
|
"prompt.mode.shell": "셸",
|
||||||
|
"prompt.mode.normal": "프롬프트",
|
||||||
"prompt.mode.shell.exit": "종료하려면 esc",
|
"prompt.mode.shell.exit": "종료하려면 esc",
|
||||||
"prompt.example.1": "코드베이스의 TODO 수정",
|
"prompt.example.1": "코드베이스의 TODO 수정",
|
||||||
"prompt.example.2": "이 프로젝트의 기술 스택이 무엇인가요?",
|
"prompt.example.2": "이 프로젝트의 기술 스택이 무엇인가요?",
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Bytt til forrige agent",
|
"command.agent.cycle.reverse.description": "Bytt til forrige agent",
|
||||||
"command.model.variant.cycle": "Bytt tenkeinnsats",
|
"command.model.variant.cycle": "Bytt tenkeinnsats",
|
||||||
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
|
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
|
||||||
|
"command.prompt.mode.shell": "Bytt til Shell-modus",
|
||||||
|
"command.prompt.mode.normal": "Bytt til Prompt-modus",
|
||||||
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
|
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
|
||||||
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
|
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
|
||||||
"command.workspace.toggle": "Veksle arbeidsområder",
|
"command.workspace.toggle": "Veksle arbeidsområder",
|
||||||
@@ -230,6 +232,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
|
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
|
||||||
"prompt.placeholder.summarizeComment": "Oppsummer kommentar…",
|
"prompt.placeholder.summarizeComment": "Oppsummer kommentar…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "ESC for å avslutte",
|
"prompt.mode.shell.exit": "ESC for å avslutte",
|
||||||
|
|
||||||
"prompt.example.1": "Fiks en TODO i kodebasen",
|
"prompt.example.1": "Fiks en TODO i kodebasen",
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Przełącz na poprzedniego agenta",
|
"command.agent.cycle.reverse.description": "Przełącz na poprzedniego agenta",
|
||||||
"command.model.variant.cycle": "Przełącz wysiłek myślowy",
|
"command.model.variant.cycle": "Przełącz wysiłek myślowy",
|
||||||
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
|
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
|
||||||
|
"command.prompt.mode.shell": "Przełącz na tryb terminala",
|
||||||
|
"command.prompt.mode.normal": "Przełącz na tryb Prompt",
|
||||||
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
|
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
|
||||||
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
|
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
|
||||||
"command.workspace.toggle": "Przełącz przestrzenie robocze",
|
"command.workspace.toggle": "Przełącz przestrzenie robocze",
|
||||||
@@ -211,6 +213,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
|
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
|
||||||
"prompt.placeholder.summarizeComment": "Podsumuj komentarz…",
|
"prompt.placeholder.summarizeComment": "Podsumuj komentarz…",
|
||||||
"prompt.mode.shell": "Terminal",
|
"prompt.mode.shell": "Terminal",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc aby wyjść",
|
"prompt.mode.shell.exit": "esc aby wyjść",
|
||||||
"prompt.example.1": "Napraw TODO w bazie kodu",
|
"prompt.example.1": "Napraw TODO w bazie kodu",
|
||||||
"prompt.example.2": "Jaki jest stos technologiczny tego projektu?",
|
"prompt.example.2": "Jaki jest stos technologiczny tego projektu?",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "Переключиться к предыдущему агенту",
|
"command.agent.cycle.reverse.description": "Переключиться к предыдущему агенту",
|
||||||
"command.model.variant.cycle": "Цикл режимов мышления",
|
"command.model.variant.cycle": "Цикл режимов мышления",
|
||||||
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
|
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
|
||||||
|
"command.prompt.mode.shell": "Переключиться в режим оболочки",
|
||||||
|
"command.prompt.mode.normal": "Переключиться в режим промпта",
|
||||||
"command.permissions.autoaccept.enable": "Авто-принятие изменений",
|
"command.permissions.autoaccept.enable": "Авто-принятие изменений",
|
||||||
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
|
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
|
||||||
"command.workspace.toggle": "Переключить рабочие пространства",
|
"command.workspace.toggle": "Переключить рабочие пространства",
|
||||||
@@ -227,6 +229,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",
|
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",
|
||||||
"prompt.placeholder.summarizeComment": "Суммировать комментарий…",
|
"prompt.placeholder.summarizeComment": "Суммировать комментарий…",
|
||||||
"prompt.mode.shell": "Оболочка",
|
"prompt.mode.shell": "Оболочка",
|
||||||
|
"prompt.mode.normal": "Промпт",
|
||||||
"prompt.mode.shell.exit": "esc для выхода",
|
"prompt.mode.shell.exit": "esc для выхода",
|
||||||
|
|
||||||
"prompt.example.1": "Исправить TODO в коде",
|
"prompt.example.1": "Исправить TODO в коде",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "สลับไปยังเอเจนต์ก่อนหน้า",
|
"command.agent.cycle.reverse.description": "สลับไปยังเอเจนต์ก่อนหน้า",
|
||||||
"command.model.variant.cycle": "เปลี่ยนความพยายามในการคิด",
|
"command.model.variant.cycle": "เปลี่ยนความพยายามในการคิด",
|
||||||
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
|
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
|
||||||
|
"command.prompt.mode.shell": "สลับไปยังโหมดเชลล์",
|
||||||
|
"command.prompt.mode.normal": "สลับไปยังโหมดพรอมต์",
|
||||||
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
|
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
|
||||||
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
|
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
|
||||||
"command.workspace.toggle": "สลับพื้นที่ทำงาน",
|
"command.workspace.toggle": "สลับพื้นที่ทำงาน",
|
||||||
@@ -227,6 +229,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
|
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
|
||||||
"prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
|
"prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
|
||||||
"prompt.mode.shell": "เชลล์",
|
"prompt.mode.shell": "เชลล์",
|
||||||
|
"prompt.mode.normal": "พรอมต์",
|
||||||
"prompt.mode.shell.exit": "กด esc เพื่อออก",
|
"prompt.mode.shell.exit": "กด esc เพื่อออก",
|
||||||
|
|
||||||
"prompt.example.1": "แก้ไข TODO ในโค้ดเบส",
|
"prompt.example.1": "แก้ไข TODO ในโค้ดเบส",
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ export const dict = {
|
|||||||
"command.model.variant.cycle": "切换思考强度",
|
"command.model.variant.cycle": "切换思考强度",
|
||||||
"command.model.variant.cycle.description": "切换到下一个强度等级",
|
"command.model.variant.cycle.description": "切换到下一个强度等级",
|
||||||
|
|
||||||
|
"command.prompt.mode.shell": "切换到 Shell 模式",
|
||||||
|
"command.prompt.mode.normal": "切换到 Prompt 模式",
|
||||||
|
|
||||||
"command.permissions.autoaccept.enable": "自动接受编辑",
|
"command.permissions.autoaccept.enable": "自动接受编辑",
|
||||||
"command.permissions.autoaccept.disable": "停止自动接受编辑",
|
"command.permissions.autoaccept.disable": "停止自动接受编辑",
|
||||||
|
|
||||||
@@ -248,6 +251,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "总结评论…",
|
"prompt.placeholder.summarizeComments": "总结评论…",
|
||||||
"prompt.placeholder.summarizeComment": "总结该评论…",
|
"prompt.placeholder.summarizeComment": "总结该评论…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "按 esc 退出",
|
"prompt.mode.shell.exit": "按 esc 退出",
|
||||||
"prompt.example.1": "修复代码库中的一个 TODO",
|
"prompt.example.1": "修复代码库中的一个 TODO",
|
||||||
"prompt.example.2": "这个项目的技术栈是什么?",
|
"prompt.example.2": "这个项目的技术栈是什么?",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export const dict = {
|
|||||||
"command.agent.cycle.reverse.description": "切換到上一個代理程式",
|
"command.agent.cycle.reverse.description": "切換到上一個代理程式",
|
||||||
"command.model.variant.cycle": "循環思考強度",
|
"command.model.variant.cycle": "循環思考強度",
|
||||||
"command.model.variant.cycle.description": "切換到下一個強度等級",
|
"command.model.variant.cycle.description": "切換到下一個強度等級",
|
||||||
|
"command.prompt.mode.shell": "切換到 Shell 模式",
|
||||||
|
"command.prompt.mode.normal": "切換到 Prompt 模式",
|
||||||
"command.permissions.autoaccept.enable": "自動接受編輯",
|
"command.permissions.autoaccept.enable": "自動接受編輯",
|
||||||
"command.permissions.autoaccept.disable": "停止自動接受編輯",
|
"command.permissions.autoaccept.disable": "停止自動接受編輯",
|
||||||
"command.workspace.toggle": "切換工作區",
|
"command.workspace.toggle": "切換工作區",
|
||||||
@@ -227,6 +229,7 @@ export const dict = {
|
|||||||
"prompt.placeholder.summarizeComments": "摘要評論…",
|
"prompt.placeholder.summarizeComments": "摘要評論…",
|
||||||
"prompt.placeholder.summarizeComment": "摘要這則評論…",
|
"prompt.placeholder.summarizeComment": "摘要這則評論…",
|
||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "按 esc 退出",
|
"prompt.mode.shell.exit": "按 esc 退出",
|
||||||
|
|
||||||
"prompt.example.1": "修復程式碼庫中的一個 TODO",
|
"prompt.example.1": "修復程式碼庫中的一個 TODO",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,106 +367,110 @@ 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,
|
||||||
"w-full": true,
|
"w-full": true,
|
||||||
"pb-4": true,
|
"pb-4": true,
|
||||||
"pl-2 pr-4 md:pl-4 md:pr-6": true,
|
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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,34 +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 { BasicTool } from "@opencode-ai/ui/basic-tool"
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
|
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
|
||||||
@@ -45,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
|
||||||
@@ -112,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>
|
||||||
@@ -122,72 +193,78 @@ export function SessionPromptDock(props: {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.permissionRequest()} keyed>
|
<Show when={permissionRequest()} keyed>
|
||||||
{(perm) => (
|
{(perm) => {
|
||||||
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
|
const toolDescription = () => {
|
||||||
<BasicTool
|
const key = `settings.permissions.tool.${perm.permission}.description`
|
||||||
icon="checklist"
|
const value = language.t(key as Parameters<typeof language.t>[0])
|
||||||
locked
|
if (value === key) return ""
|
||||||
defaultOpen
|
return value
|
||||||
trigger={{
|
}
|
||||||
title: props.t("notification.permission.title"),
|
|
||||||
subtitle:
|
return (
|
||||||
perm.permission === "doom_loop"
|
<div>
|
||||||
? props.t("settings.permissions.tool.doom_loop.title")
|
<DockPrompt
|
||||||
: perm.permission,
|
kind="permission"
|
||||||
}}
|
header={
|
||||||
>
|
<div data-slot="permission-row" data-variant="header">
|
||||||
<Show when={perm.patterns.length > 0}>
|
<span data-slot="permission-icon">
|
||||||
<div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
|
<Icon name="warning" size="normal" />
|
||||||
<For each={perm.patterns}>
|
</span>
|
||||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
|
||||||
</For>
|
</div>
|
||||||
</div>
|
}
|
||||||
</Show>
|
footer={
|
||||||
<Show when={perm.permission === "doom_loop"}>
|
<>
|
||||||
<div class="text-12-regular text-text-weak pb-2 px-3">
|
<div />
|
||||||
{props.t("settings.permissions.tool.doom_loop.description")}
|
<div data-slot="permission-footer-actions">
|
||||||
</div>
|
<Button variant="ghost" size="normal" onClick={() => decide("reject")} disabled={responding()}>
|
||||||
</Show>
|
{language.t("ui.permission.deny")}
|
||||||
</BasicTool>
|
</Button>
|
||||||
<div data-component="permission-prompt">
|
<Button
|
||||||
<div data-slot="permission-actions">
|
variant="secondary"
|
||||||
<Button
|
size="normal"
|
||||||
variant="ghost"
|
onClick={() => decide("always")}
|
||||||
size="small"
|
disabled={responding()}
|
||||||
onClick={() => props.onDecide("reject")}
|
>
|
||||||
disabled={props.responding}
|
{language.t("ui.permission.allowAlways")}
|
||||||
>
|
</Button>
|
||||||
{props.t("ui.permission.deny")}
|
<Button variant="primary" size="normal" onClick={() => decide("once")} disabled={responding()}>
|
||||||
</Button>
|
{language.t("ui.permission.allowOnce")}
|
||||||
<Button
|
</Button>
|
||||||
variant="secondary"
|
</div>
|
||||||
size="small"
|
</>
|
||||||
onClick={() => props.onDecide("always")}
|
}
|
||||||
disabled={props.responding}
|
>
|
||||||
>
|
<Show when={toolDescription()}>
|
||||||
{props.t("ui.permission.allowAlways")}
|
<div data-slot="permission-row">
|
||||||
</Button>
|
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||||
<Button
|
<div data-slot="permission-hint">{toolDescription()}</div>
|
||||||
variant="primary"
|
</div>
|
||||||
size="small"
|
</Show>
|
||||||
onClick={() => props.onDecide("once")}
|
|
||||||
disabled={props.responding}
|
<Show when={perm.patterns.length > 0}>
|
||||||
>
|
<div data-slot="permission-row">
|
||||||
{props.t("ui.permission.allowOnce")}
|
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||||
</Button>
|
<div data-slot="permission-patterns">
|
||||||
</div>
|
<For each={perm.patterns}>
|
||||||
|
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</DockPrompt>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
}}
|
||||||
</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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -202,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ describe("terminalWriter", () => {
|
|||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const scheduled: VoidFunction[] = []
|
const scheduled: VoidFunction[] = []
|
||||||
const writer = terminalWriter(
|
const writer = terminalWriter(
|
||||||
(data) => calls.push(data),
|
(data, done) => {
|
||||||
|
calls.push(data)
|
||||||
|
done?.()
|
||||||
|
},
|
||||||
(flush) => scheduled.push(flush),
|
(flush) => scheduled.push(flush),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,10 +27,38 @@ describe("terminalWriter", () => {
|
|||||||
test("flush is a no-op when empty", () => {
|
test("flush is a no-op when empty", () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const writer = terminalWriter(
|
const writer = terminalWriter(
|
||||||
(data) => calls.push(data),
|
(data, done) => {
|
||||||
|
calls.push(data)
|
||||||
|
done?.()
|
||||||
|
},
|
||||||
(flush) => flush(),
|
(flush) => flush(),
|
||||||
)
|
)
|
||||||
writer.flush()
|
writer.flush()
|
||||||
expect(calls).toEqual([])
|
expect(calls).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("flush waits for pending write completion", () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
let done: VoidFunction | undefined
|
||||||
|
const writer = terminalWriter(
|
||||||
|
(data, finish) => {
|
||||||
|
calls.push(data)
|
||||||
|
done = finish
|
||||||
|
},
|
||||||
|
(flush) => flush(),
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.push("a")
|
||||||
|
|
||||||
|
let settled = false
|
||||||
|
writer.flush(() => {
|
||||||
|
settled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(calls).toEqual(["a"])
|
||||||
|
expect(settled).toBe(false)
|
||||||
|
|
||||||
|
done?.()
|
||||||
|
expect(settled).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,42 @@
|
|||||||
export function terminalWriter(
|
export function terminalWriter(
|
||||||
write: (data: string) => void,
|
write: (data: string, done?: VoidFunction) => void,
|
||||||
schedule: (flush: VoidFunction) => void = queueMicrotask,
|
schedule: (flush: VoidFunction) => void = queueMicrotask,
|
||||||
) {
|
) {
|
||||||
let chunks: string[] | undefined
|
let chunks: string[] | undefined
|
||||||
|
let waits: VoidFunction[] | undefined
|
||||||
let scheduled = false
|
let scheduled = false
|
||||||
|
let writing = false
|
||||||
|
|
||||||
const flush = () => {
|
const settle = () => {
|
||||||
|
if (scheduled || writing || chunks?.length) return
|
||||||
|
const list = waits
|
||||||
|
if (!list?.length) return
|
||||||
|
waits = undefined
|
||||||
|
for (const fn of list) {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (writing) return
|
||||||
scheduled = false
|
scheduled = false
|
||||||
const items = chunks
|
const items = chunks
|
||||||
if (!items?.length) return
|
if (!items?.length) {
|
||||||
|
settle()
|
||||||
|
return
|
||||||
|
}
|
||||||
chunks = undefined
|
chunks = undefined
|
||||||
write(items.join(""))
|
writing = true
|
||||||
|
write(items.join(""), () => {
|
||||||
|
writing = false
|
||||||
|
if (chunks?.length) {
|
||||||
|
if (scheduled) return
|
||||||
|
scheduled = true
|
||||||
|
schedule(run)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settle()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const push = (data: string) => {
|
const push = (data: string) => {
|
||||||
@@ -18,9 +44,21 @@ export function terminalWriter(
|
|||||||
if (chunks) chunks.push(data)
|
if (chunks) chunks.push(data)
|
||||||
else chunks = [data]
|
else chunks = [data]
|
||||||
|
|
||||||
if (scheduled) return
|
if (scheduled || writing) return
|
||||||
scheduled = true
|
scheduled = true
|
||||||
schedule(flush)
|
schedule(run)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = (done?: VoidFunction) => {
|
||||||
|
if (!scheduled && !writing && !chunks?.length) {
|
||||||
|
done?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
if (waits) waits.push(done)
|
||||||
|
else waits = [done]
|
||||||
|
}
|
||||||
|
run()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { push, flush }
|
return { push, flush }
|
||||||
|
|||||||
@@ -22,5 +22,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "package.json"],
|
"include": ["src", "package.json"],
|
||||||
"exclude": ["dist", "ts-dist"]
|
"exclude": ["dist", "ts-dist"],
|
||||||
|
"references": [{ "path": "../sdk/js" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ use process_wrap::tokio::ProcessGroup;
|
|||||||
use process_wrap::tokio::{JobObject, KillOnDrop};
|
use process_wrap::tokio::{JobObject, KillOnDrop};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::process::ExitStatusExt;
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
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::io::{AsyncBufReadExt, BufReader};
|
use tokio::{
|
||||||
use tokio::process::Command;
|
io::{AsyncBufRead, AsyncBufReadExt, BufReader},
|
||||||
use tokio::sync::{mpsc, oneshot};
|
process::Command,
|
||||||
|
sync::{mpsc, oneshot},
|
||||||
|
task::JoinHandle,
|
||||||
|
};
|
||||||
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";
|
||||||
@@ -34,8 +38,8 @@ pub struct Config {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum CommandEvent {
|
pub enum CommandEvent {
|
||||||
Stdout(Vec<u8>),
|
Stdout(String),
|
||||||
Stderr(Vec<u8>),
|
Stderr(String),
|
||||||
Error(String),
|
Error(String),
|
||||||
Terminated(TerminatedPayload),
|
Terminated(TerminatedPayload),
|
||||||
}
|
}
|
||||||
@@ -64,10 +68,11 @@ pub async fn get_config(app: &AppHandle) -> Option<Config> {
|
|||||||
|
|
||||||
events
|
events
|
||||||
.fold(String::new(), async |mut config_str, event| {
|
.fold(String::new(), async |mut config_str, event| {
|
||||||
if let CommandEvent::Stdout(stdout) = event
|
if let CommandEvent::Stdout(s) = &event {
|
||||||
&& let Ok(s) = str::from_utf8(&stdout)
|
config_str += s.as_str()
|
||||||
{
|
}
|
||||||
config_str += s
|
if let CommandEvent::Stderr(s) = &event {
|
||||||
|
config_str += s.as_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
config_str
|
config_str
|
||||||
@@ -197,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 {
|
||||||
@@ -317,9 +314,9 @@ pub fn spawn_command(
|
|||||||
cmd
|
cmd
|
||||||
};
|
};
|
||||||
|
|
||||||
cmd.stdin(Stdio::null());
|
|
||||||
cmd.stdout(Stdio::piped());
|
cmd.stdout(Stdio::piped());
|
||||||
cmd.stderr(Stdio::piped());
|
cmd.stderr(Stdio::piped());
|
||||||
|
cmd.stdin(Stdio::null());
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
cmd.creation_flags(0x0800_0000);
|
cmd.creation_flags(0x0800_0000);
|
||||||
@@ -337,32 +334,24 @@ pub fn spawn_command(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut child = wrap.spawn()?;
|
let mut child = wrap.spawn()?;
|
||||||
let stdout = child.stdout().take();
|
let guard = Arc::new(tokio::sync::RwLock::new(()));
|
||||||
let stderr = child.stderr().take();
|
|
||||||
let (tx, rx) = mpsc::channel(256);
|
let (tx, rx) = mpsc::channel(256);
|
||||||
let (kill_tx, mut kill_rx) = mpsc::channel(1);
|
let (kill_tx, mut kill_rx) = mpsc::channel(1);
|
||||||
|
|
||||||
if let Some(stdout) = stdout {
|
let stdout = spawn_pipe_reader(
|
||||||
let tx = tx.clone();
|
tx.clone(),
|
||||||
tokio::spawn(async move {
|
guard.clone(),
|
||||||
let mut lines = BufReader::new(stdout).lines();
|
BufReader::new(child.stdout().take().unwrap()),
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
CommandEvent::Stdout,
|
||||||
let _ = tx.send(CommandEvent::Stdout(line.into_bytes())).await;
|
);
|
||||||
}
|
let stderr = spawn_pipe_reader(
|
||||||
});
|
tx.clone(),
|
||||||
}
|
guard.clone(),
|
||||||
|
BufReader::new(child.stderr().take().unwrap()),
|
||||||
|
CommandEvent::Stderr,
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(stderr) = stderr {
|
tokio::task::spawn(async move {
|
||||||
let tx = tx.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut lines = BufReader::new(stderr).lines();
|
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
|
||||||
let _ = tx.send(CommandEvent::Stderr(line.into_bytes())).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut kill_open = true;
|
let mut kill_open = true;
|
||||||
let status = loop {
|
let status = loop {
|
||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
@@ -394,6 +383,9 @@ pub fn spawn_command(
|
|||||||
let _ = tx.send(CommandEvent::Error(err.to_string())).await;
|
let _ = tx.send(CommandEvent::Error(err.to_string())).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stdout.abort();
|
||||||
|
stderr.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
let event_stream = ReceiverStream::new(rx);
|
let event_stream = ReceiverStream::new(rx);
|
||||||
@@ -404,9 +396,7 @@ pub fn spawn_command(
|
|||||||
|
|
||||||
fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
|
fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
return status.signal();
|
||||||
return status.signal();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
{
|
{
|
||||||
@@ -442,12 +432,10 @@ pub fn serve(
|
|||||||
events
|
events
|
||||||
.for_each(move |event| {
|
.for_each(move |event| {
|
||||||
match event {
|
match event {
|
||||||
CommandEvent::Stdout(line_bytes) => {
|
CommandEvent::Stdout(line) => {
|
||||||
let line = String::from_utf8_lossy(&line_bytes);
|
|
||||||
tracing::info!("{line}");
|
tracing::info!("{line}");
|
||||||
}
|
}
|
||||||
CommandEvent::Stderr(line_bytes) => {
|
CommandEvent::Stderr(line) => {
|
||||||
let line = String::from_utf8_lossy(&line_bytes);
|
|
||||||
tracing::info!("{line}");
|
tracing::info!("{line}");
|
||||||
}
|
}
|
||||||
CommandEvent::Error(err) => {
|
CommandEvent::Error(err) => {
|
||||||
@@ -499,11 +487,7 @@ pub mod sqlite_migration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
future::ready(match &event {
|
future::ready(match &event {
|
||||||
CommandEvent::Stdout(stdout) => {
|
CommandEvent::Stdout(s) | CommandEvent::Stderr(s) => {
|
||||||
let Ok(s) = str::from_utf8(stdout) else {
|
|
||||||
return future::ready(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
|
if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
|
||||||
if let Ok(progress) = s.parse::<u8>() {
|
if let Ok(progress) = s.parse::<u8>() {
|
||||||
let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
|
let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
|
||||||
@@ -522,3 +506,41 @@ pub mod sqlite_migration {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
|
||||||
|
tx: mpsc::Sender<CommandEvent>,
|
||||||
|
guard: Arc<tokio::sync::RwLock<()>>,
|
||||||
|
pipe_reader: impl AsyncBufRead + Send + Unpin + 'static,
|
||||||
|
wrapper: F,
|
||||||
|
) -> JoinHandle<()> {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _lock = guard.read().await;
|
||||||
|
let reader = BufReader::new(pipe_reader);
|
||||||
|
|
||||||
|
read_line(reader, tx, wrapper).await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
|
||||||
|
reader: BufReader<impl AsyncBufRead + Unpin>,
|
||||||
|
tx: mpsc::Sender<CommandEvent>,
|
||||||
|
wrapper: F,
|
||||||
|
) {
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
loop {
|
||||||
|
let line = lines.next_line().await;
|
||||||
|
|
||||||
|
match line {
|
||||||
|
Ok(s) => {
|
||||||
|
if let Some(s) = s {
|
||||||
|
let _ = tx.clone().send(wrapper(s)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let tx_ = tx.clone();
|
||||||
|
let _ = tx_.send(CommandEvent::Error(e.to_string())).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -159,6 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a deduplicated list of plugin-registered auth providers that are not
|
||||||
|
* already present in models.dev, respecting enabled/disabled provider lists.
|
||||||
|
* Pure function with no side effects; safe to test without mocking.
|
||||||
|
*/
|
||||||
|
export function resolvePluginProviders(input: {
|
||||||
|
hooks: Hooks[]
|
||||||
|
existingProviders: Record<string, unknown>
|
||||||
|
disabled: Set<string>
|
||||||
|
enabled?: Set<string>
|
||||||
|
providerNames: Record<string, string | undefined>
|
||||||
|
}): Array<{ id: string; name: string }> {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: Array<{ id: string; name: string }> = []
|
||||||
|
|
||||||
|
for (const hook of input.hooks) {
|
||||||
|
if (!hook.auth) continue
|
||||||
|
const id = hook.auth.provider
|
||||||
|
if (seen.has(id)) continue
|
||||||
|
seen.add(id)
|
||||||
|
if (Object.hasOwn(input.existingProviders, id)) continue
|
||||||
|
if (input.disabled.has(id)) continue
|
||||||
|
if (input.enabled && !input.enabled.has(id)) continue
|
||||||
|
result.push({
|
||||||
|
id,
|
||||||
|
name: input.providerNames[id] ?? id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export const AuthCommand = cmd({
|
export const AuthCommand = cmd({
|
||||||
command: "auth",
|
command: "auth",
|
||||||
describe: "manage credentials",
|
describe: "manage credentials",
|
||||||
@@ -277,6 +309,13 @@ export const AuthLoginCommand = cmd({
|
|||||||
openrouter: 5,
|
openrouter: 5,
|
||||||
vercel: 6,
|
vercel: 6,
|
||||||
}
|
}
|
||||||
|
const pluginProviders = resolvePluginProviders({
|
||||||
|
hooks: await Plugin.list(),
|
||||||
|
existingProviders: providers,
|
||||||
|
disabled,
|
||||||
|
enabled,
|
||||||
|
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||||
|
})
|
||||||
let provider = await prompts.autocomplete({
|
let provider = await prompts.autocomplete({
|
||||||
message: "Select provider",
|
message: "Select provider",
|
||||||
maxItems: 8,
|
maxItems: 8,
|
||||||
@@ -298,6 +337,11 @@ export const AuthLoginCommand = cmd({
|
|||||||
}[x.id],
|
}[x.id],
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
...pluginProviders.map((x) => ({
|
||||||
|
label: x.name,
|
||||||
|
value: x.id,
|
||||||
|
hint: "plugin",
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
value: "other",
|
value: "other",
|
||||||
label: "Other",
|
label: "Other",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const ExportCommand = cmd({
|
|||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
await bootstrap(process.cwd(), async () => {
|
await bootstrap(process.cwd(), async () => {
|
||||||
let sessionID = args.sessionID
|
let sessionID = args.sessionID
|
||||||
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}`)
|
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
|
||||||
|
|
||||||
if (!sessionID) {
|
if (!sessionID) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -64,12 +64,16 @@ function EditBody(props: { request: PermissionRequest }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
|
||||||
<text fg={theme.textMuted}>{"→"}</text>
|
|
||||||
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
|
|
||||||
</box>
|
|
||||||
<Show when={diff()}>
|
<Show when={diff()}>
|
||||||
<scrollbox height="100%">
|
<scrollbox
|
||||||
|
height="100%"
|
||||||
|
verticalScrollbarOptions={{
|
||||||
|
trackOptions: {
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
foregroundColor: theme.borderActive,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<diff
|
<diff
|
||||||
diff={diff()}
|
diff={diff()}
|
||||||
view={view()}
|
view={view()}
|
||||||
@@ -91,6 +95,11 @@ function EditBody(props: { request: PermissionRequest }) {
|
|||||||
/>
|
/>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={!diff()}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>No diff provided</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -194,76 +203,233 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={store.stage === "permission"}>
|
<Match when={store.stage === "permission"}>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
const info = () => {
|
||||||
|
const permission = props.request.permission
|
||||||
|
const data = input()
|
||||||
|
|
||||||
|
if (permission === "edit") {
|
||||||
|
const raw = props.request.metadata?.filepath
|
||||||
|
const filepath = typeof raw === "string" ? raw : ""
|
||||||
|
return {
|
||||||
|
icon: "→",
|
||||||
|
title: `Edit ${normalizePath(filepath)}`,
|
||||||
|
body: <EditBody request={props.request} />,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "read") {
|
||||||
|
const raw = data.filePath
|
||||||
|
const filePath = typeof raw === "string" ? raw : ""
|
||||||
|
return {
|
||||||
|
icon: "→",
|
||||||
|
title: `Read ${normalizePath(filePath)}`,
|
||||||
|
body: (
|
||||||
|
<Show when={filePath}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "glob") {
|
||||||
|
const pattern = typeof data.pattern === "string" ? data.pattern : ""
|
||||||
|
return {
|
||||||
|
icon: "✱",
|
||||||
|
title: `Glob "${pattern}"`,
|
||||||
|
body: (
|
||||||
|
<Show when={pattern}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "grep") {
|
||||||
|
const pattern = typeof data.pattern === "string" ? data.pattern : ""
|
||||||
|
return {
|
||||||
|
icon: "✱",
|
||||||
|
title: `Grep "${pattern}"`,
|
||||||
|
body: (
|
||||||
|
<Show when={pattern}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "list") {
|
||||||
|
const raw = data.path
|
||||||
|
const dir = typeof raw === "string" ? raw : ""
|
||||||
|
return {
|
||||||
|
icon: "→",
|
||||||
|
title: `List ${normalizePath(dir)}`,
|
||||||
|
body: (
|
||||||
|
<Show when={dir}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "bash") {
|
||||||
|
const title =
|
||||||
|
typeof data.description === "string" && data.description ? data.description : "Shell command"
|
||||||
|
const command = typeof data.command === "string" ? data.command : ""
|
||||||
|
return {
|
||||||
|
icon: "#",
|
||||||
|
title,
|
||||||
|
body: (
|
||||||
|
<Show when={command}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.text}>{"$ " + command}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "task") {
|
||||||
|
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown"
|
||||||
|
const desc = typeof data.description === "string" ? data.description : ""
|
||||||
|
return {
|
||||||
|
icon: "#",
|
||||||
|
title: `${Locale.titlecase(type)} Task`,
|
||||||
|
body: (
|
||||||
|
<Show when={desc}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.text}>{"◉ " + desc}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "webfetch") {
|
||||||
|
const url = typeof data.url === "string" ? data.url : ""
|
||||||
|
return {
|
||||||
|
icon: "%",
|
||||||
|
title: `WebFetch ${url}`,
|
||||||
|
body: (
|
||||||
|
<Show when={url}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"URL: " + url}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "websearch") {
|
||||||
|
const query = typeof data.query === "string" ? data.query : ""
|
||||||
|
return {
|
||||||
|
icon: "◈",
|
||||||
|
title: `Exa Web Search "${query}"`,
|
||||||
|
body: (
|
||||||
|
<Show when={query}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"Query: " + query}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "codesearch") {
|
||||||
|
const query = typeof data.query === "string" ? data.query : ""
|
||||||
|
return {
|
||||||
|
icon: "◇",
|
||||||
|
title: `Exa Code Search "${query}"`,
|
||||||
|
body: (
|
||||||
|
<Show when={query}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"Query: " + query}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "external_directory") {
|
||||||
|
const meta = props.request.metadata ?? {}
|
||||||
|
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||||
|
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
||||||
|
const pattern = props.request.patterns?.[0]
|
||||||
|
const derived =
|
||||||
|
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
|
||||||
|
|
||||||
|
const raw = parent ?? filepath ?? derived
|
||||||
|
const dir = normalizePath(raw)
|
||||||
|
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: "←",
|
||||||
|
title: `Access external directory ${dir}`,
|
||||||
|
body: (
|
||||||
|
<Show when={patterns.length > 0}>
|
||||||
|
<box paddingLeft={1} gap={1}>
|
||||||
|
<text fg={theme.textMuted}>Patterns</text>
|
||||||
|
<box>
|
||||||
|
<For each={patterns}>{(p) => <text fg={theme.text}>{"- " + p}</text>}</For>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "doom_loop") {
|
||||||
|
return {
|
||||||
|
icon: "⟳",
|
||||||
|
title: "Continue after repeated failures",
|
||||||
|
body: (
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>This keeps the session running despite repeated failures.</text>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: "⚙",
|
||||||
|
title: `Call tool ${permission}`,
|
||||||
|
body: (
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.textMuted}>{"Tool: " + permission}</text>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = info()
|
||||||
|
|
||||||
|
const header = () => (
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||||
|
<text fg={theme.warning}>{"△"}</text>
|
||||||
|
<text fg={theme.text}>Permission required</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
|
||||||
|
<text fg={theme.textMuted} flexShrink={0}>
|
||||||
|
{current.icon}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text}>{current.title}</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<Prompt
|
<Prompt
|
||||||
title="Permission required"
|
title="Permission required"
|
||||||
body={
|
header={header()}
|
||||||
<Switch>
|
body={current.body}
|
||||||
<Match when={props.request.permission === "edit"}>
|
|
||||||
<EditBody request={props.request} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "read"}>
|
|
||||||
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "glob"}>
|
|
||||||
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "grep"}>
|
|
||||||
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "list"}>
|
|
||||||
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "bash"}>
|
|
||||||
<TextBody
|
|
||||||
icon="#"
|
|
||||||
title={(input().description as string) ?? ""}
|
|
||||||
description={("$ " + input().command) as string}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "task"}>
|
|
||||||
<TextBody
|
|
||||||
icon="#"
|
|
||||||
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
|
|
||||||
description={"◉ " + input().description}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "webfetch"}>
|
|
||||||
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "websearch"}>
|
|
||||||
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "codesearch"}>
|
|
||||||
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "external_directory"}>
|
|
||||||
{(() => {
|
|
||||||
const meta = props.request.metadata ?? {}
|
|
||||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
|
||||||
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
|
|
||||||
const pattern = props.request.patterns?.[0]
|
|
||||||
const derived =
|
|
||||||
typeof pattern === "string"
|
|
||||||
? pattern.includes("*")
|
|
||||||
? path.dirname(pattern)
|
|
||||||
: pattern
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const raw = parent ?? filepath ?? derived
|
|
||||||
const dir = normalizePath(raw)
|
|
||||||
|
|
||||||
return <TextBody icon="←" title={`Access external directory ` + dir} />
|
|
||||||
})()}
|
|
||||||
</Match>
|
|
||||||
<Match when={props.request.permission === "doom_loop"}>
|
|
||||||
<TextBody icon="⟳" title="Continue after repeated failures" />
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
}
|
|
||||||
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
|
||||||
escapeKey="reject"
|
escapeKey="reject"
|
||||||
fullscreen
|
fullscreen
|
||||||
@@ -372,6 +538,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
|
|||||||
|
|
||||||
function Prompt<const T extends Record<string, string>>(props: {
|
function Prompt<const T extends Record<string, string>>(props: {
|
||||||
title: string
|
title: string
|
||||||
|
header?: JSX.Element
|
||||||
body: JSX.Element
|
body: JSX.Element
|
||||||
options: T
|
options: T
|
||||||
escapeKey?: keyof T
|
escapeKey?: keyof T
|
||||||
@@ -445,10 +612,19 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
|
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
<Show
|
||||||
<text fg={theme.warning}>{"△"}</text>
|
when={props.header}
|
||||||
<text fg={theme.text}>{props.title}</text>
|
fallback={
|
||||||
</box>
|
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||||
|
<text fg={theme.warning}>{"△"}</text>
|
||||||
|
<text fg={theme.text}>{props.title}</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box paddingLeft={1} flexShrink={0}>
|
||||||
|
{props.header}
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
{props.body}
|
{props.body}
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
|
|||||||
@@ -80,7 +80,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
position={props.overlay ? "absolute" : "relative"}
|
position={props.overlay ? "absolute" : "relative"}
|
||||||
>
|
>
|
||||||
<scrollbox flexGrow={1}>
|
<scrollbox
|
||||||
|
flexGrow={1}
|
||||||
|
verticalScrollbarOptions={{
|
||||||
|
trackOptions: {
|
||||||
|
backgroundColor: theme.background,
|
||||||
|
foregroundColor: theme.borderActive,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||||
<box paddingRight={1}>
|
<box paddingRight={1}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
|
|||||||
@@ -184,5 +184,6 @@ export const TuiThreadCommand = cmd({
|
|||||||
} finally {
|
} finally {
|
||||||
unguard?.()
|
unguard?.()
|
||||||
}
|
}
|
||||||
|
process.exit(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -137,7 +137,12 @@ export const rpc = {
|
|||||||
async shutdown() {
|
async shutdown() {
|
||||||
Log.Default.info("worker shutting down")
|
Log.Default.info("worker shutting down")
|
||||||
if (eventStream.abort) eventStream.abort.abort()
|
if (eventStream.abort) eventStream.abort.abort()
|
||||||
await Instance.disposeAll()
|
await Promise.race([
|
||||||
|
Instance.disposeAll(),
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 5000)
|
||||||
|
}),
|
||||||
|
])
|
||||||
if (server) server.stop(true)
|
if (server) server.stop(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 +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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
}
|
}
|
||||||
return entry.oauthState
|
return entry.oauthState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||||
|
log.info("invalidating credentials", { mcpName: this.mcpName, type })
|
||||||
|
const entry = await McpAuth.get(this.mcpName)
|
||||||
|
if (!entry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "all":
|
||||||
|
await McpAuth.remove(this.mcpName)
|
||||||
|
break
|
||||||
|
case "client":
|
||||||
|
delete entry.clientInfo
|
||||||
|
await McpAuth.set(this.mcpName, entry)
|
||||||
|
break
|
||||||
|
case "tokens":
|
||||||
|
delete entry.tokens
|
||||||
|
await McpAuth.set(this.mcpName, entry)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export namespace ModelsDev {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH) {
|
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
|
||||||
ModelsDev.refresh()
|
ModelsDev.refresh()
|
||||||
setInterval(
|
setInterval(
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -578,6 +578,17 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
kilo: async () => {
|
||||||
|
return {
|
||||||
|
autoload: false,
|
||||||
|
options: {
|
||||||
|
headers: {
|
||||||
|
"HTTP-Referer": "https://opencode.ai/",
|
||||||
|
"X-Title": "opencode",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Model = z
|
export const Model = z
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const updateMessage = fn(MessageV2.Info, async (msg) => {
|
export const updateMessage = fn(MessageV2.Info, async (msg) => {
|
||||||
const time_created = msg.role === "user" ? msg.time.created : msg.time.created
|
const time_created = msg.time.created
|
||||||
const { id, sessionID, ...data } = msg
|
const { id, sessionID, ...data } = msg
|
||||||
Database.use((db) => {
|
Database.use((db) => {
|
||||||
db.insert(MessageTable)
|
db.insert(MessageTable)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import * as fs from "fs"
|
import { createReadStream } from "fs"
|
||||||
|
import * as fs from "fs/promises"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
|
import { createInterface } from "readline"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
@@ -11,7 +13,9 @@ import { InstructionPrompt } from "../session/instruction"
|
|||||||
|
|
||||||
const DEFAULT_READ_LIMIT = 2000
|
const DEFAULT_READ_LIMIT = 2000
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
|
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||||
const MAX_BYTES = 50 * 1024
|
const MAX_BYTES = 50 * 1024
|
||||||
|
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||||
|
|
||||||
export const ReadTool = Tool.define("read", {
|
export const ReadTool = Tool.define("read", {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -49,14 +53,18 @@ export const ReadTool = Tool.define("read", {
|
|||||||
const dir = path.dirname(filepath)
|
const dir = path.dirname(filepath)
|
||||||
const base = path.basename(filepath)
|
const base = path.basename(filepath)
|
||||||
|
|
||||||
const dirEntries = fs.readdirSync(dir)
|
const suggestions = await fs
|
||||||
const suggestions = dirEntries
|
.readdir(dir)
|
||||||
.filter(
|
.then((entries) =>
|
||||||
(entry) =>
|
entries
|
||||||
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((entry) => path.join(dir, entry))
|
||||||
|
.slice(0, 3),
|
||||||
)
|
)
|
||||||
.map((entry) => path.join(dir, entry))
|
.catch(() => [])
|
||||||
.slice(0, 3)
|
|
||||||
|
|
||||||
if (suggestions.length > 0) {
|
if (suggestions.length > 0) {
|
||||||
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
|
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
|
||||||
@@ -66,12 +74,12 @@ export const ReadTool = Tool.define("read", {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
const dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
|
const dirents = await fs.readdir(filepath, { withFileTypes: true })
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
dirents.map(async (dirent) => {
|
dirents.map(async (dirent) => {
|
||||||
if (dirent.isDirectory()) return dirent.name + "/"
|
if (dirent.isDirectory()) return dirent.name + "/"
|
||||||
if (dirent.isSymbolicLink()) {
|
if (dirent.isSymbolicLink()) {
|
||||||
const target = await fs.promises.stat(path.join(filepath, dirent.name)).catch(() => undefined)
|
const target = await fs.stat(path.join(filepath, dirent.name)).catch(() => undefined)
|
||||||
if (target?.isDirectory()) return dirent.name + "/"
|
if (target?.isDirectory()) return dirent.name + "/"
|
||||||
}
|
}
|
||||||
return dirent.name
|
return dirent.name
|
||||||
@@ -134,27 +142,53 @@ export const ReadTool = Tool.define("read", {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBinary = await isBinaryFile(filepath, file)
|
const isBinary = await isBinaryFile(filepath, stat.size)
|
||||||
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
|
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
|
||||||
|
|
||||||
|
const stream = createReadStream(filepath, { encoding: "utf8" })
|
||||||
|
const rl = createInterface({
|
||||||
|
input: stream,
|
||||||
|
// Note: we use the crlfDelay option to recognize all instances of CR LF
|
||||||
|
// ('\r\n') in file as a single line break.
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||||
const offset = params.offset ?? 1
|
const offset = params.offset ?? 1
|
||||||
const start = offset - 1
|
const start = offset - 1
|
||||||
const lines = await file.text().then((text) => text.split("\n"))
|
|
||||||
if (start >= lines.length) throw new Error(`Offset ${offset} is out of range for this file (${lines.length} lines)`)
|
|
||||||
|
|
||||||
const raw: string[] = []
|
const raw: string[] = []
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
|
let lines = 0
|
||||||
let truncatedByBytes = false
|
let truncatedByBytes = false
|
||||||
for (let i = start; i < Math.min(lines.length, start + limit); i++) {
|
let hasMoreLines = false
|
||||||
const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
|
try {
|
||||||
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
for await (const text of rl) {
|
||||||
if (bytes + size > MAX_BYTES) {
|
lines += 1
|
||||||
truncatedByBytes = true
|
if (lines <= start) continue
|
||||||
break
|
|
||||||
|
if (raw.length >= limit) {
|
||||||
|
hasMoreLines = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
|
||||||
|
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
||||||
|
if (bytes + size > MAX_BYTES) {
|
||||||
|
truncatedByBytes = true
|
||||||
|
hasMoreLines = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
raw.push(line)
|
||||||
|
bytes += size
|
||||||
}
|
}
|
||||||
raw.push(line)
|
} finally {
|
||||||
bytes += size
|
rl.close()
|
||||||
|
stream.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines < offset && !(lines === 0 && offset === 1)) {
|
||||||
|
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = raw.map((line, index) => {
|
const content = raw.map((line, index) => {
|
||||||
@@ -165,15 +199,15 @@ export const ReadTool = Tool.define("read", {
|
|||||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||||
output += content.join("\n")
|
output += content.join("\n")
|
||||||
|
|
||||||
const totalLines = lines.length
|
const totalLines = lines
|
||||||
const lastReadLine = offset + raw.length - 1
|
const lastReadLine = offset + raw.length - 1
|
||||||
const hasMoreLines = totalLines > lastReadLine
|
const nextOffset = lastReadLine + 1
|
||||||
const truncated = hasMoreLines || truncatedByBytes
|
const truncated = hasMoreLines || truncatedByBytes
|
||||||
|
|
||||||
if (truncatedByBytes) {
|
if (truncatedByBytes) {
|
||||||
output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
|
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
|
||||||
} else if (hasMoreLines) {
|
} else if (hasMoreLines) {
|
||||||
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
|
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
|
||||||
} else {
|
} else {
|
||||||
output += `\n\n(End of file - total ${totalLines} lines)`
|
output += `\n\n(End of file - total ${totalLines} lines)`
|
||||||
}
|
}
|
||||||
@@ -199,7 +233,7 @@ export const ReadTool = Tool.define("read", {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolean> {
|
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
|
||||||
const ext = path.extname(filepath).toLowerCase()
|
const ext = path.extname(filepath).toLowerCase()
|
||||||
// binary check for common non-text extensions
|
// binary check for common non-text extensions
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
@@ -236,22 +270,25 @@ async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolea
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const stat = await file.stat()
|
|
||||||
const fileSize = stat.size
|
|
||||||
if (fileSize === 0) return false
|
if (fileSize === 0) return false
|
||||||
|
|
||||||
const bufferSize = Math.min(4096, fileSize)
|
const fh = await fs.open(filepath, "r")
|
||||||
const buffer = await file.arrayBuffer()
|
try {
|
||||||
if (buffer.byteLength === 0) return false
|
const sampleSize = Math.min(4096, fileSize)
|
||||||
const bytes = new Uint8Array(buffer.slice(0, bufferSize))
|
const bytes = Buffer.alloc(sampleSize)
|
||||||
|
const result = await fh.read(bytes, 0, sampleSize, 0)
|
||||||
|
if (result.bytesRead === 0) return false
|
||||||
|
|
||||||
let nonPrintableCount = 0
|
let nonPrintableCount = 0
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
for (let i = 0; i < result.bytesRead; i++) {
|
||||||
if (bytes[i] === 0) return true
|
if (bytes[i] === 0) return true
|
||||||
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
|
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
|
||||||
nonPrintableCount++
|
nonPrintableCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// If >30% non-printable characters, consider it binary
|
||||||
|
return nonPrintableCount / result.bytesRead > 0.3
|
||||||
|
} finally {
|
||||||
|
await fh.close()
|
||||||
}
|
}
|
||||||
// If >30% non-printable characters, consider it binary
|
|
||||||
return nonPrintableCount / bytes.length > 0.3
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { test, expect, describe } from "bun:test"
|
||||||
|
import { resolvePluginProviders } from "../../src/cli/cmd/auth"
|
||||||
|
import type { Hooks } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
function hookWithAuth(provider: string): Hooks {
|
||||||
|
return {
|
||||||
|
auth: {
|
||||||
|
provider,
|
||||||
|
methods: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hookWithoutAuth(): Hooks {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolvePluginProviders", () => {
|
||||||
|
test("returns plugin providers not in models.dev", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithAuth("portkey")],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips providers already in models.dev", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithAuth("anthropic")],
|
||||||
|
existingProviders: { anthropic: {} },
|
||||||
|
disabled: new Set(),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deduplicates across plugins", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithAuth("portkey"), hookWithAuth("portkey")],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("respects disabled_providers", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithAuth("portkey")],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(["portkey"]),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("respects enabled_providers when provider is absent", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithAuth("portkey")],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(),
|
||||||
|
enabled: new Set(["anthropic"]),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes provider when in enabled set", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithAuth("portkey")],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(),
|
||||||
|
enabled: new Set(["portkey"]),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves name from providerNames", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithAuth("portkey")],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(),
|
||||||
|
providerNames: { portkey: "Portkey AI" },
|
||||||
|
})
|
||||||
|
expect(result).toEqual([{ id: "portkey", name: "Portkey AI" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to id when no name configured", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithAuth("portkey")],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips hooks without auth", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [hookWithoutAuth(), hookWithAuth("portkey"), hookWithoutAuth()],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty for no hooks", () => {
|
||||||
|
const result = resolvePluginProviders({
|
||||||
|
hooks: [],
|
||||||
|
existingProviders: {},
|
||||||
|
disabled: new Set(),
|
||||||
|
providerNames: {},
|
||||||
|
})
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -211,8 +211,8 @@ describe("tool.read truncation", () => {
|
|||||||
const read = await ReadTool.init()
|
const read = await ReadTool.init()
|
||||||
const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
|
const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
|
||||||
expect(result.metadata.truncated).toBe(true)
|
expect(result.metadata.truncated).toBe(true)
|
||||||
expect(result.output).toContain("Output truncated at")
|
expect(result.output).toContain("Output capped at")
|
||||||
expect(result.output).toContain("bytes")
|
expect(result.output).toContain("Use offset=")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -230,7 +230,8 @@ describe("tool.read truncation", () => {
|
|||||||
const read = await ReadTool.init()
|
const read = await ReadTool.init()
|
||||||
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
|
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
|
||||||
expect(result.metadata.truncated).toBe(true)
|
expect(result.metadata.truncated).toBe(true)
|
||||||
expect(result.output).toContain("File has more lines")
|
expect(result.output).toContain("Showing lines 1-10 of 100")
|
||||||
|
expect(result.output).toContain("Use offset=11")
|
||||||
expect(result.output).toContain("line0")
|
expect(result.output).toContain("line0")
|
||||||
expect(result.output).toContain("line9")
|
expect(result.output).toContain("line9")
|
||||||
expect(result.output).not.toContain("line10")
|
expect(result.output).not.toContain("line10")
|
||||||
@@ -267,6 +268,10 @@ describe("tool.read truncation", () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const read = await ReadTool.init()
|
const read = await ReadTool.init()
|
||||||
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
|
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
|
||||||
|
expect(result.output).toContain("10: line10")
|
||||||
|
expect(result.output).toContain("14: line14")
|
||||||
|
expect(result.output).not.toContain("9: line10")
|
||||||
|
expect(result.output).not.toContain("15: line15")
|
||||||
expect(result.output).toContain("line10")
|
expect(result.output).toContain("line10")
|
||||||
expect(result.output).toContain("line14")
|
expect(result.output).toContain("line14")
|
||||||
expect(result.output).not.toContain("line0")
|
expect(result.output).not.toContain("line0")
|
||||||
@@ -293,6 +298,40 @@ describe("tool.read truncation", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("allows reading empty file at default offset", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "empty.txt"), "")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const read = await ReadTool.init()
|
||||||
|
const result = await read.execute({ filePath: path.join(tmp.path, "empty.txt") }, ctx)
|
||||||
|
expect(result.metadata.truncated).toBe(false)
|
||||||
|
expect(result.output).toContain("End of file - total 0 lines")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws when offset > 1 for empty file", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "empty.txt"), "")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const read = await ReadTool.init()
|
||||||
|
await expect(read.execute({ filePath: path.join(tmp.path, "empty.txt"), offset: 2 }, ctx)).rejects.toThrow(
|
||||||
|
"Offset 2 is out of range for this file (0 lines)",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("does not mark final directory page as truncated", async () => {
|
test("does not mark final directory page as truncated", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
@@ -324,7 +363,7 @@ describe("tool.read truncation", () => {
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const read = await ReadTool.init()
|
const read = await ReadTool.init()
|
||||||
const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
|
const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
|
||||||
expect(result.output).toContain("...")
|
expect(result.output).toContain("(line truncated to 2000 chars)")
|
||||||
expect(result.output.length).toBeLessThan(3000)
|
expect(result.output.length).toBeLessThan(3000)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -425,3 +464,40 @@ describe("tool.read loaded instructions", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("tool.read binary detection", () => {
|
||||||
|
test("rejects text extension files with null bytes", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
|
||||||
|
await Bun.write(path.join(dir, "null-byte.txt"), bytes)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const read = await ReadTool.init()
|
||||||
|
await expect(read.execute({ filePath: path.join(tmp.path, "null-byte.txt") }, ctx)).rejects.toThrow(
|
||||||
|
"Cannot read binary file",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects known binary extensions", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "module.wasm"), "not really wasm")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const read = await ReadTool.init()
|
||||||
|
await expect(read.execute({ filePath: path.join(tmp.path, "module.wasm") }, ctx)).rejects.toThrow(
|
||||||
|
"Cannot read binary 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"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,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": [
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-size="small"] {
|
&[data-size="small"] {
|
||||||
height: 22px;
|
height: 24px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
&[data-icon] {
|
&[data-icon] {
|
||||||
padding: 0 12px 0 4px;
|
padding: 0 12px 0 4px;
|
||||||
@@ -129,8 +129,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-size="normal"] {
|
&[data-size="normal"] {
|
||||||
height: 24px;
|
height: 28px;
|
||||||
line-height: 24px;
|
line-height: 28px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
&[data-icon] {
|
&[data-icon] {
|
||||||
padding: 0 12px 0 4px;
|
padding: 0 12px 0 4px;
|
||||||
@@ -179,6 +179,10 @@
|
|||||||
background-color: var(--surface-base-active);
|
background-color: var(--surface-base-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] {
|
||||||
|
color: var(--icon-strong-base);
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
|
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
|
||||||
background-color: var(--surface-base-active);
|
background-color: var(--surface-base-active);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { JSX } from "solid-js"
|
||||||
|
|
||||||
|
export function DockPrompt(props: {
|
||||||
|
kind: "question" | "permission"
|
||||||
|
header: JSX.Element
|
||||||
|
children: JSX.Element
|
||||||
|
footer: JSX.Element
|
||||||
|
ref?: (el: HTMLDivElement) => void
|
||||||
|
}) {
|
||||||
|
const slot = (name: string) => `${props.kind}-${name}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
||||||
|
<div data-slot={slot("body")}>
|
||||||
|
<div data-slot={slot("header")}>{props.header}</div>
|
||||||
|
<div data-slot={slot("content")}>{props.children}</div>
|
||||||
|
</div>
|
||||||
|
<div data-slot={slot("footer")}>{props.footer}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,3 +3,23 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
File tree: show monochrome weak icons by default.
|
||||||
|
On hover, show the original file-type colors.
|
||||||
|
*/
|
||||||
|
[data-component="filetree"] .filetree-icon--mono {
|
||||||
|
color: var(--icon-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="filetree"] .filetree-iconpair {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="filetree"] .filetree-iconpair [data-component="file-icon"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user