mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-19 07:14:20 +00:00
Compare commits
45 Commits
optimize-a
...
github-v1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a9841322 | ||
|
|
8bf06cbcc1 | ||
|
|
be2e6f1926 | ||
|
|
72c12d59af | ||
|
|
8408e4702e | ||
|
|
ef14f64f9e | ||
|
|
3f60a6c2a4 | ||
|
|
d447b7694a | ||
|
|
5638b782c5 | ||
|
|
eb3f337695 | ||
|
|
c88ff3c08b | ||
|
|
e0e8b94384 | ||
|
|
8f4a72c57a | ||
|
|
ef155f3766 | ||
|
|
82a323ef70 | ||
|
|
a500eaa2d4 | ||
|
|
b714bb21d2 | ||
|
|
472d01fbaf | ||
|
|
a5c15a23e4 | ||
|
|
3d189b42a3 | ||
|
|
91a3ee642d | ||
|
|
37b24f4870 | ||
|
|
38572b8175 | ||
|
|
fc1addb8f4 | ||
|
|
83b7d8e04c | ||
|
|
d27dbfe062 | ||
|
|
f8904e3972 | ||
|
|
4a5823562c | ||
|
|
3aaf29b693 | ||
|
|
6b29896a35 | ||
|
|
1bb8574179 | ||
|
|
2611c35acc | ||
|
|
00c238777a | ||
|
|
e4b548fa76 | ||
|
|
e132dd2c70 | ||
|
|
fbe9669c57 | ||
|
|
c34ad7223a | ||
|
|
cc86a64bb5 | ||
|
|
3394402aef | ||
|
|
6cd3a59022 | ||
|
|
5aeb305344 | ||
|
|
6eb043aedb | ||
|
|
e96f6385c2 | ||
|
|
1109a282e0 | ||
|
|
25f3eef957 |
11
.github/actions/setup-bun/action.yml
vendored
11
.github/actions/setup-bun/action.yml
vendored
@@ -3,12 +3,13 @@ description: "Setup Bun with caching and install dependencies"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Mount Bun Cache
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
uses: useblacksmith/stickydisk@v1
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
|
||||
path: ~/.bun
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
46
.github/workflows/nix-desktop.yml.disabled
vendored
46
.github/workflows/nix-desktop.yml.disabled
vendored
@@ -1,46 +0,0 @@
|
||||
name: nix-desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
- "packages/app/**"
|
||||
- "packages/desktop/**"
|
||||
- ".github/workflows/nix-desktop.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
- "packages/app/**"
|
||||
- "packages/desktop/**"
|
||||
- ".github/workflows/nix-desktop.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
nix-desktop:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- macos-15-intel
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
|
||||
- name: Build desktop via flake
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix --version
|
||||
nix build .#desktop -L
|
||||
95
.github/workflows/nix-eval.yml
vendored
Normal file
95
.github/workflows/nix-eval.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: nix-eval
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nix-eval:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
|
||||
- name: Evaluate flake outputs (all systems)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix --version
|
||||
|
||||
echo "=== Flake metadata ==="
|
||||
nix flake metadata
|
||||
|
||||
echo ""
|
||||
echo "=== Flake structure ==="
|
||||
nix flake show --all-systems
|
||||
|
||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||
PACKAGES="opencode"
|
||||
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
|
||||
OPTIONAL_PACKAGES="desktop"
|
||||
|
||||
echo ""
|
||||
echo "=== Evaluating packages for all systems ==="
|
||||
for system in $SYSTEMS; do
|
||||
echo ""
|
||||
echo "--- $system ---"
|
||||
for pkg in $PACKAGES; do
|
||||
printf " %s: " "$pkg"
|
||||
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
|
||||
echo "✓"
|
||||
else
|
||||
echo "✗"
|
||||
echo "::error::Evaluation failed for packages.$system.$pkg"
|
||||
echo "$output"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Evaluating optional packages ==="
|
||||
for system in $SYSTEMS; do
|
||||
echo ""
|
||||
echo "--- $system ---"
|
||||
for pkg in $OPTIONAL_PACKAGES; do
|
||||
printf " %s: " "$pkg"
|
||||
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
|
||||
echo "✓"
|
||||
else
|
||||
echo "✗"
|
||||
echo "::warning::Evaluation failed for packages.$system.$pkg"
|
||||
echo "$output"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Evaluating devShells for all systems ==="
|
||||
for system in $SYSTEMS; do
|
||||
printf "%s: " "$system"
|
||||
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
|
||||
echo "✓"
|
||||
else
|
||||
echo "✗"
|
||||
echo "::error::Evaluation failed for devShells.$system.default"
|
||||
echo "$output"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== All evaluations passed ==="
|
||||
2
.github/workflows/nix-hashes.yml
vendored
2
.github/workflows/nix-hashes.yml
vendored
@@ -6,7 +6,7 @@ permissions:
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, beta]
|
||||
paths:
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
|
||||
9
.zed/settings.json
Normal file
9
.zed/settings.json
Normal file
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- Requirements: Bun 1.3+
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
### Overview
|
||||
|
||||
117
bun.lock
117
bun.lock
@@ -14,10 +14,11 @@
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.17.23",
|
||||
"sst": "3.18.10",
|
||||
"turbo": "2.5.6",
|
||||
},
|
||||
},
|
||||
@@ -288,7 +289,7 @@
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@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",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
@@ -324,6 +325,7 @@
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "0.0.6",
|
||||
@@ -356,6 +358,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -989,7 +992,7 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1609,7 +1612,7 @@
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="],
|
||||
"@smithy/core": ["@smithy/core@3.23.2", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
|
||||
|
||||
@@ -1639,9 +1642,9 @@
|
||||
|
||||
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="],
|
||||
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.14", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag=="],
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.16", "", { "dependencies": { "@smithy/core": "^3.23.2", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA=="],
|
||||
|
||||
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.31", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg=="],
|
||||
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.33", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA=="],
|
||||
|
||||
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="],
|
||||
|
||||
@@ -1665,7 +1668,7 @@
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
|
||||
|
||||
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.3", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg=="],
|
||||
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.5", "", { "dependencies": { "@smithy/core": "^3.23.2", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
|
||||
|
||||
@@ -1681,9 +1684,9 @@
|
||||
|
||||
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
|
||||
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.30", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng=="],
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.32", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg=="],
|
||||
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.33", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA=="],
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.35", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q=="],
|
||||
|
||||
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="],
|
||||
|
||||
@@ -1917,6 +1920,8 @@
|
||||
|
||||
"@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/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
|
||||
@@ -1983,7 +1988,7 @@
|
||||
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251207.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5l51HlXjX7lXwo65DEl1IaCFLjmkMtL6K3NrSEamPNeNTtTQwZRa3pQ9V65dCglnnCQ0M3+VF1RqzC7FU0iDKg=="],
|
||||
|
||||
"@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="],
|
||||
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
|
||||
|
||||
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="],
|
||||
|
||||
@@ -2049,7 +2054,7 @@
|
||||
|
||||
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
|
||||
|
||||
@@ -2131,11 +2136,11 @@
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"b4a": ["b4a@1.7.4", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug=="],
|
||||
"b4a": ["b4a@1.7.5", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA=="],
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="],
|
||||
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
|
||||
|
||||
"babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
|
||||
|
||||
@@ -2211,13 +2216,13 @@
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
|
||||
|
||||
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eDgLN9teKTfmvrCqgwwmWNsNszxYs7IZdCqk0S1DCarvMhr4wcajoSBlA/nQA0/owwLduPTS8xxCnQp4/N/gDg=="],
|
||||
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="],
|
||||
|
||||
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-X+PjwJUWenUmdQBP8EtdItMyieQ6Nlpn+BH518oaouDiSnWj5+b0Y7DNDZJq7Ezom4EaxmqL/uGYZK3aCQ7CXg=="],
|
||||
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="],
|
||||
|
||||
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zMLs2YIGB+/jxrYFXaFhVKX/GBt05UTF45lc9srcHc9JXGjEj+12CIo1CHLTAWatXMTqt0Jsu6ukWEoWVT/ayA=="],
|
||||
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="],
|
||||
|
||||
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z5yAK28xrcm8Wb5k7TZ8FJKpOI/r+aVCRdlHYAqI2SDJFN3nD4mJs900X6kNVmG/xFzb5yOuKVYWGg+6ZXWbyA=="],
|
||||
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
@@ -2237,7 +2242,7 @@
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
@@ -2471,7 +2476,7 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
|
||||
|
||||
@@ -2707,7 +2712,7 @@
|
||||
|
||||
"h3": ["h3@2.0.1-rc.4", "", { "dependencies": { "rou3": "^0.7.8", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-vZq8pEUp6THsXKXrUXX44eOqfChic2wVQ1GlSzQCBr7DeFBkfIZAo2WyNND4GSv54TAa0E4LYIK73WSPdgKUgw=="],
|
||||
|
||||
"happy-dom": ["happy-dom@20.6.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^6.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ=="],
|
||||
"happy-dom": ["happy-dom@20.6.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Xk/Y0cuq9ngN/my8uvK4gKoyDl6sBKkIl8A/hJ0IabZVH7E5SJLHNE7uKRPVmSrQbhJaLIHTEcvTct4GgNtsRA=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
@@ -2919,7 +2924,7 @@
|
||||
|
||||
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
|
||||
|
||||
@@ -3497,7 +3502,7 @@
|
||||
|
||||
"punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
|
||||
|
||||
"qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
@@ -3759,23 +3764,23 @@
|
||||
|
||||
"srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="],
|
||||
|
||||
"sst": ["sst@3.17.23", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.23", "sst-darwin-x64": "3.17.23", "sst-linux-arm64": "3.17.23", "sst-linux-x64": "3.17.23", "sst-linux-x86": "3.17.23", "sst-win32-arm64": "3.17.23", "sst-win32-x64": "3.17.23", "sst-win32-x86": "3.17.23" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-TwKgUgDnZdc1Swe+bvCNeyO4dQnYz5cTodMpYj3jlXZdK9/KNz0PVxT1f0u5E76i1pmilXrUBL/f7iiMPw4RDg=="],
|
||||
"sst": ["sst@3.18.10", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.18.10", "sst-darwin-x64": "3.18.10", "sst-linux-arm64": "3.18.10", "sst-linux-x64": "3.18.10", "sst-linux-x86": "3.18.10", "sst-win32-arm64": "3.18.10", "sst-win32-x64": "3.18.10", "sst-win32-x86": "3.18.10" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-SY+ldeJ9K5E9q+DhjXA3e2W3BEOzBwkE3IyLSD71uA3/5nRhUAST31iOWEpW36LbIvSQ9uOVDFcebztoLJ8s7w=="],
|
||||
|
||||
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.23", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R6kvmF+rUideOoU7KBs2SdvrIupoE+b+Dor/eq9Uo4Dojj7KvYDZI/EDm8sSCbbcx/opiWeyNqKtlnLEdCxE6g=="],
|
||||
"sst-darwin-arm64": ["sst-darwin-arm64@3.18.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3MwIpMZhhdZKDqLp9ZQNlwkWix5+q+N0PWstuTomYwgZOxCCe6u9IIsoIszSk+GAJJN/jvGZyLiXKeV4iiQvw=="],
|
||||
|
||||
"sst-darwin-x64": ["sst-darwin-x64@3.17.23", "", { "os": "darwin", "cpu": "x64" }, "sha512-WW4P1S35iYCifQXxD+sE3wuzcN+LHLpuKMaNoaBqEcWGZnH3IPaDJ7rpLF0arkDAo/z3jZmWWzOCkr0JuqJ8vQ=="],
|
||||
"sst-darwin-x64": ["sst-darwin-x64@3.18.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-nQ0jMKkPOa+kj6Ygz8+kYhBua/vgNTLkd+4r8NSmk7v+Zs78lKnx3T//kEzS0yik6Q6QwGfokwrTcA1Jii2xSw=="],
|
||||
|
||||
"sst-linux-arm64": ["sst-linux-arm64@3.17.23", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjtNqgIh7RlAWgPLFCAt0mXvIB+J7WjmRvIRrAdX0mXsndOiBJ/DMOgXSLVsIWHCfPj8MIEot/hWpnJgXgIeag=="],
|
||||
"sst-linux-arm64": ["sst-linux-arm64@3.18.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-mj9VNj3SvLS+HaXx2PhCX0aTA7CwJNoM6JhRc0s/zCilqchcvqDjbhpYBJO4brEPv6aOaaa7T3WvIQqtYauK4Q=="],
|
||||
|
||||
"sst-linux-x64": ["sst-linux-x64@3.17.23", "", { "os": "linux", "cpu": "x64" }, "sha512-qdqJiEbYfCjZlI3F/TA6eoIU7JXVkEEI/UMILNf2JWhky0KQdCW2Xyz+wb6c0msVJCWdUM/uj+1DaiP2eXvghw=="],
|
||||
"sst-linux-x64": ["sst-linux-x64@3.18.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7iy1Eq2eqnT9Ag/8OVgC04vRjV7AAQyf/BvzLc+6Sz+GvRiKA8VEuPnbXNYQF+NIvEqsawfcd7MknSTtImpsvQ=="],
|
||||
|
||||
"sst-linux-x86": ["sst-linux-x86@3.17.23", "", { "os": "linux", "cpu": "none" }, "sha512-aGmUujIvoNlmAABEGsOgfY1rxD9koC6hN8bnTLbDI+oI/u/zjHYh50jsbL0p3TlaHpwF/lxP3xFSuT6IKp+KgA=="],
|
||||
"sst-linux-x86": ["sst-linux-x86@3.18.10", "", { "os": "linux", "cpu": "none" }, "sha512-77qZSuPZeQ5bdRCiq1pQEdY8EcGNHboKrx4P2yFid2FBDKJsXxOXtIxJdloyx+ljBn0+nxl/g040QBmXxdc9tA=="],
|
||||
|
||||
"sst-win32-arm64": ["sst-win32-arm64@3.17.23", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZxdkGqYDrrZGz98rijDCN+m5yuCcwD6Bc9/6hubLsvdpNlVorUqzpg801Ec97xSK0nIC9g6pNiRyxAcsQQstUg=="],
|
||||
"sst-win32-arm64": ["sst-win32-arm64@3.18.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aY+FhMxvYs8crlrKALpLn/kKmud8YQj6LkMHsrOAAIJhfNyxhCja2vrYQaY+bcqdsS5W2LMVcS2hyaMqKXZKcg=="],
|
||||
|
||||
"sst-win32-x64": ["sst-win32-x64@3.17.23", "", { "os": "win32", "cpu": "x64" }, "sha512-yc9cor4MS49Ccy2tQCF1tf6M81yLeSGzGL+gjhUxpVKo2pN3bxl3w70eyU/mTXSEeyAmG9zEfbt6FNu4sy5cUA=="],
|
||||
"sst-win32-x64": ["sst-win32-x64@3.18.10", "", { "os": "win32", "cpu": "x64" }, "sha512-rY+yJXOpG+P5xXnaQRpCvBK2zwwLhjzpYidGkp6F+cGgiVdh2Wre/CIQNRaVHr20ncj8lLe/RsHWa9QCNM48jg=="],
|
||||
|
||||
"sst-win32-x86": ["sst-win32-x86@3.17.23", "", { "os": "win32", "cpu": "none" }, "sha512-DIp3s54IpNAfdYjSRt6McvkbEPQDMxUu6RUeRAd2C+FcTJgTloon/ghAPQBaDgu2VoVgymjcJARO/XyfKcCLOQ=="],
|
||||
"sst-win32-x86": ["sst-win32-x86@3.18.10", "", { "os": "win32", "cpu": "none" }, "sha512-pq8SmV0pIjBFMY6DraUZ4akyTxHnfjIKCRbBLdMxFUZK8TzA1NK2YdjRt1AwrgXRYGRyctrz/mt4WyO0SMOVQQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
@@ -3841,19 +3846,19 @@
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
|
||||
"tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
|
||||
|
||||
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
|
||||
|
||||
"tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],
|
||||
|
||||
"tedious": ["tedious@19.2.1", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.5", "@types/node": ">=18", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA=="],
|
||||
"tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
|
||||
|
||||
"terracotta": ["terracotta@1.1.0", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-kfQciWUBUBgYkXu7gh3CK3FAJng/iqZslAaY08C+k1Hdx17aVEpcFFb/WPaysxAfcupNH3y53s/pc53xxZauww=="],
|
||||
|
||||
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
|
||||
|
||||
"text-decoder": ["text-decoder@1.2.4", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-mzlffA3tBNhziEHPK5L5InZg1d/ElNIpJhnhbDRNUtem/edZcJ5zg5FgwKKKOyklxk+6Jt+TrSu83musmvrDlg=="],
|
||||
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
@@ -3961,7 +3966,7 @@
|
||||
|
||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||
|
||||
"undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="],
|
||||
"undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
|
||||
|
||||
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
@@ -4127,7 +4132,7 @@
|
||||
|
||||
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
@@ -4277,8 +4282,6 @@
|
||||
|
||||
"@azure/core-http/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"@azure/core-http/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"@azure/core-xml/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
|
||||
|
||||
"@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
@@ -4299,7 +4302,7 @@
|
||||
|
||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.21.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA=="],
|
||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.22.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
@@ -4501,7 +4504,9 @@
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
|
||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.63", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
|
||||
|
||||
@@ -4509,6 +4514,8 @@
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
@@ -4529,6 +4536,8 @@
|
||||
|
||||
"aws-sdk/events": ["events@1.1.1", "", {}, "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw=="],
|
||||
|
||||
"aws-sdk/xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||
|
||||
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||
@@ -4539,6 +4548,10 @@
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"body-parser/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
|
||||
|
||||
"buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
|
||||
|
||||
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
@@ -4573,6 +4586,8 @@
|
||||
|
||||
"express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
|
||||
|
||||
"express/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
|
||||
|
||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
@@ -4581,7 +4596,7 @@
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
|
||||
"glob/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
@@ -4619,8 +4634,6 @@
|
||||
|
||||
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"mssql/tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
@@ -4649,10 +4662,10 @@
|
||||
|
||||
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
|
||||
|
||||
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||
@@ -4709,6 +4722,8 @@
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
@@ -4725,6 +4740,8 @@
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
|
||||
|
||||
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
@@ -4745,6 +4762,8 @@
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
|
||||
|
||||
"yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
|
||||
@@ -5005,8 +5024,6 @@
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
|
||||
@@ -5015,7 +5032,7 @@
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
@@ -5035,6 +5052,8 @@
|
||||
|
||||
"astro/unstorage/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"aws-sdk/xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
|
||||
|
||||
"babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
|
||||
|
||||
"babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
|
||||
@@ -5069,8 +5088,6 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
1
github/sst-env.d.ts
vendored
1
github/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../sst-env.d.ts" />
|
||||
|
||||
|
||||
@@ -214,9 +214,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
},
|
||||
transform: {
|
||||
server: {
|
||||
placement: { region: "aws:us-east-1" },
|
||||
transform: {
|
||||
worker: {
|
||||
placement: { mode: "smart" },
|
||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
|
||||
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
|
||||
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
|
||||
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
|
||||
"x86_64-linux": "sha256-5sXHoHbRdXbqM/zRJZiXt26sm/yyyZN/4OOHUtdofhk=",
|
||||
"aarch64-linux": "sha256-JCMm5X7e27BBV4wyaknCMM4CBt4Lr72SSvaGxEeNsJE=",
|
||||
"aarch64-darwin": "sha256-DBQJURlTPqFt0OYUHSvZZ4H0NUf020aic4zNX5CXzDc=",
|
||||
"x86_64-darwin": "sha256-t2luVxqCcRSgq/WNWkm4ZpKXO22n2RnAWP6msoTOr+A="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +69,11 @@
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.17.23",
|
||||
"sst": "3.18.10",
|
||||
"turbo": "2.5.6"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
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 { 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 { GlobalSyncProvider } from "@/context/global-sync"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { Navigate, Route, Router } from "@solidjs/router"
|
||||
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 { 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 { 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 Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
const Loading = () => <div class="size-full" />
|
||||
@@ -57,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
|
||||
|
||||
declare global {
|
||||
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) {
|
||||
return (
|
||||
<MetaProvider>
|
||||
@@ -157,27 +138,19 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
function ServerKey(props: ParentProps) {
|
||||
const server = useServer()
|
||||
return (
|
||||
<Show when={server.url} keyed>
|
||||
<Show when={server.key} keyed>
|
||||
{props.children}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
||||
const platform = usePlatform()
|
||||
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
|
||||
const defaultServerUrl = resolveDefaultServerUrl({
|
||||
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,
|
||||
})
|
||||
|
||||
export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/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 { 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 { 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 { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
interface AddRowProps {
|
||||
@@ -89,7 +88,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const result = await checkServerHealth(normalized, fetcher)
|
||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
@@ -171,14 +170,13 @@ export function DialogSelectServer() {
|
||||
const dialog = useDialog()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||
const { previewStatus } = useServerPreview(fetcher)
|
||||
let listRoot: HTMLDivElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<string, ServerHealth | undefined>,
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
adding: false,
|
||||
@@ -214,24 +212,25 @@ export function DialogSelectServer() {
|
||||
})
|
||||
}
|
||||
|
||||
const replaceServer = (original: string, next: string) => {
|
||||
const active = server.url
|
||||
const nextActive = active === original ? next : active
|
||||
const replaceServer = (original: ServerConnection.Http, next: string) => {
|
||||
const active = server.key
|
||||
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)
|
||||
server.remove(original)
|
||||
server.remove(ServerConnection.key(original))
|
||||
}
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.url
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
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 list = items()
|
||||
@@ -246,17 +245,17 @@ export function DialogSelectServer() {
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === 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
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshHealth() {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
items().map(async (url) => {
|
||||
results[url] = await checkServerHealth(url, fetcher)
|
||||
items().map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
}),
|
||||
)
|
||||
setStore("status", reconcile(results))
|
||||
@@ -269,15 +268,15 @@ export function DialogSelectServer() {
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
async function select(value: string, persist?: boolean) {
|
||||
if (!persist && store.status[value]?.healthy === false) return
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||
dialog.close()
|
||||
if (persist) {
|
||||
server.add(value)
|
||||
server.add(conn.http.url)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
server.setActive(value)
|
||||
server.setActive(ServerConnection.key(conn))
|
||||
navigate("/")
|
||||
}
|
||||
|
||||
@@ -311,7 +310,7 @@ export function DialogSelectServer() {
|
||||
|
||||
setStore("addServer", { adding: true, error: "" })
|
||||
|
||||
const result = await checkServerHealth(normalized, fetcher)
|
||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||
setStore("addServer", { adding: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
@@ -320,25 +319,25 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select(normalized, true)
|
||||
await select({ type: "http", http: { url: normalized } }, true)
|
||||
}
|
||||
|
||||
async function handleEdit(original: string, value: string) {
|
||||
if (store.editServer.busy) return
|
||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||
if (store.editServer.busy || original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
if (normalized === original) {
|
||||
if (normalized === original.http.url) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("editServer", { busy: true, error: "" })
|
||||
|
||||
const result = await checkServerHealth(normalized, fetcher)
|
||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||
setStore("editServer", { busy: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
@@ -366,7 +365,7 @@ export function DialogSelectServer() {
|
||||
handleAdd(store.addServer.url)
|
||||
}
|
||||
|
||||
const handleEditKey = (event: KeyboardEvent, original: string) => {
|
||||
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
@@ -378,7 +377,7 @@ export function DialogSelectServer() {
|
||||
handleEdit(original, store.editServer.value)
|
||||
}
|
||||
|
||||
async function handleRemove(url: string) {
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||
platform.setDefaultServerUrl?.(null)
|
||||
@@ -390,11 +389,14 @@ export function DialogSelectServer() {
|
||||
<div class="flex flex-col gap-2">
|
||||
<div ref={(el) => (listRoot = el)}>
|
||||
<List
|
||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||
search={{
|
||||
placeholder: language.t("dialog.server.search.placeholder"),
|
||||
autofocus: false,
|
||||
}}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={sortedItems}
|
||||
key={(x) => x}
|
||||
key={(x) => x.http.url}
|
||||
onSelect={(x) => {
|
||||
if (x) select(x)
|
||||
}}
|
||||
@@ -428,7 +430,7 @@ export function DialogSelectServer() {
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
when={store.editServer.id !== i}
|
||||
when={store.editServer.id !== i.http.url}
|
||||
fallback={
|
||||
<EditRow
|
||||
value={store.editServer.value}
|
||||
@@ -443,12 +445,12 @@ export function DialogSelectServer() {
|
||||
}
|
||||
>
|
||||
<ServerRow
|
||||
url={i}
|
||||
status={store.status[i]}
|
||||
dimmed={store.status[i]?.healthy === false}
|
||||
conn={i}
|
||||
status={store.status[ServerConnection.key(i)]}
|
||||
dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i}>
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -456,59 +458,63 @@ export function DialogSelectServer() {
|
||||
}
|
||||
/>
|
||||
</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">
|
||||
<Show when={current() === i}>
|
||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||
</Show>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setStore("editServer", {
|
||||
id: i,
|
||||
value: i,
|
||||
error: "",
|
||||
status: store.status[i]?.healthy,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultUrl() !== i}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(i)}>
|
||||
<Show when={i.type === "http"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setStore("editServer", {
|
||||
id: i.http.url,
|
||||
value: i.http.url,
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(i)]?.healthy,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<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>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultUrl() === i}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{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>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -550,8 +550,15 @@ export default function FileTree(props: {
|
||||
</Match>
|
||||
<Match when={!node.ignored}>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
<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>
|
||||
|
||||
@@ -1375,8 +1375,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
name={mode === "shell" ? "console" : "prompt"}
|
||||
class="size-[18px]"
|
||||
classList={{
|
||||
"text-icon-strong-base": mode === "shell" && store.mode === "shell",
|
||||
"text-icon-interactive-base": mode === "normal" && store.mode === "normal",
|
||||
"text-icon-strong-base": store.mode === mode,
|
||||
"text-icon-weak": store.mode !== mode,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -12,24 +12,27 @@ let selected = "/repo/worktree-a"
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
|
||||
const clientFor = (directory: string) => ({
|
||||
session: {
|
||||
create: async () => {
|
||||
createdSessions.push(directory)
|
||||
return { data: { id: `session-${createdSessions.length}` } }
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
return {
|
||||
session: {
|
||||
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 () => {
|
||||
sentShell.push(directory)
|
||||
return { data: undefined }
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
},
|
||||
prompt: async () => ({ data: undefined }),
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
@@ -88,11 +91,17 @@ beforeAll(async () => {
|
||||
}))
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => ({
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
url: "http://localhost:4096",
|
||||
}),
|
||||
useSDK: () => {
|
||||
const sdk = {
|
||||
directory: "/repo/main",
|
||||
client: rootClient,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
},
|
||||
}
|
||||
return sdk
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { Accessor } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
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 { useLocal } from "@/context/local"
|
||||
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
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 { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const platform = usePlatform()
|
||||
const local = useLocal()
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
@@ -175,9 +173,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: platform.fetch,
|
||||
client = sdk.createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
@@ -372,7 +368,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { serverDisplayName } from "@/context/server"
|
||||
import {
|
||||
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"
|
||||
|
||||
interface ServerRowProps extends ParentProps {
|
||||
url: string
|
||||
conn: ServerConnection.Any
|
||||
status?: ServerHealth
|
||||
class?: string
|
||||
nameClass?: string
|
||||
@@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
const name = createMemo(() => serverDisplayName(props.url))
|
||||
const name = createMemo(() => serverDisplayName(props.conn))
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
@@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
|
||||
|
||||
createEffect(() => {
|
||||
name()
|
||||
props.url
|
||||
props.conn.http.url
|
||||
props.status?.version
|
||||
queueMicrotask(check)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
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 { 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> = {
|
||||
system: "var(--syntax-info)",
|
||||
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 sync = useSync()
|
||||
const layout = useLayout()
|
||||
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(
|
||||
() =>
|
||||
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 formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
||||
|
||||
@@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
})
|
||||
|
||||
const counts = createMemo(() => {
|
||||
const all = props.messages()
|
||||
const all = messages()
|
||||
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)
|
||||
return {
|
||||
@@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
})
|
||||
|
||||
const systemPrompt = createMemo(() => {
|
||||
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
|
||||
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
|
||||
const system = msg?.system
|
||||
if (!system) return
|
||||
const trimmed = system.trim()
|
||||
@@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
|
||||
const breakdown = createMemo(
|
||||
on(
|
||||
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
||||
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
|
||||
() => {
|
||||
const c = ctx()
|
||||
if (!c?.input) return []
|
||||
return estimateSessionContextBreakdown({
|
||||
messages: props.messages(),
|
||||
messages: messages(),
|
||||
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||
input: c.input,
|
||||
systemPrompt: systemPrompt(),
|
||||
@@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
}
|
||||
|
||||
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.provider", value: providerLabel },
|
||||
{ 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.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
||||
{ 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) },
|
||||
] satisfies { label: string; value: () => JSX.Element }[]
|
||||
|
||||
@@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const s = props.view()?.scroll("context")
|
||||
const s = view().scroll("context")
|
||||
if (!s) return
|
||||
|
||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||
@@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
pending = undefined
|
||||
if (!next) return
|
||||
|
||||
props.view().setScroll("context", next)
|
||||
view().setScroll("context", next)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.messages().length,
|
||||
() => messages().length,
|
||||
() => {
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
@@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||
<Accordion multiple>
|
||||
<For each={props.messages()}>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||
)}
|
||||
|
||||
@@ -431,7 +431,7 @@ export const SettingsGeneral: Component = () => {
|
||||
|
||||
<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 enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
|
||||
@@ -457,7 +457,7 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Show>*/}
|
||||
|
||||
<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 { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
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 { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogSelectServer } from "./dialog-select-server"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
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 { DialogSelectServer } from "./dialog-select-server"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: string[],
|
||||
active: string | undefined,
|
||||
status: Record<string, ServerHealth | undefined>,
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
@@ -45,16 +45,16 @@ const listServersByHealth = (
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(status[a]) - rank(status[b])
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
|
||||
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
const list = servers()
|
||||
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (url) => {
|
||||
results[url] = await checkServerHealth(url, fetcher)
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerUrl = (
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | 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: {
|
||||
@@ -163,16 +170,16 @@ export function StatusPopover() {
|
||||
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const servers = createMemo(() => {
|
||||
const current = server.url
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((item) => item !== current)]
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
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 defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
@@ -251,8 +258,9 @@ export function StatusPopover() {
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(url) => {
|
||||
const isBlocked = () => health[url]?.healthy === false
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const isBlocked = () => health[key]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -264,19 +272,19 @@ export function StatusPopover() {
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
server.setActive(url)
|
||||
server.setActive(key)
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<ServerRow
|
||||
url={url}
|
||||
status={health[url]}
|
||||
conn={s}
|
||||
status={health[key]}
|
||||
dimmed={isBlocked()}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
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">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
@@ -284,7 +292,7 @@ export function StatusPopover() {
|
||||
}
|
||||
>
|
||||
<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" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||
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 { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import { parseKeybind, matchKeybind } from "@/context/command"
|
||||
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 type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
|
||||
@@ -106,8 +107,14 @@ const useTerminalUiBindings = (input: {
|
||||
input.container.addEventListener("pointerdown", input.handlePointerDown)
|
||||
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
|
||||
|
||||
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
|
||||
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
|
||||
input.container.addEventListener("click", input.handleLinkClick, {
|
||||
capture: true,
|
||||
})
|
||||
input.cleanups.push(() =>
|
||||
input.container.removeEventListener("click", input.handleLinkClick, {
|
||||
capture: true,
|
||||
}),
|
||||
)
|
||||
|
||||
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
|
||||
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
|
||||
@@ -148,6 +155,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
||||
let ws: WebSocket | undefined
|
||||
@@ -372,7 +380,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
serializeAddon = serializer
|
||||
|
||||
t.open(container)
|
||||
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
|
||||
useTerminalUiBindings({
|
||||
container,
|
||||
term: t,
|
||||
cleanups,
|
||||
handlePointerDown,
|
||||
handleLinkClick,
|
||||
})
|
||||
|
||||
focusTerminal()
|
||||
|
||||
@@ -428,10 +442,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
}
|
||||
url.username = server.current?.http.username ?? ""
|
||||
url.password = server.current?.http.password ?? ""
|
||||
const socket = new WebSocket(url)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
|
||||
@@ -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 { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import z from "zod"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
|
||||
@@ -17,20 +18,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
const platform = usePlatform()
|
||||
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 = (() => {
|
||||
if (!platform.fetch) return
|
||||
if (!platform.fetch || !server.current) return
|
||||
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"
|
||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||
} catch {
|
||||
@@ -38,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
}
|
||||
})()
|
||||
|
||||
const eventSdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
const currentServer = server.current
|
||||
if (!currentServer) throw new Error("No server available")
|
||||
|
||||
const eventSdk = createSdkForServer({
|
||||
signal: abort.signal,
|
||||
fetch: eventFetch,
|
||||
headers: eventFetch ? undefined : auth,
|
||||
server: currentServer.http,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
@@ -133,7 +126,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: server.url,
|
||||
url: currentServer.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
@@ -166,7 +159,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
if (!aborted(error) && !streamErrorLogged) {
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream failed", {
|
||||
url: server.url,
|
||||
url: currentServer.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
@@ -200,12 +193,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
flush()
|
||||
})
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
const sdk = createSdkForServer({
|
||||
server: server.current.http,
|
||||
fetch: platform.fetch,
|
||||
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,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -30,7 +30,6 @@ describe("pickDirectoriesToEvict", () => {
|
||||
describe("loadRootSessionsWithFallback", () => {
|
||||
test("uses limited roots query when supported", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
let fallback = 0
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
@@ -39,20 +38,15 @@ describe("loadRootSessionsWithFallback", () => {
|
||||
calls.push(query)
|
||||
return { data: [] }
|
||||
},
|
||||
onFallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(true)
|
||||
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
||||
expect(fallback).toBe(0)
|
||||
})
|
||||
|
||||
test("falls back to full roots query on limited-query failure", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
let fallback = 0
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
@@ -62,9 +56,6 @@ describe("loadRootSessionsWithFallback", () => {
|
||||
if (query.limit) throw new Error("unsupported")
|
||||
return { data: [] }
|
||||
},
|
||||
onFallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
@@ -73,7 +64,6 @@ describe("loadRootSessionsWithFallback", () => {
|
||||
{ directory: "dir", roots: true, limit: 25 },
|
||||
{ directory: "dir", roots: true },
|
||||
])
|
||||
expect(fallback).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import {
|
||||
type Config,
|
||||
type Path,
|
||||
type Project,
|
||||
type ProviderAuthResponse,
|
||||
type ProviderListResponse,
|
||||
type Todo,
|
||||
createOpencodeClient,
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
untrack,
|
||||
getOwner,
|
||||
useContext,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
Switch,
|
||||
Match,
|
||||
untrack,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { usePlatform } from "./platform"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
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 { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { sanitizeProject } from "./global-sync/utils"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -57,14 +57,6 @@ function errorMessage(error: unknown) {
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
function setDevStats(value: {
|
||||
activeDirectoryStores: number
|
||||
evictions: number
|
||||
loadSessionsFullFetchFallback: number
|
||||
}) {
|
||||
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
@@ -72,12 +64,7 @@ function createGlobalSync() {
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
||||
|
||||
const stats = {
|
||||
evictions: 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 sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
@@ -112,15 +99,6 @@ function createGlobalSync() {
|
||||
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
|
||||
}
|
||||
|
||||
const updateStats = (activeDirectoryStores: number) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
setDevStats({
|
||||
activeDirectoryStores,
|
||||
evictions: stats.evictions,
|
||||
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
|
||||
})
|
||||
}
|
||||
|
||||
const paused = () => untrack(() => globalStore.reload) !== undefined
|
||||
|
||||
const queue = createRefreshQueue({
|
||||
@@ -131,11 +109,6 @@ function createGlobalSync() {
|
||||
|
||||
const children = createChildStoreManager({
|
||||
owner,
|
||||
markStats: updateStats,
|
||||
incrementEvictions: () => {
|
||||
stats.evictions += 1
|
||||
updateStats(Object.keys(children.children).length)
|
||||
},
|
||||
isBooting: (directory) => booting.has(directory),
|
||||
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
||||
onBootstrap: (directory) => {
|
||||
@@ -151,9 +124,7 @@ function createGlobalSync() {
|
||||
const sdkFor = (directory: string) => {
|
||||
const cached = sdkCache.get(directory)
|
||||
if (cached) return cached
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
const sdk = globalSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
@@ -193,7 +164,10 @@ function createGlobalSync() {
|
||||
const [store, setStore] = children.child(directory, { bootstrap: false })
|
||||
const meta = sessionMeta.get(directory)
|
||||
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) {
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
}
|
||||
@@ -206,10 +180,6 @@ function createGlobalSync() {
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => globalSDK.client.session.list(query),
|
||||
onFallback: () => {
|
||||
stats.loadSessionsFallback += 1
|
||||
updateStats(Object.keys(children.children).length)
|
||||
},
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
@@ -218,10 +188,17 @@ function createGlobalSync() {
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
const limit = store.limit
|
||||
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(
|
||||
"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" }))
|
||||
sessionMeta.set(directory, { limit })
|
||||
@@ -331,7 +308,9 @@ function createGlobalSync() {
|
||||
await bootstrapGlobal({
|
||||
globalSDK: globalSDK.client,
|
||||
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"),
|
||||
setGlobalStore,
|
||||
})
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import {
|
||||
type Config,
|
||||
type Path,
|
||||
type PermissionRequest,
|
||||
type Project,
|
||||
type ProviderAuthResponse,
|
||||
type ProviderListResponse,
|
||||
type QuestionRequest,
|
||||
type Todo,
|
||||
createOpencodeClient,
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Todo,
|
||||
} 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 { 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 { cmp, normalizeProviderList } from "./utils"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -31,7 +31,7 @@ type GlobalStore = {
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: ReturnType<typeof createOpencodeClient>
|
||||
globalSDK: OpencodeClient
|
||||
connectErrorTitle: string
|
||||
connectErrorDescription: string
|
||||
requestFailedTitle: string
|
||||
@@ -110,7 +110,7 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: ReturnType<typeof createOpencodeClient>
|
||||
sdk: OpencodeClient
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
vcsCache: VcsCache
|
||||
|
||||
@@ -17,8 +17,6 @@ describe("createChildStoreManager", () => {
|
||||
|
||||
const manager = createChildStoreManager({
|
||||
owner,
|
||||
markStats() {},
|
||||
incrementEvictions() {},
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap() {},
|
||||
|
||||
@@ -17,8 +17,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||
|
||||
export function createChildStoreManager(input: {
|
||||
owner: Owner
|
||||
markStats: (activeDirectoryStores: number) => void
|
||||
incrementEvictions: () => void
|
||||
isBooting: (directory: string) => boolean
|
||||
isLoadingSessions: (directory: string) => boolean
|
||||
onBootstrap: (directory: string) => void
|
||||
@@ -102,7 +100,6 @@ export function createChildStoreManager(input: {
|
||||
}
|
||||
delete children[directory]
|
||||
input.onDispose(directory)
|
||||
input.markStats(Object.keys(children).length)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -120,7 +117,6 @@ export function createChildStoreManager(input: {
|
||||
if (list.length === 0) return
|
||||
for (const directory of list) {
|
||||
if (!disposeDirectory(directory)) continue
|
||||
input.incrementEvictions()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +196,6 @@ export function createChildStoreManager(input: {
|
||||
})
|
||||
|
||||
runWithOwner(input.owner, init)
|
||||
input.markStats(Object.keys(children).length)
|
||||
}
|
||||
mark(directory)
|
||||
const childStore = children[directory]
|
||||
|
||||
@@ -9,7 +9,6 @@ export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
||||
limited: true,
|
||||
} as const
|
||||
} catch {
|
||||
input.onFallback()
|
||||
const result = await input.list({ directory: input.directory, roots: true })
|
||||
return {
|
||||
data: result.data,
|
||||
|
||||
@@ -119,7 +119,6 @@ export type RootLoadArgs = {
|
||||
directory: string
|
||||
limit: number
|
||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||
onFallback: () => void
|
||||
}
|
||||
|
||||
export type RootLoadResult = {
|
||||
|
||||
@@ -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 { 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 { usePlatform } from "./platform"
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
@@ -12,14 +11,11 @@ type SDKEventMap = {
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { directory: Accessor<string> }) => {
|
||||
const platform = usePlatform()
|
||||
const globalSDK = useGlobalSDK()
|
||||
|
||||
const directory = createMemo(props.directory)
|
||||
const client = createMemo(() =>
|
||||
createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
globalSDK.createClient({
|
||||
directory: directory(),
|
||||
throwOnError: true,
|
||||
}),
|
||||
@@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
get 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 { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -15,92 +15,118 @@ export function normalizeServerUrl(input: string) {
|
||||
return withProtocol.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export function serverDisplayName(url: string) {
|
||||
if (!url) return ""
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||
export function serverDisplayName(conn?: ServerConnection.Any) {
|
||||
if (!conn) return ""
|
||||
if (conn.displayName) return conn.displayName
|
||||
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function projectsKey(url: string) {
|
||||
if (!url) return ""
|
||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||
function projectsKey(key: ServerConnection.Key) {
|
||||
if (!key) return ""
|
||||
if (key === "sidecar") return "local"
|
||||
const host = key.replace(/^https?:\/\//, "").split(":")[0]
|
||||
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({
|
||||
name: "Server",
|
||||
init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
|
||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||
const platform = usePlatform()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
createStore({
|
||||
list: [] as string[],
|
||||
currentSidecarUrl: "",
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
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({
|
||||
active: "",
|
||||
active: props.defaultServer,
|
||||
healthy: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
const healthy = () => state.healthy
|
||||
|
||||
const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
|
||||
|
||||
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) {
|
||||
function startHealthPolling(conn: ServerConnection.Any) {
|
||||
let alive = true
|
||||
let busy = false
|
||||
|
||||
const run = () => {
|
||||
if (busy) return
|
||||
busy = true
|
||||
void check(url)
|
||||
void check(conn)
|
||||
.then((next) => {
|
||||
if (!alive) return
|
||||
setState("healthy", next)
|
||||
@@ -118,59 +144,73 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}
|
||||
}
|
||||
|
||||
function setActive(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
setState("active", url)
|
||||
function setActive(input: ServerConnection.Key) {
|
||||
if (state.active !== input) setState("active", input)
|
||||
}
|
||||
|
||||
function add(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
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) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
updateServerList(url, true)
|
||||
function remove(key: ServerConnection.Key) {
|
||||
const list = store.list.filter((x) => x !== key)
|
||||
batch(() => {
|
||||
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 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(() => {
|
||||
const url = state.active
|
||||
if (!url) return
|
||||
const current_ = current()
|
||||
if (!current_) return
|
||||
|
||||
setState("healthy", undefined)
|
||||
onCleanup(startHealthPolling(url))
|
||||
onCleanup(startHealthPolling(current_))
|
||||
})
|
||||
|
||||
const origin = createMemo(() => projectsKey(state.active))
|
||||
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 {
|
||||
ready: isReady,
|
||||
healthy,
|
||||
isLocal,
|
||||
get url() {
|
||||
get key() {
|
||||
return state.active
|
||||
},
|
||||
get name() {
|
||||
return serverDisplayName(state.active)
|
||||
return serverDisplayName(current())
|
||||
},
|
||||
get list() {
|
||||
return store.list
|
||||
return allServers()
|
||||
},
|
||||
get current() {
|
||||
return current()
|
||||
},
|
||||
setActive,
|
||||
add,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// @refresh reload
|
||||
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { render } from "solid-js/web"
|
||||
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 zh } from "@/i18n/zh"
|
||||
import { handleNotificationClick } from "@/utils/notification-click"
|
||||
import pkg from "../package.json"
|
||||
import { ServerConnection } from "./context/server"
|
||||
|
||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||
|
||||
@@ -107,12 +110,22 @@ const platform: Platform = {
|
||||
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) {
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<AppInterface />
|
||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
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"
|
||||
|
||||
@@ -51,7 +51,6 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { DialogSettings } from "@/components/dialog-settings"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { navStart } from "@/utils/perf"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
@@ -826,14 +825,6 @@ export default function Layout(props: ParentProps) {
|
||||
if (next) prefetchSession(next)
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
navStart({
|
||||
dir: base64Encode(session.directory),
|
||||
from: params.id,
|
||||
to: session.id,
|
||||
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
|
||||
})
|
||||
}
|
||||
navigateToSession(session)
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
}
|
||||
@@ -869,15 +860,6 @@ export default function Layout(props: ParentProps) {
|
||||
if (next) prefetchSession(next)
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
navStart({
|
||||
dir: base64Encode(session.directory),
|
||||
from: params.id,
|
||||
to: session.id,
|
||||
trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup",
|
||||
})
|
||||
}
|
||||
|
||||
navigateToSession(session)
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
return
|
||||
|
||||
@@ -1,57 +1,30 @@
|
||||
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
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 { useGlobalSync } from "@/context/global-sync"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
|
||||
import { navMark, navParams } from "@/utils/perf"
|
||||
import { SessionHeader, NewSessionView } from "@/components/session"
|
||||
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 { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import {
|
||||
SessionReviewTab,
|
||||
StickyAddButton,
|
||||
type DiffStyle,
|
||||
type SessionReviewTabProps,
|
||||
} from "@/pages/session/review-tab"
|
||||
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
|
||||
@@ -59,98 +32,34 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
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() {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const file = useFile()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const terminal = useTerminal()
|
||||
const dialog = useDialog()
|
||||
const codeComponent = useCodeComponent()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
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({
|
||||
responding: false,
|
||||
pendingMessage: undefined as string | undefined,
|
||||
scrollGesture: 0,
|
||||
autoCreated: false,
|
||||
scroll: {
|
||||
overflow: false,
|
||||
bottom: true,
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => permRequest()?.id,
|
||||
() => setUi("responding", false),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const blocked = createMemo(() => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return false
|
||||
return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
|
||||
})
|
||||
|
||||
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 workspaceKey = createMemo(() => params.dir ?? "")
|
||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||
@@ -193,46 +102,6 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.dir, params.id] as const,
|
||||
([dir, id], prev) => {
|
||||
if (!id) return
|
||||
navParams({ dir, from: prev?.[1], to: id })
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!prompt.ready()) return
|
||||
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!terminal.ready()) return
|
||||
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!file.ready()) return
|
||||
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (sync.data.message[id] === undefined) return
|
||||
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
|
||||
})
|
||||
}
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
@@ -265,16 +134,6 @@ export default function Page() {
|
||||
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()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
@@ -323,206 +182,6 @@ export default function Page() {
|
||||
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 userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
@@ -555,8 +214,6 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
activeDraggable: undefined as string | undefined,
|
||||
activeTerminalDraggable: undefined as string | undefined,
|
||||
messageId: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
@@ -615,33 +272,6 @@ export default function Page() {
|
||||
scrollToMessage(msgs[targetIndex], "auto")
|
||||
}
|
||||
|
||||
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 emptyDiffFiles: string[] = []
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same })
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
@@ -649,7 +279,6 @@ export default function Page() {
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
let inputRef!: HTMLDivElement
|
||||
let promptDock: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
@@ -679,43 +308,6 @@ export default function Page() {
|
||||
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(
|
||||
on(
|
||||
() => visibleUserMessages().at(-1)?.id,
|
||||
@@ -728,20 +320,12 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
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(
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setUi("autoCreated", false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -768,11 +352,6 @@ export default function Page() {
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
const addSelectionToContext = (path: string, selection: FileSelection) => {
|
||||
const preview = selectionPreview(path, selection)
|
||||
prompt.context.add({ type: "file", path, selection, preview })
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
@@ -827,53 +406,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 openedTabs = createMemo(() =>
|
||||
tabs()
|
||||
@@ -911,29 +443,8 @@ export default function Page() {
|
||||
const focusInput = () => inputRef?.focus()
|
||||
|
||||
useSessionCommands({
|
||||
command,
|
||||
dialog,
|
||||
file,
|
||||
language,
|
||||
local,
|
||||
permission,
|
||||
prompt,
|
||||
sdk,
|
||||
sync,
|
||||
terminal,
|
||||
layout,
|
||||
params,
|
||||
navigate,
|
||||
tabs,
|
||||
view,
|
||||
info,
|
||||
status,
|
||||
userMessages,
|
||||
visibleUserMessages,
|
||||
showAllFiles,
|
||||
navigateMessageByOffset,
|
||||
setActiveMessage,
|
||||
addSelectionToContext,
|
||||
focusInput,
|
||||
})
|
||||
|
||||
@@ -1071,11 +582,6 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
const setFileTreeTabValue = (value: string) => {
|
||||
if (value !== "changes" && value !== "all") return
|
||||
setFileTreeTab(value)
|
||||
}
|
||||
|
||||
const reviewDiffId = (path: string) => {
|
||||
const sum = checksum(path)
|
||||
if (!sum) return
|
||||
@@ -1171,12 +677,6 @@ export default function Page() {
|
||||
return "empty"
|
||||
})
|
||||
|
||||
const activeFileTab = createMemo(() => {
|
||||
const active = activeTab()
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!layout.ready()) return
|
||||
if (tabs().active()) return
|
||||
@@ -1485,58 +985,6 @@ export default function Page() {
|
||||
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(() => {
|
||||
cancelTurnBackfill()
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
@@ -1555,7 +1003,6 @@ export default function Page() {
|
||||
reviewCount={reviewCount()}
|
||||
onSession={() => setStore("mobileTab", "session")}
|
||||
onChanges={() => setStore("mobileTab", "changes")}
|
||||
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
|
||||
/>
|
||||
|
||||
{/* Session panel */}
|
||||
@@ -1595,27 +1042,7 @@ export default function Page() {
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
showHeader={!!(info()?.title || info()?.parentID)}
|
||||
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) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
@@ -1637,11 +1064,6 @@ export default function Page() {
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
onFirstTurnMount={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
|
||||
}}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
/>
|
||||
</Show>
|
||||
@@ -1670,15 +1092,6 @@ export default function Page() {
|
||||
|
||||
<SessionPromptDock
|
||||
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
|
||||
}}
|
||||
@@ -1688,7 +1101,9 @@ export default function Page() {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
setPromptDockRef={(el) => (promptDock = el)}
|
||||
setPromptDockRef={(el) => {
|
||||
promptDock = el
|
||||
}}
|
||||
/>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
@@ -1702,64 +1117,10 @@ export default function Page() {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
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}
|
||||
/>
|
||||
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
<TerminalPanel />
|
||||
</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 { 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 { decode64 } from "@/utils/base64"
|
||||
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 { Tabs } from "@opencode-ai/ui/tabs"
|
||||
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 { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { getSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
const formatCommentLabel = (range: SelectedLineRange) => {
|
||||
const start = Math.min(range.start, range.end)
|
||||
@@ -19,34 +23,29 @@ const formatCommentLabel = (range: SelectedLineRange) => {
|
||||
return `lines ${start}-${end}`
|
||||
}
|
||||
|
||||
export function FileTabContent(props: {
|
||||
tab: string
|
||||
activeTab: () => string
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
|
||||
file: ReturnType<typeof useFile>
|
||||
comments: ReturnType<typeof useComments>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
codeComponent: NonNullable<ValidComponent>
|
||||
addCommentToContext: (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
preview?: string
|
||||
origin?: "review" | "file"
|
||||
}) => void
|
||||
}) {
|
||||
export function FileTabContent(props: { tab: string }) {
|
||||
const params = useParams()
|
||||
const layout = useLayout()
|
||||
const file = useFile()
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const prompt = usePrompt()
|
||||
const codeComponent = useCodeComponent()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
|
||||
const path = createMemo(() => props.file.pathFromTab(props.tab))
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const state = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
return props.file.get(p)
|
||||
return file.get(p)
|
||||
})
|
||||
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
||||
@@ -82,7 +81,7 @@ export function FileTabContent(props: {
|
||||
svgToast.shown = true
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: props.language.t("toast.file.loadFailed.title"),
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
})
|
||||
const svgPreviewUrl = createMemo(() => {
|
||||
@@ -100,16 +99,57 @@ export function FileTabContent(props: {
|
||||
const selectedLines = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
if (props.file.ready()) return props.file.selectedLines(p) ?? null
|
||||
return props.handoffFiles()?.[p] ?? null
|
||||
if (file.ready()) return file.selectedLines(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
|
||||
|
||||
const fileComments = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return []
|
||||
return props.comments.list(p)
|
||||
return comments.list(p)
|
||||
})
|
||||
|
||||
const commentLayout = createMemo(() => {
|
||||
@@ -228,19 +268,19 @@ export function FileTabContent(props: {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const focus = props.comments.focus()
|
||||
const focus = comments.focus()
|
||||
const p = path()
|
||||
if (!focus || !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)
|
||||
if (!target) return
|
||||
|
||||
setNote("openedComment", target.id)
|
||||
setNote("commenting", null)
|
||||
props.file.setSelectedLines(p, target.selection)
|
||||
requestAnimationFrame(() => props.comments.clearFocus())
|
||||
file.setSelectedLines(p, target.selection)
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
const getCodeScroll = () => {
|
||||
@@ -269,7 +309,7 @@ export function FileTabContent(props: {
|
||||
pending = undefined
|
||||
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
|
||||
if (!el) return
|
||||
|
||||
const s = props.view()?.scroll(props.tab)
|
||||
const s = view().scroll(props.tab)
|
||||
if (!s) return
|
||||
|
||||
syncCodeScroll()
|
||||
@@ -343,7 +383,7 @@ export function FileTabContent(props: {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.file.ready(),
|
||||
() => file.ready(),
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
@@ -354,7 +394,7 @@ export function FileTabContent(props: {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.tabs().active() === props.tab,
|
||||
() => tabs().active() === props.tab,
|
||||
(active) => {
|
||||
if (!active) return
|
||||
if (!state()?.loaded) return
|
||||
@@ -381,7 +421,7 @@ export function FileTabContent(props: {
|
||||
class={`relative overflow-hidden ${wrapperClass}`}
|
||||
>
|
||||
<Dynamic
|
||||
component={props.codeComponent}
|
||||
component={codeComponent}
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: source,
|
||||
@@ -397,7 +437,7 @@ export function FileTabContent(props: {
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
props.file.setSelectedLines(p, range)
|
||||
file.setSelectedLines(p, range)
|
||||
if (!range) setNote("commenting", null)
|
||||
}}
|
||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
@@ -423,14 +463,14 @@ export function FileTabContent(props: {
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
props.file.setSelectedLines(p, comment.selection)
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setNote("commenting", null)
|
||||
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) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
props.addCommentToContext({
|
||||
file: p,
|
||||
selection: range(),
|
||||
comment: value,
|
||||
origin: "file",
|
||||
})
|
||||
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
|
||||
setNote("commenting", null)
|
||||
}}
|
||||
onPopoverFocusOut={(e: FocusEvent) => {
|
||||
@@ -509,13 +544,13 @@ export function FileTabContent(props: {
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<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-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>
|
||||
</Match>
|
||||
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
||||
<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 when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
|
||||
36
packages/app/src/pages/session/handoff.ts
Normal file
36
packages/app/src/pages/session/handoff.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
|
||||
type HandoffSession = {
|
||||
prompt: string
|
||||
files: Record<string, SelectedLineRange | null>
|
||||
}
|
||||
|
||||
const MAX = 40
|
||||
|
||||
const store = {
|
||||
session: new Map<string, HandoffSession>(),
|
||||
terminal: new Map<string, string[]>(),
|
||||
}
|
||||
|
||||
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
|
||||
map.delete(key)
|
||||
map.set(key, value)
|
||||
while (map.size > MAX) {
|
||||
const first = map.keys().next().value
|
||||
if (first === undefined) return
|
||||
map.delete(first)
|
||||
}
|
||||
}
|
||||
|
||||
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
|
||||
const prev = store.session.get(key) ?? { prompt: "", files: {} }
|
||||
touch(store.session, key, { ...prev, ...patch })
|
||||
}
|
||||
|
||||
export const getSessionHandoff = (key: string) => store.session.get(key)
|
||||
|
||||
export const setTerminalHandoff = (key: string, value: string[]) => {
|
||||
touch(store.terminal, key, value)
|
||||
}
|
||||
|
||||
export const getTerminalHandoff = (key: string) => store.terminal.get(key)
|
||||
@@ -1,13 +1,21 @@
|
||||
import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { For, createEffect, createMemo, on, onCleanup, 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 { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
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 current = target instanceof Element ? target : undefined
|
||||
@@ -53,29 +61,7 @@ export function MessageTimeline(props: {
|
||||
isDesktop: boolean
|
||||
onScrollSpyScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
showHeader: 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
|
||||
turnStart: number
|
||||
onRenderEarlier: () => void
|
||||
@@ -86,11 +72,234 @@ export function MessageTimeline(props: {
|
||||
anchor: (id: string) => string
|
||||
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
||||
onUnregisterMessage: (id: string) => void
|
||||
onFirstTurnMount?: () => void
|
||||
lastUserMessageID?: string
|
||||
}) {
|
||||
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 (
|
||||
<Show
|
||||
when={!props.mobileChanges}
|
||||
@@ -157,9 +366,9 @@ export function MessageTimeline(props: {
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
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
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
@@ -171,92 +380,96 @@ export function MessageTimeline(props: {
|
||||
>
|
||||
<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">
|
||||
<Show when={props.parentID}>
|
||||
<Show when={parentID()}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={props.onNavigateParent}
|
||||
aria-label={props.t("common.goBack")}
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.title || props.titleState.editing}>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={props.titleState.editing}
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={props.openTitleEditor}
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{props.title}
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={props.titleRef}
|
||||
value={props.titleState.draft}
|
||||
disabled={props.titleState.saving}
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
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)" }}
|
||||
onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void props.saveTitleEditor()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.closeTitleEditor()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={props.closeTitleEditor}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.sessionID}>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={props.titleState.menuOpen}
|
||||
onOpenChange={props.onTitleMenuOpen}
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
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.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!props.titleState.pendingRename) return
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
props.onTitlePendingRename(false)
|
||||
props.openTitleEditor()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
props.onTitlePendingRename(true)
|
||||
props.onTitleMenuOpen(false)
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
@@ -282,7 +495,7 @@ export function MessageTimeline(props: {
|
||||
<Show when={props.turnStart > 0}>
|
||||
<div class="w-full flex justify-center">
|
||||
<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>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -296,43 +509,37 @@ export function MessageTimeline(props: {
|
||||
onClick={props.onLoadEarlier}
|
||||
>
|
||||
{props.historyLoading
|
||||
? props.t("session.messages.loadingEarlier")
|
||||
: props.t("session.messages.loadEarlier")}
|
||||
? language.t("session.messages.loadingEarlier")
|
||||
: language.t("session.messages.loadEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={props.renderedUserMessages}>
|
||||
{(message) => {
|
||||
if (import.meta.env.DEV && props.onFirstTurnMount) {
|
||||
onMount(() => props.onFirstTurnMount?.())
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, message.id)
|
||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
||||
{(message) => (
|
||||
<div
|
||||
id={props.anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, message.id)
|
||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-6",
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
sessionID={props.sessionID}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-6",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export function SessionMobileTabs(props: {
|
||||
open: boolean
|
||||
@@ -8,8 +9,9 @@ export function SessionMobileTabs(props: {
|
||||
reviewCount: number
|
||||
onSession: () => void
|
||||
onChanges: () => void
|
||||
t: (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<Tabs value={props.mobileTab} class="h-auto">
|
||||
@@ -20,7 +22,7 @@ export function SessionMobileTabs(props: {
|
||||
classes={{ button: "w-full" }}
|
||||
onClick={props.onSession}
|
||||
>
|
||||
{props.t("session.tab.session")}
|
||||
{language.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="changes"
|
||||
@@ -29,8 +31,8 @@ export function SessionMobileTabs(props: {
|
||||
onClick={props.onChanges}
|
||||
>
|
||||
{props.hasReview
|
||||
? props.t("session.review.filesChanged", { count: props.reviewCount })
|
||||
: props.t("session.review.change.other")}
|
||||
? language.t("session.review.filesChanged", { count: props.reviewCount })
|
||||
: language.t("session.review.change.other")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,35 +1,105 @@
|
||||
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 { 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 { QuestionDock } from "@/components/question-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: {
|
||||
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
|
||||
newSessionWorktree: string
|
||||
onNewSessionWorktreeReset: () => void
|
||||
onSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const done = createMemo(
|
||||
() =>
|
||||
props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
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 [opening, setOpening] = createSignal(false)
|
||||
let timer: number | undefined
|
||||
@@ -46,7 +116,7 @@ export function SessionPromptDock(props: {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.todos.length, done()] as const,
|
||||
() => [todos().length, done()] as const,
|
||||
([count, complete], prev) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
@@ -113,7 +183,7 @@ export function SessionPromptDock(props: {
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.questionRequest()} keyed>
|
||||
<Show when={questionRequest()} keyed>
|
||||
{(req) => {
|
||||
return (
|
||||
<div>
|
||||
@@ -123,11 +193,11 @@ export function SessionPromptDock(props: {
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={props.permissionRequest()} keyed>
|
||||
<Show when={permissionRequest()} keyed>
|
||||
{(perm) => {
|
||||
const toolDescription = () => {
|
||||
const key = `settings.permissions.tool.${perm.permission}.description`
|
||||
const value = props.t(key)
|
||||
const value = language.t(key as Parameters<typeof language.t>[0])
|
||||
if (value === key) return ""
|
||||
return value
|
||||
}
|
||||
@@ -141,36 +211,26 @@ export function SessionPromptDock(props: {
|
||||
<span data-slot="permission-icon">
|
||||
<Icon name="warning" size="normal" />
|
||||
</span>
|
||||
<div data-slot="permission-header-title">{props.t("notification.permission.title")}</div>
|
||||
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<div />
|
||||
<div data-slot="permission-footer-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("reject")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.deny")}
|
||||
<Button variant="ghost" size="normal" onClick={() => decide("reject")} disabled={responding()}>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
onClick={() => decide("always")}
|
||||
disabled={responding()}
|
||||
>
|
||||
{props.t("ui.permission.allowAlways")}
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("once")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{props.t("ui.permission.allowOnce")}
|
||||
<Button variant="primary" size="normal" onClick={() => decide("once")} disabled={responding()}>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -199,12 +259,12 @@ export function SessionPromptDock(props: {
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when={!props.blocked}>
|
||||
<Show when={!blocked()}>
|
||||
<Show
|
||||
when={props.promptReady}
|
||||
when={prompt.ready()}
|
||||
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">
|
||||
{props.handoffPrompt || props.t("prompt.loading")}
|
||||
{handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -219,10 +279,10 @@ export function SessionPromptDock(props: {
|
||||
}}
|
||||
>
|
||||
<SessionTodoDock
|
||||
todos={props.todos}
|
||||
title={props.t("session.todo.title")}
|
||||
collapseLabel={props.t("session.todo.collapse")}
|
||||
expandLabel={props.t("session.todo.expand")}
|
||||
todos={todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
/>
|
||||
</div>
|
||||
</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 { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
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 { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
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 { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type SessionSidePanelViewModel = {
|
||||
messages: () => Message[]
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
||||
}
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { getTabReorderIndex } from "@/pages/session/helpers"
|
||||
import { StickyAddButton } from "@/pages/session/review-tab"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
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
|
||||
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
|
||||
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 (
|
||||
<Show when={props.open}>
|
||||
<Show when={open()}>
|
||||
<aside
|
||||
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"
|
||||
classList={{
|
||||
"flex-1": props.reviewOpen,
|
||||
"shrink-0": !props.reviewOpen,
|
||||
"flex-1": 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">
|
||||
<Show
|
||||
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
|
||||
when={layout.fileTree.opened() && fileTreeTab() === "changes"}
|
||||
fallback={
|
||||
<DragDropProvider
|
||||
onDragStart={props.onDragStart}
|
||||
onDragEnd={props.onDragEnd}
|
||||
onDragOver={props.onDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={props.activeTab()} onChange={props.openTab}>
|
||||
<Tabs value={activeTab()} onChange={openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={props.reviewTab}>
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{props.language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview}>
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<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">
|
||||
{props.reviewCount}
|
||||
{reviewCount()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={props.contextOpen()}>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<Tooltip value={props.language.t("common.closeTab")} placement="bottom">
|
||||
<Tooltip value={language.t("common.closeTab")} placement="bottom">
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.tabs().close("context")}
|
||||
aria-label={props.language.t("common.closeTab")}
|
||||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.tabs().close("context")}
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{props.language.t("session.tab.context")}</div>
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<SortableProvider ids={openedTabs()}>
|
||||
<For each={openedTabs()}>
|
||||
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
|
||||
</For>
|
||||
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
||||
</SortableProvider>
|
||||
<StickyAddButton>
|
||||
<TooltipKeybind
|
||||
title={props.language.t("command.file.open")}
|
||||
keybind={props.command.keybind("file.open")}
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
@@ -158,72 +271,52 @@ export function SessionSidePanel(props: {
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={() =>
|
||||
props.dialog.show(() => (
|
||||
<DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
|
||||
))
|
||||
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
}
|
||||
aria-label={props.language.t("command.file.open")}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</StickyAddButton>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={props.reviewTab}>
|
||||
<Show when={reviewTab()}>
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<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="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<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>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={props.contextOpen()}>
|
||||
<Show when={contextOpen()}>
|
||||
<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">
|
||||
<SessionContextTab
|
||||
messages={props.vm.messages}
|
||||
visibleUserMessages={props.vm.visibleUserMessages}
|
||||
view={props.vm.view}
|
||||
info={props.vm.info}
|
||||
/>
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.activeFileTab()} keyed>
|
||||
{(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 when={activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={props.activeDraggable()}>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = createMemo(() => props.file.pathFromTab(tab()))
|
||||
const path = createMemo(() => file.pathFromTab(tab))
|
||||
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">
|
||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
@@ -240,50 +333,44 @@ export function SessionSidePanel(props: {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.layout.fileTree.opened()}>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
class="relative shrink-0 h-full"
|
||||
style={{ width: `${props.layout.fileTree.width()}px` }}
|
||||
>
|
||||
<Show when={layout.fileTree.opened()}>
|
||||
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
||||
<div
|
||||
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
|
||||
variant="pill"
|
||||
value={props.fileTreeTab()}
|
||||
onChange={props.setFileTreeTabValue}
|
||||
value={fileTreeTab()}
|
||||
onChange={setFileTreeTabValue}
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{props.reviewCount}{" "}
|
||||
{props.language.t(
|
||||
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
</Tabs.Trigger>
|
||||
<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.List>
|
||||
<Tabs.Content value="changes" class="bg-background-base px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={props.hasReview}>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={props.diffsReady}
|
||||
when={diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{props.language.t("common.loading")}
|
||||
{props.language.t("common.loading.ellipsis")}
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
allowed={props.diffFiles}
|
||||
kinds={props.kinds}
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
active={props.activeDiff}
|
||||
onFileClick={(node) => props.focusReviewDiff(node.path)}
|
||||
@@ -292,7 +379,7 @@ export function SessionSidePanel(props: {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-8 text-center text-12-regular text-text-weak">
|
||||
{props.language.t("session.review.noChanges")}
|
||||
{language.t("session.review.noChanges")}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -300,9 +387,9 @@ export function SessionSidePanel(props: {
|
||||
<Tabs.Content value="all" class="bg-background-base px-3 py-0">
|
||||
<FileTree
|
||||
path=""
|
||||
modified={props.diffFiles}
|
||||
kinds={props.kinds}
|
||||
onFileClick={(node) => props.openTab(props.file.tab(node.path))}
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
@@ -310,12 +397,12 @@ export function SessionSidePanel(props: {
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={props.layout.fileTree.width()}
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
collapseThreshold={160}
|
||||
onResize={props.layout.fileTree.resize}
|
||||
onCollapse={props.layout.fileTree.close}
|
||||
onResize={layout.fileTree.resize}
|
||||
onCollapse={layout.fileTree.close}
|
||||
/>
|
||||
</div>
|
||||
</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 { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } 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 { Terminal } from "@/components/terminal"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { useLanguage } from "@/context/language"
|
||||
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 { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
|
||||
export function TerminalPanel(props: {
|
||||
open: boolean
|
||||
height: number
|
||||
resize: (value: number) => void
|
||||
close: () => void
|
||||
terminal: ReturnType<typeof useTerminal>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
command: ReturnType<typeof useCommand>
|
||||
handoff: () => string[]
|
||||
activeTerminalDraggable: () => string | undefined
|
||||
handleTerminalDragStart: (event: unknown) => void
|
||||
handleTerminalDragOver: (event: DragEvent) => void
|
||||
handleTerminalDragEnd: () => void
|
||||
onCloseTab: () => void
|
||||
}) {
|
||||
const all = createMemo(() => props.terminal.all())
|
||||
export function TerminalPanel() {
|
||||
const params = useParams()
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const open = createMemo(() => isDesktop() && opened())
|
||||
const height = createMemo(() => layout.terminal.height())
|
||||
const close = () => view().terminal.close()
|
||||
|
||||
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 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 (
|
||||
<Show when={props.open}>
|
||||
<Show when={open()}>
|
||||
<div
|
||||
id="terminal-panel"
|
||||
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"
|
||||
style={{ height: `${props.height}px` }}
|
||||
style={{ height: `${height()}px` }}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="vertical"
|
||||
size={props.height}
|
||||
size={height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={props.resize}
|
||||
onCollapse={props.close}
|
||||
onResize={layout.terminal.resize}
|
||||
onCollapse={close}
|
||||
/>
|
||||
<Show
|
||||
when={props.terminal.ready()}
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<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">
|
||||
<For each={props.handoff()}>
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
@@ -64,20 +164,18 @@ export function TerminalPanel(props: {
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">
|
||||
{props.language.t("common.loading")}
|
||||
{props.language.t("common.loading.ellipsis")}
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">
|
||||
{props.language.t("terminal.loading")}
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={props.handleTerminalDragStart}
|
||||
onDragEnd={props.handleTerminalDragEnd}
|
||||
onDragOver={props.handleTerminalDragOver}
|
||||
onDragStart={handleTerminalDragStart}
|
||||
onDragEnd={handleTerminalDragEnd}
|
||||
onDragOver={handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
@@ -85,36 +183,26 @@ export function TerminalPanel(props: {
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="alt"
|
||||
value={props.terminal.active()}
|
||||
onChange={(id) => props.terminal.open(id)}
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-auto !flex-none"
|
||||
>
|
||||
<Tabs.List class="h-10">
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={all()}>
|
||||
{(pty) => (
|
||||
<SortableTerminalTab
|
||||
terminal={pty}
|
||||
onClose={() => {
|
||||
props.close()
|
||||
props.onCloseTab()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
title={props.language.t("command.terminal.new")}
|
||||
keybind={props.command.keybind("terminal.new")}
|
||||
title={language.t("command.terminal.new")}
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={props.terminal.new}
|
||||
aria-label={props.language.t("command.terminal.new")}
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
@@ -127,15 +215,11 @@ export function TerminalPanel(props: {
|
||||
id={`terminal-wrapper-${pty.id}`}
|
||||
class="absolute inset-0"
|
||||
style={{
|
||||
display: props.terminal.active() === pty.id ? "block" : "none",
|
||||
display: terminal.active() === pty.id ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<Show when={pty.id} keyed>
|
||||
<Terminal
|
||||
pty={pty}
|
||||
onCleanup={props.terminal.update}
|
||||
onConnectError={() => props.terminal.clone(pty.id)}
|
||||
/>
|
||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
@@ -143,25 +227,20 @@ export function TerminalPanel(props: {
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={props.activeTerminalDraggable()}>
|
||||
{(draggedId) => {
|
||||
return (
|
||||
<Show when={byId().get(draggedId())}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
t: props.language.t as (
|
||||
key: string,
|
||||
vars?: Record<string, string | number | boolean>,
|
||||
) => string,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
<Show when={store.activeDraggable}>
|
||||
{(draggedId) => (
|
||||
<Show when={byId().get(draggedId())}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
|
||||
@@ -22,29 +22,8 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
command: ReturnType<typeof useCommand>
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
file: ReturnType<typeof useFile>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
local: ReturnType<typeof useLocal>
|
||||
permission: ReturnType<typeof usePermission>
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
terminal: ReturnType<typeof useTerminal>
|
||||
layout: ReturnType<typeof useLayout>
|
||||
params: ReturnType<typeof useParams>
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
|
||||
status: () => { type: string }
|
||||
userMessages: () => UserMessage[]
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
showAllFiles: () => void
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
addSelectionToContext: (path: string, selection: FileSelection) => void
|
||||
focusInput: () => void
|
||||
}
|
||||
|
||||
@@ -55,45 +34,98 @@ const withCategory = (category: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const sessionCommand = withCategory(input.language.t("command.category.session"))
|
||||
const fileCommand = withCategory(input.language.t("command.category.file"))
|
||||
const contextCommand = withCategory(input.language.t("command.category.context"))
|
||||
const viewCommand = withCategory(input.language.t("command.category.view"))
|
||||
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
|
||||
const modelCommand = withCategory(input.language.t("command.category.model"))
|
||||
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
|
||||
const agentCommand = withCategory(input.language.t("command.category.agent"))
|
||||
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
|
||||
export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
|
||||
const visibleUserMessages = createMemo(() => {
|
||||
const revert = info()?.revert?.messageID
|
||||
if (!revert) return userMessages()
|
||||
return userMessages().filter((m) => m.id < revert)
|
||||
})
|
||||
|
||||
const showAllFiles = () => {
|
||||
if (layout.fileTree.tab() !== "changes") return
|
||||
layout.fileTree.setTab("all")
|
||||
}
|
||||
|
||||
const selectionPreview = (path: string, selection: FileSelection) => {
|
||||
const content = file.get(path)?.content?.content
|
||||
if (!content) return undefined
|
||||
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
|
||||
const end = Math.max(selection.startLine, selection.endLine)
|
||||
const lines = content.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return undefined
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
const addSelectionToContext = (path: string, selection: FileSelection) => {
|
||||
const preview = selectionPreview(path, selection)
|
||||
prompt.context.add({ type: "file", path, selection, preview })
|
||||
}
|
||||
|
||||
const navigateMessageByOffset = actions.navigateMessageByOffset
|
||||
const setActiveMessage = actions.setActiveMessage
|
||||
const focusInput = actions.focusInput
|
||||
|
||||
const sessionCommand = withCategory(language.t("command.category.session"))
|
||||
const fileCommand = withCategory(language.t("command.category.file"))
|
||||
const contextCommand = withCategory(language.t("command.category.context"))
|
||||
const viewCommand = withCategory(language.t("command.category.view"))
|
||||
const terminalCommand = withCategory(language.t("command.category.terminal"))
|
||||
const modelCommand = withCategory(language.t("command.category.model"))
|
||||
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
||||
|
||||
const sessionCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: input.language.t("command.session.new"),
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => input.navigate(`/${input.params.dir}/session`),
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
])
|
||||
|
||||
const fileCommands = createMemo(() => [
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: input.language.t("command.file.open"),
|
||||
description: input.language.t("palette.search.placeholder"),
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: input.language.t("command.tab.close"),
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !input.tabs().active(),
|
||||
disabled: !tabs().active(),
|
||||
onSelect: () => {
|
||||
const active = input.tabs().active()
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
input.tabs().close(active)
|
||||
tabs().close(active)
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -101,30 +133,30 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const contextCommands = createMemo(() => [
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: input.language.t("command.context.addSelection"),
|
||||
description: input.language.t("command.context.addSelection.description"),
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext({
|
||||
active: input.tabs().active(),
|
||||
pathFromTab: input.file.pathFromTab,
|
||||
selectedLines: input.file.selectedLines,
|
||||
active: tabs().active(),
|
||||
pathFromTab: file.pathFromTab,
|
||||
selectedLines: file.selectedLines,
|
||||
}),
|
||||
onSelect: () => {
|
||||
const active = input.tabs().active()
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
const path = input.file.pathFromTab(active)
|
||||
const path = file.pathFromTab(active)
|
||||
if (!path) return
|
||||
|
||||
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: input.language.t("toast.context.noLineSelection.title"),
|
||||
description: input.language.t("toast.context.noLineSelection.description"),
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.addSelectionToContext(path, selectionFromLines(range))
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -132,37 +164,37 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const viewCommands = createMemo(() => [
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: input.language.t("command.terminal.toggle"),
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => input.view().terminal.toggle(),
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: input.language.t("command.review.toggle"),
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => input.view().reviewPanel.toggle(),
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: input.language.t("command.fileTree.toggle"),
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => input.layout.fileTree.toggle(),
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: input.language.t("command.input.focus"),
|
||||
title: language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => input.focusInput(),
|
||||
onSelect: () => focusInput(),
|
||||
}),
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: input.language.t("command.terminal.new"),
|
||||
description: input.language.t("command.terminal.new.description"),
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: () => {
|
||||
if (input.terminal.all().length > 0) input.terminal.new()
|
||||
input.view().terminal.open()
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -170,61 +202,61 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const messageCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: input.language.t("command.message.previous"),
|
||||
description: input.language.t("command.message.previous.description"),
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+arrowup",
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => input.navigateMessageByOffset(-1),
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: input.language.t("command.message.next"),
|
||||
description: input.language.t("command.message.next.description"),
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+arrowdown",
|
||||
disabled: !input.params.id,
|
||||
onSelect: () => input.navigateMessageByOffset(1),
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
}),
|
||||
])
|
||||
|
||||
const agentCommands = createMemo(() => [
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: input.language.t("command.model.choose"),
|
||||
description: input.language.t("command.model.choose.description"),
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
|
||||
onSelect: () => dialog.show(() => <DialogSelectModel />),
|
||||
}),
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: input.language.t("command.mcp.toggle"),
|
||||
description: input.language.t("command.mcp.toggle.description"),
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
|
||||
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: input.language.t("command.agent.cycle"),
|
||||
description: input.language.t("command.agent.cycle.description"),
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => input.local.agent.move(1),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: input.language.t("command.agent.cycle.reverse"),
|
||||
description: input.language.t("command.agent.cycle.reverse.description"),
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => input.local.agent.move(-1),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: input.language.t("command.model.variant.cycle"),
|
||||
description: input.language.t("command.model.variant.cycle.description"),
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => {
|
||||
input.local.model.variant.cycle()
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -233,22 +265,22 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title:
|
||||
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
|
||||
? input.language.t("command.permissions.autoaccept.disable")
|
||||
: input.language.t("command.permissions.autoaccept.enable"),
|
||||
params.id && permission.isAutoAccepting(params.id, sdk.directory)
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: !input.params.id || !input.permission.permissionsEnabled(),
|
||||
disabled: !params.id || !permission.permissionsEnabled(),
|
||||
onSelect: () => {
|
||||
const sessionID = input.params.id
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
showToast({
|
||||
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
|
||||
? input.language.t("toast.permissions.autoaccept.on.title")
|
||||
: input.language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
|
||||
? input.language.t("toast.permissions.autoaccept.on.description")
|
||||
: input.language.t("toast.permissions.autoaccept.off.description"),
|
||||
title: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
},
|
||||
}),
|
||||
@@ -257,71 +289,71 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const sessionActionCommands = createMemo(() => [
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: input.language.t("command.session.undo"),
|
||||
description: input.language.t("command.session.undo.description"),
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (input.status()?.type !== "idle") {
|
||||
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
if (status()?.type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = input.info()?.revert?.messageID
|
||||
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = input.sync.data.part[message.id]
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
|
||||
input.prompt.set(restored)
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
|
||||
input.setActiveMessage(priorMessage)
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(priorMessage)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: input.language.t("command.session.redo"),
|
||||
description: input.language.t("command.session.redo.description"),
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !input.params.id || !input.info()?.revert?.messageID,
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const revertMessageID = input.info()?.revert?.messageID
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
|
||||
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!nextMessage) {
|
||||
await input.sdk.client.session.unrevert({ sessionID })
|
||||
input.prompt.reset()
|
||||
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
|
||||
input.setActiveMessage(lastMsg)
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
|
||||
input.setActiveMessage(priorMsg)
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
||||
setActiveMessage(priorMsg)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: input.language.t("command.session.compact"),
|
||||
description: input.language.t("command.session.compact.description"),
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = input.params.id
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const model = input.local.model.current()
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: input.language.t("toast.model.none.title"),
|
||||
description: input.language.t("toast.model.none.description"),
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
await input.sdk.client.session.summarize({
|
||||
await sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
@@ -330,29 +362,27 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: input.language.t("command.session.fork"),
|
||||
description: input.language.t("command.session.fork.description"),
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||
onSelect: () => input.dialog.show(() => <DialogFork />),
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => dialog.show(() => <DialogFork />),
|
||||
}),
|
||||
])
|
||||
|
||||
const shareCommands = createMemo(() => {
|
||||
if (input.sync.data.config.share === "disabled") return []
|
||||
if (sync.data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
id: "session.share",
|
||||
title: input.info()?.share?.url
|
||||
? input.language.t("session.share.copy.copyLink")
|
||||
: input.language.t("command.session.share"),
|
||||
description: input.info()?.share?.url
|
||||
? input.language.t("toast.session.share.success.description")
|
||||
: input.language.t("command.session.share.description"),
|
||||
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
|
||||
description: info()?.share?.url
|
||||
? language.t("toast.session.share.success.description")
|
||||
: language.t("command.session.share.description"),
|
||||
slash: "share",
|
||||
disabled: !input.params.id,
|
||||
disabled: !params.id,
|
||||
onSelect: async () => {
|
||||
if (!input.params.id) return
|
||||
if (!params.id) return
|
||||
|
||||
const write = (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
@@ -382,7 +412,7 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.copyFailed.title"),
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
@@ -390,27 +420,27 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
|
||||
showToast({
|
||||
title: existing
|
||||
? input.language.t("session.share.copy.copied")
|
||||
: input.language.t("toast.session.share.success.title"),
|
||||
description: input.language.t("toast.session.share.success.description"),
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
const existing = input.info()?.share?.url
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copy(existing, true)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await input.sdk.client.session
|
||||
.share({ sessionID: input.params.id })
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID: params.id })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
showToast({
|
||||
title: input.language.t("toast.session.share.failed.title"),
|
||||
description: input.language.t("toast.session.share.failed.description"),
|
||||
title: language.t("toast.session.share.failed.title"),
|
||||
description: language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
@@ -421,25 +451,25 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.unshare",
|
||||
title: input.language.t("command.session.unshare"),
|
||||
description: input.language.t("command.session.unshare.description"),
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !input.params.id || !input.info()?.share?.url,
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!input.params.id) return
|
||||
await input.sdk.client.session
|
||||
.unshare({ sessionID: input.params.id })
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.unshare.success.title"),
|
||||
description: input.language.t("toast.session.unshare.success.description"),
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: input.language.t("toast.session.unshare.failed.title"),
|
||||
description: input.language.t("toast.session.unshare.failed.description"),
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
@@ -448,7 +478,7 @@ export const useSessionCommands = (input: SessionCommandContext) => {
|
||||
]
|
||||
})
|
||||
|
||||
input.command.register("session", () =>
|
||||
command.register("session", () =>
|
||||
[
|
||||
sessionCommands(),
|
||||
fileCommands(),
|
||||
|
||||
2
packages/app/src/sst-env.d.ts
vendored
2
packages/app/src/sst-env.d.ts
vendored
@@ -1,6 +1,8 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
interface ImportMetaEnv {
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { uuid } from "@/utils/uuid"
|
||||
|
||||
type Nav = {
|
||||
id: string
|
||||
dir?: string
|
||||
from?: string
|
||||
to: string
|
||||
trigger?: string
|
||||
start: number
|
||||
marks: Record<string, number>
|
||||
logged: boolean
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
const dev = import.meta.env.DEV
|
||||
|
||||
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
|
||||
|
||||
const now = () => performance.now()
|
||||
|
||||
const navs = new Map<string, Nav>()
|
||||
const pending = new Map<string, string>()
|
||||
const active = new Map<string, string>()
|
||||
|
||||
const required = [
|
||||
"session:params",
|
||||
"session:data-ready",
|
||||
"session:first-turn-mounted",
|
||||
"storage:prompt-ready",
|
||||
"storage:terminal-ready",
|
||||
"storage:file-view-ready",
|
||||
]
|
||||
|
||||
function flush(id: string, reason: "complete" | "timeout") {
|
||||
if (!dev) return
|
||||
const nav = navs.get(id)
|
||||
if (!nav) return
|
||||
if (nav.logged) return
|
||||
|
||||
nav.logged = true
|
||||
if (nav.timer) clearTimeout(nav.timer)
|
||||
|
||||
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
|
||||
const base = nav.marks[baseName] ?? nav.start
|
||||
|
||||
const ms = Object.fromEntries(
|
||||
Object.entries(nav.marks)
|
||||
.slice()
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
|
||||
)
|
||||
|
||||
console.log(
|
||||
"perf.session-nav " +
|
||||
JSON.stringify({
|
||||
type: "perf.session-nav.v0",
|
||||
id: nav.id,
|
||||
dir: nav.dir,
|
||||
from: nav.from,
|
||||
to: nav.to,
|
||||
trigger: nav.trigger,
|
||||
base: baseName,
|
||||
reason,
|
||||
ms,
|
||||
}),
|
||||
)
|
||||
|
||||
navs.delete(id)
|
||||
}
|
||||
|
||||
function maybeFlush(id: string) {
|
||||
if (!dev) return
|
||||
const nav = navs.get(id)
|
||||
if (!nav) return
|
||||
if (nav.logged) return
|
||||
if (!required.every((name) => nav.marks[name] !== undefined)) return
|
||||
flush(id, "complete")
|
||||
}
|
||||
|
||||
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
|
||||
const existing = navs.get(id)
|
||||
if (existing) return existing
|
||||
|
||||
const nav: Nav = {
|
||||
...data,
|
||||
marks: {},
|
||||
logged: false,
|
||||
}
|
||||
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
|
||||
navs.set(id, nav)
|
||||
return nav
|
||||
}
|
||||
|
||||
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
|
||||
if (!dev) return
|
||||
|
||||
const id = uuid()
|
||||
const start = now()
|
||||
const nav = ensure(id, { ...input, id, start })
|
||||
nav.marks["navigate:start"] = start
|
||||
|
||||
pending.set(key(input.dir, input.to), id)
|
||||
return id
|
||||
}
|
||||
|
||||
export function navParams(input: { dir?: string; from?: string; to: string }) {
|
||||
if (!dev) return
|
||||
|
||||
const k = key(input.dir, input.to)
|
||||
const pendingId = pending.get(k)
|
||||
if (pendingId) pending.delete(k)
|
||||
const id = pendingId ?? uuid()
|
||||
|
||||
const start = now()
|
||||
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
|
||||
nav.marks["session:params"] = start
|
||||
|
||||
active.set(k, id)
|
||||
maybeFlush(id)
|
||||
return id
|
||||
}
|
||||
|
||||
export function navMark(input: { dir?: string; to: string; name: string }) {
|
||||
if (!dev) return
|
||||
|
||||
const id = active.get(key(input.dir, input.to))
|
||||
if (!id) return
|
||||
|
||||
const nav = navs.get(id)
|
||||
if (!nav) return
|
||||
if (nav.marks[input.name] !== undefined) return
|
||||
|
||||
nav.marks[input.name] = now()
|
||||
maybeFlush(id)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { checkServerHealth } from "./server-health"
|
||||
|
||||
const server: ServerConnection.HttpBase = {
|
||||
url: "http://localhost:4096",
|
||||
}
|
||||
|
||||
function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
|
||||
if (init?.signal) return init.signal
|
||||
if (input instanceof Request) return input.signal
|
||||
@@ -15,7 +20,7 @@ describe("checkServerHealth", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
})) 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" })
|
||||
})
|
||||
@@ -25,7 +30,7 @@ describe("checkServerHealth", () => {
|
||||
throw new Error("network")
|
||||
}) 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 })
|
||||
})
|
||||
@@ -51,7 +56,9 @@ describe("checkServerHealth", () => {
|
||||
)
|
||||
})) 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) Reflect.deleteProperty(AbortSignal, "timeout")
|
||||
})
|
||||
@@ -71,7 +78,9 @@ describe("checkServerHealth", () => {
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -87,7 +96,7 @@ describe("checkServerHealth", () => {
|
||||
})
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth("http://localhost:4096", fetch, {
|
||||
const result = await checkServerHealth(server, fetch, {
|
||||
retryCount: 2,
|
||||
retryDelayMs: 1,
|
||||
})
|
||||
@@ -103,7 +112,7 @@ describe("checkServerHealth", () => {
|
||||
throw new TypeError("network")
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth("http://localhost:4096", fetch, {
|
||||
const result = await checkServerHealth(server, fetch, {
|
||||
retryCount: 2,
|
||||
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 }
|
||||
|
||||
@@ -17,7 +18,10 @@ function timeoutSignal(timeoutMs: number) {
|
||||
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
|
||||
if (timeout) {
|
||||
try {
|
||||
return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined }
|
||||
return {
|
||||
signal: timeout.call(AbortSignal, timeoutMs),
|
||||
clear: undefined as (() => void) | undefined,
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const controller = new AbortController()
|
||||
@@ -52,7 +56,7 @@ function retryable(error: unknown, signal?: AbortSignal) {
|
||||
}
|
||||
|
||||
export async function checkServerHealth(
|
||||
url: string,
|
||||
server: ServerConnection.HttpBase,
|
||||
fetch: typeof globalThis.fetch,
|
||||
opts?: CheckServerHealthOptions,
|
||||
): Promise<ServerHealth> {
|
||||
@@ -67,8 +71,8 @@ export async function checkServerHealth(
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
const attempt = (count: number): Promise<ServerHealth> =>
|
||||
createOpencodeClient({
|
||||
baseUrl: url,
|
||||
createSdkForServer({
|
||||
server,
|
||||
fetch,
|
||||
signal,
|
||||
})
|
||||
|
||||
22
packages/app/src/utils/server.ts
Normal file
22
packages/app/src/utils/server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
export function createSdkForServer({
|
||||
server,
|
||||
...config
|
||||
}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
|
||||
server: ServerConnection.HttpBase
|
||||
}) {
|
||||
const auth = (() => {
|
||||
if (!server.password) return
|
||||
return {
|
||||
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
|
||||
}
|
||||
})()
|
||||
|
||||
return createOpencodeClient({
|
||||
...config,
|
||||
headers: { ...config.headers, ...auth },
|
||||
baseUrl: server.url,
|
||||
})
|
||||
}
|
||||
1
packages/app/sst-env.d.ts
vendored
1
packages/app/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
}
|
||||
},
|
||||
"include": ["src", "package.json"],
|
||||
"exclude": ["dist", "ts-dist"]
|
||||
"exclude": ["dist", "ts-dist"],
|
||||
"references": [{ "path": "../sdk/js" }]
|
||||
}
|
||||
|
||||
1
packages/console/app/sst-env.d.ts
vendored
1
packages/console/app/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
|
||||
1
packages/console/core/sst-env.d.ts
vendored
1
packages/console/core/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
|
||||
1
packages/console/function/sst-env.d.ts
vendored
1
packages/console/function/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
|
||||
1
packages/console/mail/sst-env.d.ts
vendored
1
packages/console/mail/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
|
||||
1
packages/console/resource/sst-env.d.ts
vendored
1
packages/console/resource/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
|
||||
@@ -9,7 +9,6 @@ use std::os::unix::process::ExitStatusExt;
|
||||
use std::sync::Arc;
|
||||
use std::{process::Stdio, time::Duration};
|
||||
use tauri::{AppHandle, Manager, path::BaseDirectory};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tauri_specta::Event;
|
||||
use tokio::{
|
||||
io::{AsyncBufRead, AsyncBufReadExt, BufReader},
|
||||
@@ -20,7 +19,8 @@ use tokio::{
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
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_BINARY_NAME: &str = "opencode";
|
||||
@@ -202,16 +202,8 @@ fn get_user_shell() -> String {
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
|
||||
}
|
||||
|
||||
fn is_wsl_enabled(app: &tauri::AppHandle) -> bool {
|
||||
let Ok(store) = app.store(SETTINGS_STORE) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
store
|
||||
.get(WSL_ENABLED_KEY)
|
||||
.as_ref()
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false)
|
||||
fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
|
||||
get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
|
||||
}
|
||||
|
||||
fn shell_escape(input: &str) -> String {
|
||||
|
||||
@@ -55,18 +55,18 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||
pub fn get_wsl_config(_app: AppHandle) -> Result<WslConfig, String> {
|
||||
// let store = app
|
||||
// .store(SETTINGS_STORE)
|
||||
// .map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||
|
||||
let enabled = store
|
||||
.get(WSL_ENABLED_KEY)
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
// let enabled = store
|
||||
// .get(WSL_ENABLED_KEY)
|
||||
// .as_ref()
|
||||
// .and_then(|v| v.as_bool())
|
||||
// .unwrap_or(false);
|
||||
|
||||
Ok(WslConfig { enabled })
|
||||
Ok(WslConfig { enabled: false })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
// @refresh reload
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import { render } from "solid-js/web"
|
||||
|
||||
import {
|
||||
AppBaseProviders,
|
||||
AppInterface,
|
||||
PlatformProvider,
|
||||
Platform,
|
||||
useCommand,
|
||||
handleNotificationClick,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
ServerConnection,
|
||||
useCommand,
|
||||
} 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 { 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 { UPDATER_ENABLED } from "./updater"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
|
||||
import { open, save } from "@tauri-apps/plugin-dialog"
|
||||
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 { initI18n, t } from "./i18n"
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import "./styles.css"
|
||||
import { commands, InitStep } from "./bindings"
|
||||
import { Channel } from "@tauri-apps/api/core"
|
||||
import { commands, type InitStep } from "./bindings"
|
||||
import { createMenu } from "./menu"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
@@ -58,7 +59,7 @@ const listenForDeepLinks = async () => {
|
||||
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
|
||||
}
|
||||
|
||||
const createPlatform = (password: Accessor<string | null>): Platform => {
|
||||
const createPlatform = (): Platform => {
|
||||
const os = (() => {
|
||||
const type = ostype()
|
||||
if (type === "macos" || type === "windows" || type === "linux") return type
|
||||
@@ -344,22 +345,10 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
|
||||
},
|
||||
|
||||
fetch: (input, init) => {
|
||||
const pw = password()
|
||||
|
||||
const addHeader = (headers: Headers, password: string) => {
|
||||
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
|
||||
}
|
||||
|
||||
if (input instanceof Request) {
|
||||
if (pw) addHeader(input.headers, pw)
|
||||
return tauriFetch(input)
|
||||
} else {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (pw) addHeader(headers, pw)
|
||||
return tauriFetch(input, {
|
||||
...(init as any),
|
||||
headers: headers,
|
||||
})
|
||||
return tauriFetch(input, init)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -417,7 +406,11 @@ const createPlatform = (password: Accessor<string | null>): Platform => {
|
||||
return new Promise<File | null>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
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")
|
||||
})
|
||||
},
|
||||
@@ -431,9 +424,7 @@ createMenu((id) => {
|
||||
void listenForDeepLinks()
|
||||
|
||||
render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
const platform = createPlatform()
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
@@ -455,9 +446,16 @@ render(() => {
|
||||
<AppBaseProviders>
|
||||
<ServerGate>
|
||||
{(data) => {
|
||||
setServerPassword(data().password)
|
||||
window.__OPENCODE__ ??= {}
|
||||
window.__OPENCODE__.serverPassword = data().password ?? undefined
|
||||
const server: ServerConnection.Sidecar = {
|
||||
displayName: "Local Server",
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data().url,
|
||||
username: "opencode",
|
||||
password: data().password ?? undefined,
|
||||
},
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const cmd = useCommand()
|
||||
@@ -468,7 +466,7 @@ render(() => {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppInterface defaultUrl={data().url} isSidecar>
|
||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]}>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)
|
||||
|
||||
1
packages/desktop/sst-env.d.ts
vendored
1
packages/desktop/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
|
||||
1
packages/enterprise/sst-env.d.ts
vendored
1
packages/enterprise/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
|
||||
1
packages/function/sst-env.d.ts
vendored
1
packages/function/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -74,7 +75,7 @@
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@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",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
@@ -110,6 +111,7 @@
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "0.0.6",
|
||||
|
||||
@@ -214,7 +214,8 @@ export default {
|
||||
},
|
||||
{
|
||||
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: {
|
||||
highlights: [
|
||||
"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 skillDirs = await Skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
const defaults = PermissionNext.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
[Truncate.GLOB]: "allow",
|
||||
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
@@ -142,7 +142,8 @@ export namespace Agent {
|
||||
codesearch: "allow",
|
||||
read: "allow",
|
||||
external_directory: {
|
||||
[Truncate.GLOB]: "allow",
|
||||
"*": "ask",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
}),
|
||||
user,
|
||||
|
||||
@@ -66,14 +66,14 @@ export namespace BunProc {
|
||||
using _ = await Lock.write("bun-install")
|
||||
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
|
||||
const parsed = await pkgjson.json().catch(async () => {
|
||||
const result = { dependencies: {} }
|
||||
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
|
||||
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
|
||||
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
|
||||
const result = { dependencies: {} as Record<string, string> }
|
||||
await Filesystem.writeJson(pkgjsonPath, result)
|
||||
return result
|
||||
})
|
||||
const dependencies = parsed.dependencies ?? {}
|
||||
if (!parsed.dependencies) parsed.dependencies = dependencies
|
||||
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
|
||||
const dependencies = parsed.dependencies
|
||||
const modExists = await Filesystem.exists(mod)
|
||||
const cachedVersion = dependencies[pkg]
|
||||
|
||||
@@ -123,15 +123,16 @@ export namespace BunProc {
|
||||
// This ensures subsequent starts use the cached version until explicitly updated
|
||||
let resolvedVersion = version
|
||||
if (version === "latest") {
|
||||
const installedPkgJson = Bun.file(path.join(mod, "package.json"))
|
||||
const installedPkg = await installedPkgJson.json().catch(() => null)
|
||||
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
|
||||
() => null,
|
||||
)
|
||||
if (installedPkg?.version) {
|
||||
resolvedVersion = installedPkg.version
|
||||
}
|
||||
}
|
||||
|
||||
parsed.dependencies[pkg] = resolvedVersion
|
||||
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
|
||||
await Filesystem.writeJson(pkgjsonPath, parsed)
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { exec } from "child_process"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
import { Octokit } from "@octokit/rest"
|
||||
@@ -173,6 +174,18 @@ export function extractResponseText(parts: MessageV2.Part[]): string | null {
|
||||
throw new Error("Failed to parse response: no parts returned")
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a PROMPT_TOO_LARGE error message with details about files in the prompt.
|
||||
* Content is base64 encoded, so we calculate original size by multiplying by 0.75.
|
||||
*/
|
||||
export function formatPromptTooLargeError(files: { filename: string; content: string }[]): string {
|
||||
const fileDetails =
|
||||
files.length > 0
|
||||
? `\n\nFiles in prompt:\n${files.map((f) => ` - ${f.filename} (${((f.content.length * 0.75) / 1024).toFixed(0)} KB)`).join("\n")}`
|
||||
: ""
|
||||
return `PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.${fileDetails}`
|
||||
}
|
||||
|
||||
export const GithubCommand = cmd({
|
||||
command: "github",
|
||||
describe: "manage GitHub agent",
|
||||
@@ -360,7 +373,7 @@ export const GithubInstallCommand = cmd({
|
||||
? ""
|
||||
: `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}`
|
||||
|
||||
await Bun.write(
|
||||
await Filesystem.write(
|
||||
path.join(app.root, WORKFLOW_FILE),
|
||||
`name: opencode
|
||||
|
||||
@@ -802,6 +815,7 @@ export const GithubRunCommand = cmd({
|
||||
replacement,
|
||||
})
|
||||
}
|
||||
|
||||
return { userPrompt: prompt, promptFiles: imgData }
|
||||
}
|
||||
|
||||
@@ -909,10 +923,15 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
// result should always be assistant just satisfying type checker
|
||||
if (result.info.role === "assistant" && result.info.error) {
|
||||
console.error("Agent error:", result.info.error)
|
||||
throw new Error(
|
||||
`${result.info.error.name}: ${"message" in result.info.error ? result.info.error.message : ""}`,
|
||||
)
|
||||
const err = result.info.error
|
||||
console.error("Agent error:", err)
|
||||
|
||||
if (err.name === "ContextOverflowError") {
|
||||
throw new Error(formatPromptTooLargeError(files))
|
||||
}
|
||||
|
||||
const errorMsg = err.data?.message || ""
|
||||
throw new Error(`${err.name}: ${errorMsg}`)
|
||||
}
|
||||
|
||||
const text = extractResponseText(result.parts)
|
||||
@@ -938,10 +957,15 @@ export const GithubRunCommand = cmd({
|
||||
})
|
||||
|
||||
if (summary.info.role === "assistant" && summary.info.error) {
|
||||
console.error("Summary agent error:", summary.info.error)
|
||||
throw new Error(
|
||||
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
|
||||
)
|
||||
const err = summary.info.error
|
||||
console.error("Summary agent error:", err)
|
||||
|
||||
if (err.name === "ContextOverflowError") {
|
||||
throw new Error(formatPromptTooLargeError(files))
|
||||
}
|
||||
|
||||
const errorMsg = err.data?.message || ""
|
||||
throw new Error(`${err.name}: ${errorMsg}`)
|
||||
}
|
||||
|
||||
const summaryText = extractResponseText(summary.parts)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Installation } from "../../installation"
|
||||
import path from "path"
|
||||
import { Global } from "../../global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Bus } from "../../bus"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
@@ -388,7 +389,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await Bun.file(candidate).exists()) {
|
||||
if (await Filesystem.exists(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -398,11 +399,9 @@ async function resolveConfigPath(baseDir: string, global = false) {
|
||||
}
|
||||
|
||||
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
|
||||
const file = Bun.file(configPath)
|
||||
|
||||
let text = "{}"
|
||||
if (await file.exists()) {
|
||||
text = await file.text()
|
||||
if (await Filesystem.exists(configPath)) {
|
||||
text = await Filesystem.readText(configPath)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
await Bun.write(configPath, result)
|
||||
await Filesystem.write(configPath, result)
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
@@ -315,19 +316,12 @@ export const RunCommand = cmd({
|
||||
|
||||
for (const filePath of list) {
|
||||
const resolvedPath = path.resolve(process.cwd(), filePath)
|
||||
const file = Bun.file(resolvedPath)
|
||||
const stats = await file.stat().catch(() => {})
|
||||
if (!stats) {
|
||||
UI.error(`File not found: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!(await file.exists())) {
|
||||
if (!(await Filesystem.exists(resolvedPath))) {
|
||||
UI.error(`File not found: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const stat = await file.stat()
|
||||
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
|
||||
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
|
||||
|
||||
files.push({
|
||||
type: "file",
|
||||
|
||||
@@ -85,26 +85,17 @@ export const SessionListCommand = cmd({
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessions = []
|
||||
for await (const session of Session.list()) {
|
||||
if (!session.parentID) {
|
||||
sessions.push(session)
|
||||
}
|
||||
}
|
||||
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
|
||||
|
||||
sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
|
||||
const limitedSessions = args.maxCount ? sessions.slice(0, args.maxCount) : sessions
|
||||
|
||||
if (limitedSessions.length === 0) {
|
||||
if (sessions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let output: string
|
||||
if (args.format === "json") {
|
||||
output = formatSessionJSON(limitedSessions)
|
||||
output = formatSessionJSON(sessions)
|
||||
} else {
|
||||
output = formatSessionTable(limitedSessions)
|
||||
output = formatSessionTable(sessions)
|
||||
}
|
||||
|
||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||
|
||||
@@ -694,7 +694,7 @@ export function Prompt(props: PromptProps) {
|
||||
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const extmarkStart = currentOffset
|
||||
const count = store.prompt.parts.filter((x) => x.type === "file").length
|
||||
const count = store.prompt.parts.filter((x) => x.type === "file" && x.mime.startsWith("image/")).length
|
||||
const virtualText = `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
|
||||
@@ -4,6 +4,7 @@ import clipboardy from "clipboardy"
|
||||
import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../../../../util/filesystem"
|
||||
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
@@ -34,9 +35,8 @@ export namespace Clipboard {
|
||||
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
|
||||
.nothrow()
|
||||
.quiet()
|
||||
const file = Bun.file(tmpfile)
|
||||
const buffer = await file.arrayBuffer()
|
||||
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
|
||||
const buffer = await Filesystem.readBytes(tmpfile)
|
||||
return { data: buffer.toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
} finally {
|
||||
await $`rm -f "${tmpfile}"`.nothrow().quiet()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CliRenderer } from "@opentui/core"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
export namespace Editor {
|
||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||
@@ -12,7 +13,7 @@ export namespace Editor {
|
||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
||||
await using _ = defer(async () => rm(filepath, { force: true }))
|
||||
|
||||
await Bun.write(filepath, opts.value)
|
||||
await Filesystem.write(filepath, opts.value)
|
||||
opts.renderer.suspend()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
const parts = editor.split(" ")
|
||||
@@ -23,7 +24,7 @@ export namespace Editor {
|
||||
stderr: "inherit",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Bun.file(filepath).text()
|
||||
const content = await Filesystem.readText(filepath)
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
opts.renderer.resume()
|
||||
opts.renderer.requestRender()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
|
||||
interface UninstallArgs {
|
||||
keepConfig: boolean
|
||||
@@ -267,9 +268,7 @@ async function getShellConfigFile(): Promise<string | null> {
|
||||
.catch(() => false)
|
||||
if (!exists) continue
|
||||
|
||||
const content = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
const content = await Filesystem.readText(file).catch(() => "")
|
||||
if (content.includes("# opencode") || content.includes(".opencode/bin")) {
|
||||
return file
|
||||
}
|
||||
@@ -279,7 +278,7 @@ async function getShellConfigFile(): Promise<string | null> {
|
||||
}
|
||||
|
||||
async function cleanShellConfig(file: string) {
|
||||
const content = await Bun.file(file).text()
|
||||
const content = await Filesystem.readText(file)
|
||||
const lines = content.split("\n")
|
||||
|
||||
const filtered: string[] = []
|
||||
@@ -315,7 +314,7 @@ async function cleanShellConfig(file: string) {
|
||||
}
|
||||
|
||||
const output = filtered.join("\n") + "\n"
|
||||
await Bun.write(file, output)
|
||||
await Filesystem.write(file, output)
|
||||
}
|
||||
|
||||
async function getDirectorySize(dir: string): Promise<number> {
|
||||
|
||||
@@ -255,19 +255,20 @@ export namespace Config {
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
|
||||
const json = await Bun.file(pkg)
|
||||
.json()
|
||||
.catch(() => ({}))
|
||||
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
|
||||
dependencies: {},
|
||||
}))
|
||||
json.dependencies = {
|
||||
...json.dependencies,
|
||||
"@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))
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const hasGitIgnore = await Bun.file(gitignore).exists()
|
||||
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
if (!hasGitIgnore)
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
@@ -303,11 +304,10 @@ export namespace Config {
|
||||
if (!existsSync(nodeModules)) return true
|
||||
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const pkgFile = Bun.file(pkg)
|
||||
const pkgExists = await pkgFile.exists()
|
||||
const pkgExists = await Filesystem.exists(pkg)
|
||||
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 depVersion = dependencies["@opencode-ai/plugin"]
|
||||
if (!depVersion) return true
|
||||
@@ -1220,7 +1220,7 @@ export namespace Config {
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
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)
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -1231,12 +1231,10 @@ export namespace Config {
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
log.info("loading", { path: filepath })
|
||||
let text = await Bun.file(filepath)
|
||||
.text()
|
||||
.catch((err) => {
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
let text = await Filesystem.readText(filepath).catch((err: any) => {
|
||||
if (err.code === "ENOENT") return
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
if (!text) return {}
|
||||
return load(text, filepath)
|
||||
}
|
||||
@@ -1263,21 +1261,19 @@ export namespace Config {
|
||||
}
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Bun.file(resolvedPath)
|
||||
.text()
|
||||
.catch((error) => {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configFilepath,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
|
||||
})
|
||||
await Filesystem.readText(resolvedPath).catch((error: any) => {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configFilepath,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
// escape newlines/quotes, strip outer quotes
|
||||
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"
|
||||
// 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",')
|
||||
await Bun.write(configFilepath, updated).catch(() => {})
|
||||
await Filesystem.write(configFilepath, updated).catch(() => {})
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
@@ -1370,7 +1366,7 @@ export namespace Config {
|
||||
export async function update(config: Info) {
|
||||
const filepath = path.join(Instance.directory, "config.json")
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1441,24 +1437,22 @@ export namespace Config {
|
||||
|
||||
export async function updateGlobal(config: Info) {
|
||||
const filepath = globalConfigFile()
|
||||
const before = await Bun.file(filepath)
|
||||
.text()
|
||||
.catch((err) => {
|
||||
if (err.code === "ENOENT") return "{}"
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
const before = await Filesystem.readText(filepath).catch((err: any) => {
|
||||
if (err.code === "ENOENT") return "{}"
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
|
||||
const next = await (async () => {
|
||||
if (!filepath.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, filepath)
|
||||
const merged = mergeDeep(existing, config)
|
||||
await Bun.write(filepath, JSON.stringify(merged, null, 2))
|
||||
await Filesystem.writeJson(filepath, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
const updated = patchJsonc(before, config)
|
||||
const merged = parseConfig(updated, filepath)
|
||||
await Bun.write(filepath, updated)
|
||||
await Filesystem.write(filepath, updated)
|
||||
return merged
|
||||
})()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import matter from "gray-matter"
|
||||
import { z } from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
export namespace ConfigMarkdown {
|
||||
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
||||
@@ -68,7 +69,7 @@ export namespace ConfigMarkdown {
|
||||
}
|
||||
|
||||
export async function parse(filePath: string) {
|
||||
const template = await Bun.file(filePath).text()
|
||||
const template = await Filesystem.readText(filePath)
|
||||
|
||||
try {
|
||||
const md = matter(template)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
import { $ } from "bun"
|
||||
import type { BunFile } from "bun"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
@@ -241,8 +240,8 @@ export namespace File {
|
||||
return mimeType.startsWith("image/")
|
||||
}
|
||||
|
||||
async function shouldEncode(file: BunFile): Promise<boolean> {
|
||||
const type = file.type?.toLowerCase()
|
||||
async function shouldEncode(mimeType: string): Promise<boolean> {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.info("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
|
||||
@@ -385,7 +384,7 @@ export namespace File {
|
||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||
for (const filepath of untrackedFiles) {
|
||||
try {
|
||||
const content = await Bun.file(path.join(Instance.directory, filepath)).text()
|
||||
const content = await Filesystem.readText(path.join(Instance.directory, filepath))
|
||||
const lines = content.split("\n").length
|
||||
changedFiles.push({
|
||||
path: filepath,
|
||||
@@ -437,10 +436,9 @@ export namespace File {
|
||||
|
||||
// Fast path: check extension before any filesystem operations
|
||||
if (isImageByExtension(file)) {
|
||||
const bunFile = Bun.file(full)
|
||||
if (await bunFile.exists()) {
|
||||
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
|
||||
const content = Buffer.from(buffer).toString("base64")
|
||||
if (await Filesystem.exists(full)) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
const content = buffer.toString("base64")
|
||||
const mimeType = getImageMimeType(file)
|
||||
return { type: "text", content, mimeType, encoding: "base64" }
|
||||
}
|
||||
@@ -451,29 +449,24 @@ export namespace File {
|
||||
return { type: "binary", content: "" }
|
||||
}
|
||||
|
||||
const bunFile = Bun.file(full)
|
||||
|
||||
if (!(await bunFile.exists())) {
|
||||
if (!(await Filesystem.exists(full))) {
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const encode = await shouldEncode(bunFile)
|
||||
const mimeType = bunFile.type || "application/octet-stream"
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const encode = await shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) {
|
||||
return { type: "binary", content: "", mimeType }
|
||||
}
|
||||
|
||||
if (encode) {
|
||||
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
|
||||
const content = Buffer.from(buffer).toString("base64")
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
const content = buffer.toString("base64")
|
||||
return { type: "text", content, mimeType, encoding: "base64" }
|
||||
}
|
||||
|
||||
const content = await bunFile
|
||||
.text()
|
||||
.catch(() => "")
|
||||
.then((x) => x.trim())
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
|
||||
if (project.vcs === "git") {
|
||||
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
@@ -497,13 +490,13 @@ export namespace File {
|
||||
let ignored = (_: string) => false
|
||||
if (project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignore = Bun.file(path.join(Instance.worktree, ".gitignore"))
|
||||
if (await gitignore.exists()) {
|
||||
ig.add(await gitignore.text())
|
||||
const gitignorePath = path.join(Instance.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignorePath)) {
|
||||
ig.add(await Filesystem.readText(gitignorePath))
|
||||
}
|
||||
const ignoreFile = Bun.file(path.join(Instance.worktree, ".ignore"))
|
||||
if (await ignoreFile.exists()) {
|
||||
ig.add(await ignoreFile.text())
|
||||
const ignorePath = path.join(Instance.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignorePath)) {
|
||||
ig.add(await Filesystem.readText(ignorePath))
|
||||
}
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { $ } from "bun"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||
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 file = Bun.file(filepath)
|
||||
if (!(await file.exists())) {
|
||||
if (!(await Filesystem.exists(filepath))) {
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
const config = PLATFORM[platformKey]
|
||||
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
|
||||
@@ -144,9 +144,9 @@ export namespace Ripgrep {
|
||||
const response = await fetch(url)
|
||||
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)
|
||||
await Bun.write(archivePath, buffer)
|
||||
await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
|
||||
if (config.extension === "tar.gz") {
|
||||
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
||||
|
||||
@@ -166,7 +166,7 @@ export namespace Ripgrep {
|
||||
})
|
||||
}
|
||||
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()
|
||||
let rgEntry: any
|
||||
for (const entry of entries) {
|
||||
@@ -190,7 +190,7 @@ export namespace Ripgrep {
|
||||
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 fs.unlink(archivePath)
|
||||
|
||||
@@ -67,7 +67,10 @@ export const prettier: Info = {
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
|
||||
for (const item of items) {
|
||||
const json = await Bun.file(item).json()
|
||||
const json = await Filesystem.readJson<{
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.prettier) return true
|
||||
if (json.devDependencies?.prettier) return true
|
||||
}
|
||||
@@ -86,7 +89,10 @@ export const oxfmt: Info = {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
|
||||
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
|
||||
for (const item of items) {
|
||||
const json = await Bun.file(item).json()
|
||||
const json = await Filesystem.readJson<{
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.oxfmt) return true
|
||||
if (json.devDependencies?.oxfmt) return true
|
||||
}
|
||||
@@ -179,7 +185,7 @@ export const ruff: Info = {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
if (config === "pyproject.toml") {
|
||||
const content = await Bun.file(found[0]).text()
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("[tool.ruff]")) return true
|
||||
} else {
|
||||
return true
|
||||
@@ -190,7 +196,7 @@ export const ruff: Info = {
|
||||
for (const dep of deps) {
|
||||
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
const content = await Bun.file(found[0]).text()
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("ruff")) return true
|
||||
}
|
||||
}
|
||||
@@ -348,7 +354,10 @@ export const pint: Info = {
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
|
||||
for (const item of items) {
|
||||
const json = await Bun.file(item).json()
|
||||
const json = await Filesystem.readJson<{
|
||||
require?: Record<string, string>
|
||||
"require-dev"?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.require?.["laravel/pint"]) return true
|
||||
if (json["require-dev"]?.["laravel/pint"]) return true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "fs/promises"
|
||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
|
||||
const app = "opencode"
|
||||
|
||||
@@ -35,9 +36,7 @@ await Promise.all([
|
||||
|
||||
const CACHE_VERSION = "21"
|
||||
|
||||
const version = await Bun.file(path.join(Global.Path.cache, "version"))
|
||||
.text()
|
||||
.catch(() => "0")
|
||||
const version = await Filesystem.readText(path.join(Global.Path.cache, "version")).catch(() => "0")
|
||||
|
||||
if (version !== CACHE_VERSION) {
|
||||
try {
|
||||
@@ -51,5 +50,5 @@ if (version !== CACHE_VERSION) {
|
||||
),
|
||||
)
|
||||
} catch (e) {}
|
||||
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
|
||||
await Filesystem.write(path.join(Global.Path.cache, "version"), CACHE_VERSION)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Installation } from "./installation"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { FormatError } from "./cli/error"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { Filesystem } from "./util/filesystem"
|
||||
import { DebugCommand } from "./cli/cmd/debug"
|
||||
import { StatsCommand } from "./cli/cmd/stats"
|
||||
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")
|
||||
if (!(await Bun.file(marker).exists())) {
|
||||
if (!(await Filesystem.exists(marker))) {
|
||||
const tty = process.stderr.isTTY
|
||||
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
|
||||
const width = 36
|
||||
|
||||
@@ -44,6 +44,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
||||
".htm": "html",
|
||||
".ini": "ini",
|
||||
".java": "java",
|
||||
".jl": "julia",
|
||||
".js": "javascript",
|
||||
".kt": "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,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { dirname, join, relative } from "path"
|
||||
|
||||
export namespace Filesystem {
|
||||
export const exists = (p: string) =>
|
||||
Bun.file(p)
|
||||
.stat()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
// Fast sync version for metadata checks
|
||||
export async function exists(p: string): Promise<boolean> {
|
||||
return existsSync(p)
|
||||
}
|
||||
|
||||
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 = any>(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.
|
||||
* This is needed because Windows paths are case-insensitive but LSP servers
|
||||
@@ -26,6 +84,7 @@ export namespace Filesystem {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = relative(a, b)
|
||||
const relB = relative(b, a)
|
||||
|
||||
1
packages/opencode/sst-env.d.ts
vendored
1
packages/opencode/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { extractResponseText } from "../../src/cli/cmd/github"
|
||||
import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
// Helper to create minimal valid parts
|
||||
@@ -159,3 +159,39 @@ describe("extractResponseText", () => {
|
||||
expect(extractResponseText(parts)).toBe("Here's what I found")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatPromptTooLargeError", () => {
|
||||
test("formats error without files", () => {
|
||||
const result = formatPromptTooLargeError([])
|
||||
expect(result).toBe("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.")
|
||||
})
|
||||
|
||||
test("formats error with files (base64 content)", () => {
|
||||
// Base64 is ~33% larger than original, so we multiply by 0.75 to get original size
|
||||
// 400 KB base64 = 300 KB original, 200 KB base64 = 150 KB original
|
||||
const files = [
|
||||
{ filename: "screenshot.png", content: "a".repeat(400 * 1024) },
|
||||
{ filename: "diagram.png", content: "b".repeat(200 * 1024) },
|
||||
]
|
||||
const result = formatPromptTooLargeError(files)
|
||||
|
||||
expect(result).toStartWith("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.")
|
||||
expect(result).toInclude("Files in prompt:")
|
||||
expect(result).toInclude("screenshot.png (300 KB)")
|
||||
expect(result).toInclude("diagram.png (150 KB)")
|
||||
})
|
||||
|
||||
test("lists all files when multiple present", () => {
|
||||
// Base64 sizes: 4KB -> 3KB, 8KB -> 6KB, 12KB -> 9KB
|
||||
const files = [
|
||||
{ filename: "img1.png", content: "x".repeat(4 * 1024) },
|
||||
{ filename: "img2.jpg", content: "y".repeat(8 * 1024) },
|
||||
{ filename: "img3.gif", content: "z".repeat(12 * 1024) },
|
||||
]
|
||||
const result = formatPromptTooLargeError(files)
|
||||
|
||||
expect(result).toInclude("img1.png (3 KB)")
|
||||
expect(result).toInclude("img2.jpg (6 KB)")
|
||||
expect(result).toInclude("img3.gif (9 KB)")
|
||||
})
|
||||
})
|
||||
|
||||
340
packages/opencode/test/file/index.test.ts
Normal file
340
packages/opencode/test/file/index.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("file/index Bun.file patterns", () => {
|
||||
describe("File.read() - text content", () => {
|
||||
test("reads text file via Bun.file().text()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, "Hello World", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("Hello World")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reads with Bun.file().exists() check", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Non-existent file should return empty content
|
||||
const result = await File.read("nonexistent.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("trims whitespace from text content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.txt")
|
||||
expect(result.content).toBe("content with spaces")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles empty text file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "empty.txt")
|
||||
await fs.writeFile(filepath, "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("empty.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles multi-line text files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "multiline.txt")
|
||||
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("multiline.txt")
|
||||
expect(result.content).toBe("line1\nline2\nline3")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.read() - binary content", () => {
|
||||
test("reads binary file via Bun.file().arrayBuffer()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "image.png")
|
||||
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
await fs.writeFile(filepath, binaryContent)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("image.png")
|
||||
expect(result.type).toBe("text") // Images return as text with base64 encoding
|
||||
expect(result.encoding).toBe("base64")
|
||||
expect(result.mimeType).toBe("image/png")
|
||||
expect(result.content).toBe(binaryContent.toString("base64"))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty for binary non-image files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "binary.so")
|
||||
await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("binary.so")
|
||||
expect(result.type).toBe("binary")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.read() - Bun.file().type", () => {
|
||||
test("detects MIME type via Bun.file().type", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.json")
|
||||
await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bunFile = Bun.file(filepath)
|
||||
expect(bunFile.type).toContain("application/json")
|
||||
|
||||
const result = await File.read("test.json")
|
||||
expect(result.type).toBe("text")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles various image MIME types", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const testCases = [
|
||||
{ ext: "jpg", mime: "image/jpeg" },
|
||||
{ ext: "png", mime: "image/png" },
|
||||
{ ext: "gif", mime: "image/gif" },
|
||||
{ ext: "webp", mime: "image/webp" },
|
||||
]
|
||||
|
||||
for (const { ext, mime } of testCases) {
|
||||
const filepath = path.join(tmp.path, `test.${ext}`)
|
||||
await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bunFile = Bun.file(filepath)
|
||||
expect(bunFile.type).toContain(mime)
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.list() - Bun.file().exists() and .text()", () => {
|
||||
test("reads .gitignore via Bun.file().exists() and .text()", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
|
||||
|
||||
// This is used internally in File.list()
|
||||
const bunFile = Bun.file(gitignorePath)
|
||||
expect(await bunFile.exists()).toBe(true)
|
||||
|
||||
const content = await bunFile.text()
|
||||
expect(content).toContain("node_modules")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reads .ignore file similarly", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ignorePath = path.join(tmp.path, ".ignore")
|
||||
await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
|
||||
|
||||
const bunFile = Bun.file(ignorePath)
|
||||
expect(await bunFile.exists()).toBe(true)
|
||||
expect(await bunFile.text()).toContain("*.log")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles missing .gitignore gracefully", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||
const bunFile = Bun.file(gitignorePath)
|
||||
expect(await bunFile.exists()).toBe(false)
|
||||
|
||||
// File.list() should still work
|
||||
const nodes = await File.list()
|
||||
expect(Array.isArray(nodes)).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.changed() - Bun.file().text() for untracked files", () => {
|
||||
test("reads untracked files via Bun.file().text()", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const untrackedPath = path.join(tmp.path, "untracked.txt")
|
||||
await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
|
||||
|
||||
// This is how File.changed() reads untracked files
|
||||
const bunFile = Bun.file(untrackedPath)
|
||||
const content = await bunFile.text()
|
||||
const lines = content.split("\n").length
|
||||
expect(lines).toBe(2)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
test("handles errors gracefully in Bun.file().text()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "readonly.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.txt"))
|
||||
// Bun.file().text() on non-existent file throws
|
||||
await expect(nonExistentFile.text()).rejects.toThrow()
|
||||
|
||||
// But File.read() handles this gracefully
|
||||
const result = await File.read("does-not-exist.txt")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles errors in Bun.file().arrayBuffer()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.bin"))
|
||||
const buffer = await nonExistentFile.arrayBuffer().catch(() => new ArrayBuffer(0))
|
||||
expect(buffer.byteLength).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty array buffer on error for images", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "broken.png")
|
||||
// Don't create the file
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bunFile = Bun.file(filepath)
|
||||
// File.read() handles missing images gracefully
|
||||
const result = await File.read("broken.png")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldEncode() logic", () => {
|
||||
test("returns encoding info for text files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, "simple text", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.txt")
|
||||
expect(result.encoding).toBeUndefined()
|
||||
expect(result.type).toBe("text")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns base64 encoding for images", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.jpg")
|
||||
await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.jpg")
|
||||
expect(result.encoding).toBe("base64")
|
||||
expect(result.mimeType).toBe("image/jpeg")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Path security", () => {
|
||||
test("throws for paths outside project directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws for paths outside project directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
360
packages/opencode/test/file/time.test.ts
Normal file
360
packages/opencode/test/file/time.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("file/time", () => {
|
||||
const sessionID = "test-session-123"
|
||||
|
||||
describe("read() and get()", () => {
|
||||
test("stores read timestamp", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = FileTime.get(sessionID, filepath)
|
||||
expect(before).toBeUndefined()
|
||||
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
const after = FileTime.get(sessionID, filepath)
|
||||
expect(after).toBeInstanceOf(Date)
|
||||
expect(after!.getTime()).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks separate timestamps per session", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read("session1", filepath)
|
||||
FileTime.read("session2", filepath)
|
||||
|
||||
const time1 = FileTime.get("session1", filepath)
|
||||
const time2 = FileTime.get("session2", filepath)
|
||||
|
||||
expect(time1).toBeDefined()
|
||||
expect(time2).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("updates timestamp on subsequent reads", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
const first = FileTime.get(sessionID, filepath)!
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
FileTime.read(sessionID, filepath)
|
||||
const second = FileTime.get(sessionID, filepath)!
|
||||
|
||||
expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("assert()", () => {
|
||||
test("passes when file has not been modified", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
// Should not throw
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when file was not read first", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when file was modified after read", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
// Wait to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Modify file after reading
|
||||
await fs.writeFile(filepath, "modified content", "utf-8")
|
||||
|
||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("includes timestamps in error message", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
|
||||
let error: Error | undefined
|
||||
try {
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
} catch (e) {
|
||||
error = e as Error
|
||||
}
|
||||
expect(error).toBeDefined()
|
||||
expect(error!.message).toContain("Last modification:")
|
||||
expect(error!.message).toContain("Last read:")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { Flag } = await import("../../src/flag/flag")
|
||||
const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
|
||||
|
||||
try {
|
||||
// Should not throw even though file wasn't read
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
} finally {
|
||||
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("withLock()", () => {
|
||||
test("executes function within lock", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
let executed = false
|
||||
await FileTime.withLock(filepath, async () => {
|
||||
executed = true
|
||||
return "result"
|
||||
})
|
||||
expect(executed).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns function result", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await FileTime.withLock(filepath, async () => {
|
||||
return "success"
|
||||
})
|
||||
expect(result).toBe("success")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes concurrent operations on same file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const order: number[] = []
|
||||
|
||||
const op1 = FileTime.withLock(filepath, async () => {
|
||||
order.push(1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
order.push(2)
|
||||
})
|
||||
|
||||
const op2 = FileTime.withLock(filepath, async () => {
|
||||
order.push(3)
|
||||
order.push(4)
|
||||
})
|
||||
|
||||
await Promise.all([op1, op2])
|
||||
|
||||
// Operations should be serialized
|
||||
expect(order).toContain(1)
|
||||
expect(order).toContain(2)
|
||||
expect(order).toContain(3)
|
||||
expect(order).toContain(4)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("allows concurrent operations on different files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath1 = path.join(tmp.path, "file1.txt")
|
||||
const filepath2 = path.join(tmp.path, "file2.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
let started1 = false
|
||||
let started2 = false
|
||||
|
||||
const op1 = FileTime.withLock(filepath1, async () => {
|
||||
started1 = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(started2).toBe(true) // op2 should have started while op1 is running
|
||||
})
|
||||
|
||||
const op2 = FileTime.withLock(filepath2, async () => {
|
||||
started2 = true
|
||||
})
|
||||
|
||||
await Promise.all([op1, op2])
|
||||
|
||||
expect(started1).toBe(true)
|
||||
expect(started2).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("releases lock even if function throws", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(
|
||||
FileTime.withLock(filepath, async () => {
|
||||
throw new Error("Test error")
|
||||
}),
|
||||
).rejects.toThrow("Test error")
|
||||
|
||||
// Lock should be released, subsequent operations should work
|
||||
let executed = false
|
||||
await FileTime.withLock(filepath, async () => {
|
||||
executed = true
|
||||
})
|
||||
expect(executed).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("deadlocks on nested locks (expected behavior)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Nested locks on same file cause deadlock - this is expected
|
||||
// The outer lock waits for inner to complete, but inner waits for outer to release
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Deadlock detected")), 100),
|
||||
)
|
||||
|
||||
const nestedLock = FileTime.withLock(filepath, async () => {
|
||||
return FileTime.withLock(filepath, async () => {
|
||||
return "inner"
|
||||
})
|
||||
})
|
||||
|
||||
// Should timeout due to deadlock
|
||||
await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("stat() Bun.file pattern", () => {
|
||||
test("reads file modification time via Bun.file().stat()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
const stats = await Bun.file(filepath).stat()
|
||||
expect(stats.mtime).toBeInstanceOf(Date)
|
||||
expect(stats.mtime.getTime()).toBeGreaterThan(0)
|
||||
|
||||
// FileTime.assert uses this stat internally
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects modification via stat mtime", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
const originalStat = await Bun.file(filepath).stat()
|
||||
|
||||
// Wait and modify
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
|
||||
const newStat = await Bun.file(filepath).stat()
|
||||
expect(newStat.mtime.getTime()).toBeGreaterThan(originalStat.mtime.getTime())
|
||||
|
||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
496
packages/opencode/test/tool/edit.test.ts
Normal file
496
packages/opencode/test/tool/edit.test.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { EditTool } from "../../src/tool/edit"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test-edit-session",
|
||||
messageID: "",
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
describe("tool.edit", () => {
|
||||
describe("creating new files", () => {
|
||||
test("creates new file when oldString is empty", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "newfile.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.diff).toContain("new content")
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("new content")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("creates new file with nested directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "nested file",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("nested file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits add event for new files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "new.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(events).toContain("edited")
|
||||
expect(events).toContain("updated")
|
||||
unsubEdited()
|
||||
unsubUpdated()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("editing existing files", () => {
|
||||
test("replaces text in existing file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "existing.txt")
|
||||
await fs.writeFile(filepath, "old content here", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old content",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Edit applied successfully")
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("new content here")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when file does not exist", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nonexistent.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("not found")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when oldString equals newString", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "same",
|
||||
newString: "same",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when oldString not found in file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "actual content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "not in file",
|
||||
newString: "replacement",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when file was not read first (FileTime)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "content",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("You must read file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when file has been modified since read", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Read first
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
// Wait a bit to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Simulate external modification
|
||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||
|
||||
// Try to edit with the new content
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "modified externally",
|
||||
newString: "edited",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("replaces all occurrences with replaceAll option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "foo",
|
||||
newString: "qux",
|
||||
replaceAll: true,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("qux bar qux baz qux")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits change event for existing files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "original",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(events).toContain("edited")
|
||||
expect(events).toContain("updated")
|
||||
unsubEdited()
|
||||
unsubUpdated()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("handles multiline replacements", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line 2\nextra line",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("line1\nnew line 2\nextra line\nline3")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles CRLF line endings", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("line1\r\nnew\r\nline3")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when oldString equals newString", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when path is directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirpath = path.join(tmp.path, "adir")
|
||||
await fs.mkdir(dirpath)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: dirpath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks file diff statistics", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line a\nnew line b",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.filediff).toBeDefined()
|
||||
expect(result.metadata.filediff.file).toBe(filepath)
|
||||
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("concurrent editing", () => {
|
||||
test("serializes concurrent edits to same file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "0", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
|
||||
// Two concurrent edits
|
||||
const promise1 = edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "1",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
// Need to read again since FileTime tracks per-session
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const promise2 = edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "2",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
// Both should complete without error (though one might fail due to content mismatch)
|
||||
const results = await Promise.allSettled([promise1, promise2])
|
||||
expect(results.some((r) => r.status === "fulfilled")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
341
packages/opencode/test/tool/write.test.ts
Normal file
341
packages/opencode/test/tool/write.test.ts
Normal 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 os from "node:os"
|
||||
import path from "node:path"
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises"
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("util.filesystem", () => {
|
||||
test("exists() is true for files and directories", async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
|
||||
const dir = path.join(tmp, "dir")
|
||||
const file = path.join(tmp, "file.txt")
|
||||
const missing = path.join(tmp, "missing")
|
||||
describe("filesystem", () => {
|
||||
describe("exists()", () => {
|
||||
test("returns true for existing file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await mkdir(dir, { recursive: true })
|
||||
await Bun.write(file, "hello")
|
||||
expect(await Filesystem.exists(filepath)).toBe(true)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
|
||||
const dir = path.join(tmp, "dir")
|
||||
const file = path.join(tmp, "file.txt")
|
||||
const missing = path.join(tmp, "missing")
|
||||
describe("isDir()", () => {
|
||||
test("returns true for directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirpath = path.join(tmp.path, "testdir")
|
||||
await fs.mkdir(dirpath)
|
||||
|
||||
await mkdir(dir, { recursive: true })
|
||||
await Bun.write(file, "hello")
|
||||
expect(await Filesystem.isDir(dirpath)).toBe(true)
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
1
packages/plugin/sst-env.d.ts
vendored
1
packages/plugin/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
|
||||
1
packages/script/sst-env.d.ts
vendored
1
packages/script/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
|
||||
@@ -12,8 +12,18 @@
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./v2": "./src/v2/index.ts",
|
||||
"./v2/client": "./src/v2/client.ts",
|
||||
"./v2": {
|
||||
"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"
|
||||
},
|
||||
"files": [
|
||||
|
||||
1
packages/sdk/js/sst-env.d.ts
vendored
1
packages/sdk/js/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"module": "nodenext",
|
||||
"declaration": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/gen"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
1
packages/slack/sst-env.d.ts
vendored
1
packages/slack/sst-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
|
||||
@@ -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 type { IconProps } from "./icon"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
@@ -27,18 +27,52 @@ export interface BasicToolProps {
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
forceOpen?: boolean
|
||||
defer?: boolean
|
||||
locked?: boolean
|
||||
onSubtitleClick?: () => void
|
||||
}
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
|
||||
const [ready, setReady] = createSignal(open())
|
||||
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(() => {
|
||||
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) => {
|
||||
if (pending()) return
|
||||
if (props.locked && !value) return
|
||||
@@ -114,7 +148,9 @@ export function BasicTool(props: BasicToolProps) {
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<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>
|
||||
</Collapsible>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user