Merge branch 'dev' into brendan-cli-codesign
This commit is contained in:
commit
a855229052
229 changed files with 12653 additions and 5397 deletions
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
|
|
@ -25,3 +25,4 @@ r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
|
-OpenCodeEngineer bot that spams issues
|
||||||
|
|
|
||||||
14
bun.lock
14
bun.lock
|
|
@ -329,7 +329,7 @@
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
|
@ -358,7 +358,7 @@
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "5.3.2",
|
"gitlab-ai-provider": "5.3.3",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
|
|
@ -1325,7 +1325,7 @@
|
||||||
|
|
||||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||||
|
|
||||||
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
||||||
|
|
||||||
|
|
@ -2889,7 +2889,7 @@
|
||||||
|
|
||||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||||
|
|
||||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
|
||||||
|
|
||||||
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
|
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
|
||||||
|
|
||||||
|
|
@ -3037,7 +3037,7 @@
|
||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||||
|
|
||||||
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "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-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
|
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "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-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
|
||||||
|
|
||||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||||
|
|
||||||
|
|
@ -5129,6 +5129,8 @@
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
|
"@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
@ -6311,6 +6313,8 @@
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||||
|
|
||||||
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
||||||
|
|
||||||
"opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
|
||||||
|
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL: Record<string, [string, string]> = {
|
||||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||||
todoread: ["Todo", "\x1b[33m\x1b[1m"],
|
|
||||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
|
"x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=",
|
||||||
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
|
"aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=",
|
||||||
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
|
"aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=",
|
||||||
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
|
"x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
import { File } from "@opencode-ai/ui/file"
|
import { File } from "@opencode-ai/ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
|
@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
|
||||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
|
|
@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Font />
|
<Font />
|
||||||
|
|
@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) {
|
||||||
void window.api?.setTitlebar?.({ mode })
|
void window.api?.setTitlebar?.({ mode })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LanguageProvider>
|
<LanguageProvider locale={props.locale}>
|
||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
|
@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
|
@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||||
const methods = createMemo(
|
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||||
() =>
|
{
|
||||||
globalSync.data.provider_auth[props.provider] ?? [
|
type: "api" as const,
|
||||||
{
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
type: "api",
|
},
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
])
|
||||||
},
|
const [auth] = createResource(
|
||||||
],
|
() => props.provider,
|
||||||
|
async () => {
|
||||||
|
const cached = globalSync.data.provider_auth[props.provider]
|
||||||
|
if (cached) return cached
|
||||||
|
const res = await globalSDK.client.provider.auth()
|
||||||
|
if (!alive.value) return fallback()
|
||||||
|
globalSync.set("provider_auth", res.data ?? {})
|
||||||
|
return res.data?.[props.provider] ?? fallback()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
||||||
|
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
|
|
@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
index: 0,
|
index: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prompts = createMemo(() => method()?.prompts ?? [])
|
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||||
|
const value = method()
|
||||||
|
if (value?.type !== "oauth") return []
|
||||||
|
return value.prompts ?? []
|
||||||
|
})
|
||||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
if (!prompt.when) return true
|
if (!prompt.when) return true
|
||||||
const actual = value[prompt.when.key]
|
const actual = value[prompt.when.key]
|
||||||
|
|
@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
listRef?.onKeyDown(e)
|
listRef?.onKeyDown(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
let auto = false
|
||||||
|
createEffect(() => {
|
||||||
|
if (auto) return
|
||||||
|
if (loading()) return
|
||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
|
auto = true
|
||||||
selectMethod(0)
|
selectMethod(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Match when={loading()}>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<Spinner />
|
||||||
|
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
<Match when={store.methodIndex === undefined}>
|
<Match when={store.methodIndex === undefined}>
|
||||||
<MethodSelection />
|
<MethodSelection />
|
||||||
</Match>
|
</Match>
|
||||||
|
|
|
||||||
|
|
@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const open = recent()
|
const open = recent()
|
||||||
const seen = new Set(open)
|
const seen = new Set(open)
|
||||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||||
|
if (!query.trim()) return [...agents, ...pinned]
|
||||||
const paths = await files.searchFilesAndDirectories(query)
|
const paths = await files.searchFilesAndDirectories(query)
|
||||||
const fileOptions: AtOption[] = paths
|
const fileOptions: AtOption[] = paths
|
||||||
.filter((path) => !seen.has(path))
|
.filter((path) => !seen.has(path))
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,41 @@
|
||||||
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
|
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { Link } from "./link"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
cleanup: undefined as (() => void) | undefined,
|
cleanup: undefined as (() => void) | undefined,
|
||||||
timeout: undefined as NodeJS.Timeout | undefined,
|
timeout: undefined as NodeJS.Timeout | undefined,
|
||||||
|
run: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||||
|
|
||||||
|
function loadFont() {
|
||||||
|
font ??= import("@opencode-ai/ui/font-loader")
|
||||||
|
return font
|
||||||
}
|
}
|
||||||
|
|
||||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||||
const stopDemoSound = () => {
|
const stopDemoSound = () => {
|
||||||
|
demoSoundState.run += 1
|
||||||
if (demoSoundState.cleanup) {
|
if (demoSoundState.cleanup) {
|
||||||
demoSoundState.cleanup()
|
demoSoundState.cleanup()
|
||||||
}
|
}
|
||||||
|
|
@ -29,12 +43,19 @@ const stopDemoSound = () => {
|
||||||
demoSoundState.cleanup = undefined
|
demoSoundState.cleanup = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const playDemoSound = (src: string | undefined) => {
|
const playDemoSound = (id: string | undefined) => {
|
||||||
stopDemoSound()
|
stopDemoSound()
|
||||||
if (!src) return
|
if (!id) return
|
||||||
|
|
||||||
|
const run = ++demoSoundState.run
|
||||||
demoSoundState.timeout = setTimeout(() => {
|
demoSoundState.timeout = setTimeout(() => {
|
||||||
demoSoundState.cleanup = playSound(src)
|
void playSoundById(id).then((cleanup) => {
|
||||||
|
if (demoSoundState.run !== run) {
|
||||||
|
cleanup?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
demoSoundState.cleanup = cleanup
|
||||||
|
})
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void theme.loadThemes()
|
||||||
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
checking: false,
|
checking: false,
|
||||||
})
|
})
|
||||||
|
|
@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
|
||||||
.finally(() => setStore("checking", false))
|
.finally(() => setStore("checking", false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeOptions = createMemo(() =>
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||||
{ value: "system", label: language.t("theme.scheme.system") },
|
{ value: "system", label: language.t("theme.scheme.system") },
|
||||||
|
|
@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
|
||||||
] as const
|
] as const
|
||||||
const fontOptionsList = [...fontOptions]
|
const fontOptionsList = [...fontOptions]
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
|
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||||
|
|
||||||
const soundSelectProps = (
|
const soundSelectProps = (
|
||||||
|
|
@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
|
||||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
playDemoSound(option.src)
|
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||||
},
|
},
|
||||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
|
|
@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
|
||||||
}
|
}
|
||||||
setEnabled(true)
|
setEnabled(true)
|
||||||
set(option.id)
|
set(option.id)
|
||||||
playDemoSound(option.src)
|
playDemoSound(option.id)
|
||||||
},
|
},
|
||||||
variant: "secondary" as const,
|
variant: "secondary" as const,
|
||||||
size: "small" as const,
|
size: "small" as const,
|
||||||
|
|
@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
|
||||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||||
value={(o) => o.value}
|
value={(o) => o.value}
|
||||||
label={(o) => language.t(o.label)}
|
label={(o) => language.t(o.label)}
|
||||||
|
onHighlight={(option) => {
|
||||||
|
void loadFont().then((x) => x.ensureMonoFont(option?.value))
|
||||||
|
}}
|
||||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
import { DialogSelectServer } from "./dialog-select-server"
|
|
||||||
|
|
||||||
const pollMs = 10_000
|
const pollMs = 10_000
|
||||||
|
|
||||||
|
|
@ -54,11 +53,15 @@ const listServersByHealth = (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||||
const checkServerHealth = useCheckServerHealth()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
if (!enabled()) {
|
||||||
|
setStatus(reconcile({}))
|
||||||
|
return
|
||||||
|
}
|
||||||
const list = servers()
|
const list = servers()
|
||||||
let dead = false
|
let dead = false
|
||||||
|
|
||||||
|
|
@ -162,6 +165,12 @@ export function StatusPopover() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
|
let dialogRun = 0
|
||||||
|
let dialogDead = false
|
||||||
|
onCleanup(() => {
|
||||||
|
dialogDead = true
|
||||||
|
dialogRun += 1
|
||||||
|
})
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
const current = server.current
|
const current = server.current
|
||||||
const list = server.list
|
const list = server.list
|
||||||
|
|
@ -169,7 +178,7 @@ export function StatusPopover() {
|
||||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||||
})
|
})
|
||||||
const health = useServerHealth(servers)
|
const health = useServerHealth(servers, shown)
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
const toggleMcp = useMcpToggleMutation()
|
const toggleMcp = useMcpToggleMutation()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
|
|
@ -300,7 +309,13 @@ export function StatusPopover() {
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||||
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
onClick={() => {
|
||||||
|
const run = ++dialogRun
|
||||||
|
void import("./dialog-select-server").then((x) => {
|
||||||
|
if (dialogDead || dialogRun !== run) return
|
||||||
|
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||||
|
})
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{language.t("status.popover.action.manageServers")}
|
{language.t("status.popover.action.manageServers")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||||
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||||
|
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme"
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
|
||||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("matchKeybind supports bracket keys", () => {
|
||||||
|
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
|
||||||
|
const prev = keybinds[0]
|
||||||
|
const next = keybinds[1]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchKeybind(
|
||||||
|
keybinds,
|
||||||
|
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
matchKeybind(
|
||||||
|
keybinds,
|
||||||
|
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("formatKeybind returns human readable output", () => {
|
test("formatKeybind returns human readable output", () => {
|
||||||
const display = formatKeybind("ctrl+alt+arrowup")
|
const display = formatKeybind("ctrl+alt+arrowup")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,7 @@ import type {
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import {
|
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||||
createContext,
|
|
||||||
getOwner,
|
|
||||||
Match,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
type ParentProps,
|
|
||||||
Switch,
|
|
||||||
untrack,
|
|
||||||
useContext,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
@ -80,6 +70,8 @@ function createGlobalSync() {
|
||||||
|
|
||||||
let active = true
|
let active = true
|
||||||
let projectWritten = false
|
let projectWritten = false
|
||||||
|
let bootedAt = 0
|
||||||
|
let bootingRoot = false
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
active = false
|
active = false
|
||||||
|
|
@ -258,6 +250,11 @@ function createGlobalSync() {
|
||||||
const sdk = sdkFor(directory)
|
const sdk = sdkFor(directory)
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
|
global: {
|
||||||
|
config: globalStore.config,
|
||||||
|
project: globalStore.project,
|
||||||
|
provider: globalStore.provider,
|
||||||
|
},
|
||||||
sdk,
|
sdk,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
|
|
@ -278,15 +275,20 @@ function createGlobalSync() {
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const event = e.details
|
const event = e.details
|
||||||
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
event,
|
event,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
refresh: queue.refresh,
|
refresh: () => {
|
||||||
|
if (recent) return
|
||||||
|
queue.refresh()
|
||||||
|
},
|
||||||
setGlobalProject: setProjects,
|
setGlobalProject: setProjects,
|
||||||
})
|
})
|
||||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||||
|
if (recent) return
|
||||||
for (const directory of Object.keys(children.children)) {
|
for (const directory of Object.keys(children.children)) {
|
||||||
queue.push(directory)
|
queue.push(directory)
|
||||||
}
|
}
|
||||||
|
|
@ -325,17 +327,19 @@ function createGlobalSync() {
|
||||||
})
|
})
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await bootstrapGlobal({
|
bootingRoot = true
|
||||||
globalSDK: globalSDK.client,
|
try {
|
||||||
connectErrorTitle: language.t("dialog.server.add.error"),
|
await bootstrapGlobal({
|
||||||
connectErrorDescription: language.t("error.globalSync.connectFailed", {
|
globalSDK: globalSDK.client,
|
||||||
url: globalSDK.url,
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
}),
|
translate: language.t,
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
translate: language.t,
|
setGlobalStore: setBootStore,
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
})
|
||||||
setGlobalStore: setBootStore,
|
bootedAt = Date.now()
|
||||||
})
|
} finally {
|
||||||
|
bootingRoot = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
||||||
|
|
||||||
export function GlobalSyncProvider(props: ParentProps) {
|
export function GlobalSyncProvider(props: ParentProps) {
|
||||||
const value = createGlobalSync()
|
const value = createGlobalSync()
|
||||||
return (
|
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||||
<Switch>
|
|
||||||
<Match when={value.ready}>
|
|
||||||
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGlobalSync() {
|
export function useGlobalSync() {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { cmp, normalizeProviderList } from "./utils"
|
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
|
|
@ -31,73 +31,102 @@ type GlobalStore = {
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForPaint() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
let done = false
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const timer = setTimeout(finish, 50)
|
||||||
|
if (typeof requestAnimationFrame !== "function") return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
finish()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function errors(list: PromiseSettledResult<unknown>[]) {
|
||||||
|
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAll(list: Array<() => Promise<unknown>>) {
|
||||||
|
return Promise.allSettled(list.map((item) => item()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrors(input: {
|
||||||
|
errors: unknown[]
|
||||||
|
title: string
|
||||||
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
|
formatMoreCount: (count: number) => string
|
||||||
|
}) {
|
||||||
|
if (input.errors.length === 0) return
|
||||||
|
const message = formatServerError(input.errors[0], input.translate)
|
||||||
|
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.title,
|
||||||
|
description: message + more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
globalSDK: OpencodeClient
|
globalSDK: OpencodeClient
|
||||||
connectErrorTitle: string
|
|
||||||
connectErrorDescription: string
|
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
formatMoreCount: (count: number) => string
|
formatMoreCount: (count: number) => string
|
||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
}) {
|
}) {
|
||||||
const health = await input.globalSDK.global
|
const fast = [
|
||||||
.health()
|
() =>
|
||||||
.then((x) => x.data)
|
retry(() =>
|
||||||
.catch(() => undefined)
|
input.globalSDK.path.get().then((x) => {
|
||||||
if (!health?.healthy) {
|
input.setGlobalStore("path", x.data!)
|
||||||
showToast({
|
}),
|
||||||
variant: "error",
|
),
|
||||||
title: input.connectErrorTitle,
|
() =>
|
||||||
description: input.connectErrorDescription,
|
retry(() =>
|
||||||
})
|
input.globalSDK.global.config.get().then((x) => {
|
||||||
input.setGlobalStore("ready", true)
|
input.setGlobalStore("config", x.data!)
|
||||||
return
|
}),
|
||||||
}
|
),
|
||||||
|
() =>
|
||||||
const tasks = [
|
retry(() =>
|
||||||
retry(() =>
|
input.globalSDK.provider.list().then((x) => {
|
||||||
input.globalSDK.path.get().then((x) => {
|
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||||
input.setGlobalStore("path", x.data!)
|
}),
|
||||||
}),
|
),
|
||||||
),
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.global.config.get().then((x) => {
|
|
||||||
input.setGlobalStore("config", x.data!)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.project.list().then((x) => {
|
|
||||||
const projects = (x.data ?? [])
|
|
||||||
.filter((p) => !!p?.id)
|
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
|
||||||
input.setGlobalStore("project", projects)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.provider.list().then((x) => {
|
|
||||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.provider.auth().then((x) => {
|
|
||||||
input.setGlobalStore("provider_auth", x.data ?? {})
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const results = await Promise.allSettled(tasks)
|
const slow = [
|
||||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
() =>
|
||||||
if (errors.length) {
|
retry(() =>
|
||||||
const message = formatServerError(errors[0], input.translate)
|
input.globalSDK.project.list().then((x) => {
|
||||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
const projects = (x.data ?? [])
|
||||||
showToast({
|
.filter((p) => !!p?.id)
|
||||||
variant: "error",
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
title: input.requestFailedTitle,
|
.slice()
|
||||||
description: message + more,
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
})
|
input.setGlobalStore("project", projects)
|
||||||
}
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
showErrors({
|
||||||
|
errors: errors(await runAll(fast)),
|
||||||
|
title: input.requestFailedTitle,
|
||||||
|
translate: input.translate,
|
||||||
|
formatMoreCount: input.formatMoreCount,
|
||||||
|
})
|
||||||
|
await waitForPaint()
|
||||||
|
showErrors({
|
||||||
|
errors: errors(await runAll(slow)),
|
||||||
|
title: input.requestFailedTitle,
|
||||||
|
translate: input.translate,
|
||||||
|
formatMoreCount: input.formatMoreCount,
|
||||||
|
})
|
||||||
input.setGlobalStore("ready", true)
|
input.setGlobalStore("ready", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectID(directory: string, projects: Project[]) {
|
||||||
|
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||||
|
}
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
|
|
@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
|
||||||
vcsCache: VcsCache
|
vcsCache: VcsCache
|
||||||
loadSessions: (directory: string) => Promise<void> | void
|
loadSessions: (directory: string) => Promise<void> | void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
}) {
|
global: {
|
||||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
config: Config
|
||||||
|
project: Project[]
|
||||||
const blockingRequests = {
|
provider: ProviderListResponse
|
||||||
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
|
||||||
provider: () =>
|
|
||||||
input.sdk.provider.list().then((x) => {
|
|
||||||
input.setStore("provider", normalizeProviderList(x.data!))
|
|
||||||
}),
|
|
||||||
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
|
|
||||||
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
|
|
||||||
}
|
}
|
||||||
|
}) {
|
||||||
|
const loading = input.store.status !== "complete"
|
||||||
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
|
if (seededProject) input.setStore("project", seededProject)
|
||||||
|
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||||
|
input.setStore("provider", input.global.provider)
|
||||||
|
}
|
||||||
|
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||||
|
input.setStore("config", input.global.config)
|
||||||
|
}
|
||||||
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
try {
|
const fast = [
|
||||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
() =>
|
||||||
} catch (err) {
|
seededProject
|
||||||
console.error("Failed to bootstrap instance", err)
|
? Promise.resolve()
|
||||||
|
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||||
|
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||||
|
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.path.get().then((x) => {
|
||||||
|
input.setStore("path", x.data!)
|
||||||
|
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||||
|
if (next) input.setStore("project", next)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.vcs.get().then((x) => {
|
||||||
|
const next = x.data ?? input.store.vcs
|
||||||
|
input.setStore("vcs", next)
|
||||||
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.permission.list().then((x) => {
|
||||||
|
const grouped = groupBySession(
|
||||||
|
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||||
|
)
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(input.store.permission)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
input.setStore("permission", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
|
input.setStore(
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.question.list().then((x) => {
|
||||||
|
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(input.store.question)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
input.setStore("question", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||||
|
input.setStore(
|
||||||
|
"question",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const slow = [
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.provider.list().then((x) => {
|
||||||
|
input.setStore("provider", normalizeProviderList(x.data!))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
|
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||||
|
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||||
|
]
|
||||||
|
|
||||||
|
const errs = errors(await runAll(fast))
|
||||||
|
if (errs.length > 0) {
|
||||||
|
console.error("Failed to bootstrap instance", errs[0])
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
description: formatServerError(err, input.translate),
|
description: formatServerError(errs[0], input.translate),
|
||||||
})
|
})
|
||||||
input.setStore("status", "partial")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.store.status !== "complete") input.setStore("status", "partial")
|
await waitForPaint()
|
||||||
|
const slowErrs = errors(await runAll(slow))
|
||||||
|
if (slowErrs.length > 0) {
|
||||||
|
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||||
|
const project = getFilename(input.directory)
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
|
description: formatServerError(slowErrs[0], input.translate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all([
|
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||||
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
|
|
||||||
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
|
|
||||||
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
|
|
||||||
input.loadSessions(input.directory),
|
|
||||||
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
|
|
||||||
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
|
|
||||||
input.sdk.vcs.get().then((x) => {
|
|
||||||
const next = x.data ?? input.store.vcs
|
|
||||||
input.setStore("vcs", next)
|
|
||||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
|
||||||
}),
|
|
||||||
input.sdk.permission.list().then((x) => {
|
|
||||||
const grouped = groupBySession(
|
|
||||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
|
||||||
)
|
|
||||||
batch(() => {
|
|
||||||
for (const sessionID of Object.keys(input.store.permission)) {
|
|
||||||
if (grouped[sessionID]) continue
|
|
||||||
input.setStore("permission", sessionID, [])
|
|
||||||
}
|
|
||||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
|
||||||
input.setStore(
|
|
||||||
"permission",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
input.sdk.question.list().then((x) => {
|
|
||||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
|
||||||
batch(() => {
|
|
||||||
for (const sessionID of Object.keys(input.store.question)) {
|
|
||||||
if (grouped[sessionID]) continue
|
|
||||||
input.setStore("question", sessionID, [])
|
|
||||||
}
|
|
||||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
|
||||||
input.setStore(
|
|
||||||
"question",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
]).then(() => {
|
|
||||||
input.setStore("status", "complete")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("updates vcs branch in store and cache", () => {
|
test("updates vcs branch in store and cache", () => {
|
||||||
const [store, setStore] = createStore(baseState())
|
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
const [cacheStore, setCacheStore] = createStore({
|
||||||
|
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||||
|
})
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||||
|
|
@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||||
|
|
|
||||||
|
|
@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vcs.branch.updated": {
|
case "vcs.branch.updated": {
|
||||||
const props = event.properties as { branch: string }
|
const props = event.properties as { branch?: string }
|
||||||
if (input.store.vcs?.branch === props.branch) break
|
if (input.store.vcs?.branch === props.branch) break
|
||||||
const next = { branch: props.branch }
|
const next = { ...input.store.vcs, branch: props.branch }
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||||
break
|
break
|
||||||
|
|
|
||||||
35
packages/app/src/context/global-sync/utils.test.ts
Normal file
35
packages/app/src/context/global-sync/utils.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { normalizeAgentList } from "./utils"
|
||||||
|
|
||||||
|
const agent = (name = "build") =>
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
mode: "primary",
|
||||||
|
permission: {},
|
||||||
|
options: {},
|
||||||
|
}) as Agent
|
||||||
|
|
||||||
|
describe("normalizeAgentList", () => {
|
||||||
|
test("keeps array payloads", () => {
|
||||||
|
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("wraps a single agent payload", () => {
|
||||||
|
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("extracts agents from keyed objects", () => {
|
||||||
|
expect(
|
||||||
|
normalizeAgentList({
|
||||||
|
build: agent("build"),
|
||||||
|
docs: agent("docs"),
|
||||||
|
}),
|
||||||
|
).toEqual([agent("build"), agent("docs")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops invalid payloads", () => {
|
||||||
|
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||||
|
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
|
function isAgent(input: unknown): input is Agent {
|
||||||
|
if (!input || typeof input !== "object") return false
|
||||||
|
const item = input as { name?: unknown; mode?: unknown }
|
||||||
|
if (typeof item.name !== "string") return false
|
||||||
|
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAgentList(input: unknown): Agent[] {
|
||||||
|
if (Array.isArray(input)) return input.filter(isAgent)
|
||||||
|
if (isAgent(input)) return [input]
|
||||||
|
if (!input || typeof input !== "object") return []
|
||||||
|
return Object.values(input).filter(isAgent)
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,10 @@
|
||||||
import * as i18n from "@solid-primitives/i18n"
|
import * as i18n from "@solid-primitives/i18n"
|
||||||
import { createEffect, createMemo } from "solid-js"
|
import { createEffect, createMemo, createResource } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
import { dict as zh } from "@/i18n/zh"
|
|
||||||
import { dict as zht } from "@/i18n/zht"
|
|
||||||
import { dict as ko } from "@/i18n/ko"
|
|
||||||
import { dict as de } from "@/i18n/de"
|
|
||||||
import { dict as es } from "@/i18n/es"
|
|
||||||
import { dict as fr } from "@/i18n/fr"
|
|
||||||
import { dict as da } from "@/i18n/da"
|
|
||||||
import { dict as ja } from "@/i18n/ja"
|
|
||||||
import { dict as pl } from "@/i18n/pl"
|
|
||||||
import { dict as ru } from "@/i18n/ru"
|
|
||||||
import { dict as ar } from "@/i18n/ar"
|
|
||||||
import { dict as no } from "@/i18n/no"
|
|
||||||
import { dict as br } from "@/i18n/br"
|
|
||||||
import { dict as th } from "@/i18n/th"
|
|
||||||
import { dict as bs } from "@/i18n/bs"
|
|
||||||
import { dict as tr } from "@/i18n/tr"
|
|
||||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
|
||||||
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
|
||||||
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
|
|
||||||
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
|
|
||||||
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
|
|
||||||
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
|
|
||||||
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
|
|
||||||
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
|
|
||||||
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
|
|
||||||
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
|
|
||||||
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
|
|
||||||
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
|
||||||
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
|
||||||
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
|
|
||||||
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
|
|
||||||
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
|
|
||||||
|
|
||||||
export type Locale =
|
export type Locale =
|
||||||
| "en"
|
| "en"
|
||||||
|
|
@ -59,6 +27,7 @@ export type Locale =
|
||||||
|
|
||||||
type RawDictionary = typeof en & typeof uiEn
|
type RawDictionary = typeof en & typeof uiEn
|
||||||
type Dictionary = i18n.Flatten<RawDictionary>
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
|
type Source = { dict: Record<string, string> }
|
||||||
|
|
||||||
function cookie(locale: Locale) {
|
function cookie(locale: Locale) {
|
||||||
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||||
|
|
@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = i18n.flatten({ ...en, ...uiEn })
|
const base = i18n.flatten({ ...en, ...uiEn })
|
||||||
const DICT: Record<Locale, Dictionary> = {
|
const dicts = new Map<Locale, Dictionary>([["en", base]])
|
||||||
en: base,
|
|
||||||
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
|
const merge = (app: Promise<Source>, ui: Promise<Source>) =>
|
||||||
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
|
Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
|
||||||
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
|
|
||||||
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
|
const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
||||||
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
|
zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
|
||||||
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
|
zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
|
||||||
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
|
ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
|
||||||
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
|
de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
|
||||||
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
|
es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
|
||||||
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
|
fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
|
||||||
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
|
da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
|
||||||
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
|
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
|
||||||
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
|
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
|
||||||
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
|
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
||||||
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
|
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
||||||
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
|
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
||||||
|
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
||||||
|
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||||
|
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
|
||||||
|
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDict(locale: Locale) {
|
||||||
|
const hit = dicts.get(locale)
|
||||||
|
if (hit) return Promise.resolve(hit)
|
||||||
|
if (locale === "en") return Promise.resolve(base)
|
||||||
|
const load = loaders[locale]
|
||||||
|
return load().then((next: Dictionary) => {
|
||||||
|
dicts.set(locale, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLocaleDict(locale: Locale) {
|
||||||
|
return loadDict(locale).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||||
|
|
@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
||||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||||
]
|
]
|
||||||
|
|
||||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
|
||||||
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
|
||||||
zh,
|
|
||||||
zht,
|
|
||||||
ko,
|
|
||||||
de,
|
|
||||||
es,
|
|
||||||
fr,
|
|
||||||
da,
|
|
||||||
ja,
|
|
||||||
pl,
|
|
||||||
ru,
|
|
||||||
ar,
|
|
||||||
no,
|
|
||||||
br,
|
|
||||||
th,
|
|
||||||
bs,
|
|
||||||
tr,
|
|
||||||
}
|
|
||||||
void PARITY_CHECK
|
|
||||||
|
|
||||||
function detectLocale(): Locale {
|
function detectLocale(): Locale {
|
||||||
if (typeof navigator !== "object") return "en"
|
if (typeof navigator !== "object") return "en"
|
||||||
|
|
||||||
|
|
@ -203,27 +170,48 @@ function detectLocale(): Locale {
|
||||||
return "en"
|
return "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLocale(value: string): Locale {
|
export function normalizeLocale(value: string): Locale {
|
||||||
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
|
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStoredLocale() {
|
||||||
|
if (typeof localStorage !== "object") return
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("opencode.global.dat:language")
|
||||||
|
if (!raw) return
|
||||||
|
const next = JSON.parse(raw) as { locale?: string }
|
||||||
|
if (typeof next?.locale !== "string") return
|
||||||
|
return normalizeLocale(next.locale)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const warm = readStoredLocale() ?? detectLocale()
|
||||||
|
if (warm !== "en") void loadDict(warm)
|
||||||
|
|
||||||
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
|
||||||
name: "Language",
|
name: "Language",
|
||||||
init: () => {
|
init: (props: { locale?: Locale }) => {
|
||||||
|
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("language", ["language.v1"]),
|
Persist.global("language", ["language.v1"]),
|
||||||
createStore({
|
createStore({
|
||||||
locale: detectLocale() as Locale,
|
locale: initial,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||||
console.log("locale", locale())
|
|
||||||
const intl = createMemo(() => INTL[locale()])
|
const intl = createMemo(() => INTL[locale()])
|
||||||
|
|
||||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
const [dict] = createResource(locale, loadDict, {
|
||||||
|
initialValue: dicts.get(initial) ?? base,
|
||||||
|
})
|
||||||
|
|
||||||
const t = i18n.translator(dict, i18n.resolveTemplate)
|
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
||||||
|
key: keyof Dictionary,
|
||||||
|
params?: Record<string, string | number | boolean>,
|
||||||
|
) => string
|
||||||
|
|
||||||
const label = (value: Locale) => t(LABEL_KEY[value])
|
const label = (value: Locale) => t(LABEL_KEY[value])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSound, soundSrc } from "@/utils/sound"
|
import { playSoundById } from "@/utils/sound"
|
||||||
|
|
||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
|
|
@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||||
if (session.parentID) return
|
if (session.parentID) return
|
||||||
|
|
||||||
if (settings.sounds.agentEnabled()) {
|
if (settings.sounds.agentEnabled()) {
|
||||||
playSound(soundSrc(settings.sounds.agent()))
|
void playSoundById(settings.sounds.agent())
|
||||||
}
|
}
|
||||||
|
|
||||||
append({
|
append({
|
||||||
|
|
@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||||
if (session?.parentID) return
|
if (session?.parentID) return
|
||||||
|
|
||||||
if (settings.sounds.errorsEnabled()) {
|
if (settings.sounds.errorsEnabled()) {
|
||||||
playSound(soundSrc(settings.sounds.errors()))
|
void playSoundById(settings.sounds.errors())
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = "error" in event.properties ? event.properties.error : undefined
|
const error = "error" in event.properties ? event.properties.error : undefined
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||||
return createMemo(() => read() ?? fallback)
|
return createMemo(() => read() ?? fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||||
|
|
||||||
|
function loadFont() {
|
||||||
|
font ??= import("@opencode-ai/ui/font-loader")
|
||||||
|
return font
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
init: () => {
|
init: () => {
|
||||||
|
|
@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
const id = store.appearance?.font ?? defaultSettings.appearance.font
|
||||||
|
if (id !== defaultSettings.appearance.font) {
|
||||||
|
void loadFont().then((x) => x.ensureMonoFont(id))
|
||||||
|
}
|
||||||
|
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
return globalSync.child(directory)
|
return globalSync.child(directory)
|
||||||
}
|
}
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const messagePageSize = 200
|
const initialMessagePageSize = 80
|
||||||
|
const historyMessagePageSize = 200
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
|
|
@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||||
if (cached && hasSession && !opts?.force) return
|
if (cached && hasSession && !opts?.force) return
|
||||||
|
|
||||||
const limit = meta.limit[key] ?? messagePageSize
|
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||||
const sessionReq =
|
const sessionReq =
|
||||||
hasSession && !opts?.force
|
hasSession && !opts?.force
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
|
|
@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
touch(directory, setStore, sessionID)
|
touch(directory, setStore, sessionID)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const step = count ?? messagePageSize
|
const step = count ?? historyMessagePageSize
|
||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
if (meta.complete[key]) return
|
if (meta.complete[key]) return
|
||||||
const before = meta.cursor[key]
|
const before = meta.cursor[key]
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,18 @@
|
||||||
import { dict as ar } from "@/i18n/ar"
|
const template = "Terminal {{number}}"
|
||||||
import { dict as br } from "@/i18n/br"
|
|
||||||
import { dict as bs } from "@/i18n/bs"
|
|
||||||
import { dict as da } from "@/i18n/da"
|
|
||||||
import { dict as de } from "@/i18n/de"
|
|
||||||
import { dict as en } from "@/i18n/en"
|
|
||||||
import { dict as es } from "@/i18n/es"
|
|
||||||
import { dict as fr } from "@/i18n/fr"
|
|
||||||
import { dict as ja } from "@/i18n/ja"
|
|
||||||
import { dict as ko } from "@/i18n/ko"
|
|
||||||
import { dict as no } from "@/i18n/no"
|
|
||||||
import { dict as pl } from "@/i18n/pl"
|
|
||||||
import { dict as ru } from "@/i18n/ru"
|
|
||||||
import { dict as th } from "@/i18n/th"
|
|
||||||
import { dict as tr } from "@/i18n/tr"
|
|
||||||
import { dict as zh } from "@/i18n/zh"
|
|
||||||
import { dict as zht } from "@/i18n/zht"
|
|
||||||
|
|
||||||
const numbered = Array.from(
|
const numbered = [
|
||||||
new Set([
|
template,
|
||||||
en["terminal.title.numbered"],
|
"محطة طرفية {{number}}",
|
||||||
ar["terminal.title.numbered"],
|
"Терминал {{number}}",
|
||||||
br["terminal.title.numbered"],
|
"ターミナル {{number}}",
|
||||||
bs["terminal.title.numbered"],
|
"터미널 {{number}}",
|
||||||
da["terminal.title.numbered"],
|
"เทอร์มินัล {{number}}",
|
||||||
de["terminal.title.numbered"],
|
"终端 {{number}}",
|
||||||
es["terminal.title.numbered"],
|
"終端機 {{number}}",
|
||||||
fr["terminal.title.numbered"],
|
]
|
||||||
ja["terminal.title.numbered"],
|
|
||||||
ko["terminal.title.numbered"],
|
|
||||||
no["terminal.title.numbered"],
|
|
||||||
pl["terminal.title.numbered"],
|
|
||||||
ru["terminal.title.numbered"],
|
|
||||||
th["terminal.title.numbered"],
|
|
||||||
tr["terminal.title.numbered"],
|
|
||||||
zh["terminal.title.numbered"],
|
|
||||||
zht["terminal.title.numbered"],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function defaultTitle(number: number) {
|
export function defaultTitle(number: number) {
|
||||||
return en["terminal.title.numbered"].replace("{{number}}", String(number))
|
return template.replace("{{number}}", String(number))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDefaultTitle(title: string, number: number) {
|
export function isDefaultTitle(title: string, number: number) {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function useProviders() {
|
||||||
const providers = () => {
|
const providers = () => {
|
||||||
if (dir()) {
|
if (dir()) {
|
||||||
const [projectStore] = globalSync.child(dir())
|
const [projectStore] = globalSync.child(dir())
|
||||||
return projectStore.provider
|
if (projectStore.provider.all.length > 0) return projectStore.provider
|
||||||
}
|
}
|
||||||
return globalSync.data.provider
|
return globalSync.data.provider
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -722,8 +722,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
|
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
|
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
|
||||||
"settings.permissions.tool.todoread.title": "قراءة المهام",
|
|
||||||
"settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
|
|
||||||
"settings.permissions.tool.todowrite.title": "كتابة المهام",
|
"settings.permissions.tool.todowrite.title": "كتابة المهام",
|
||||||
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
|
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
|
||||||
"settings.permissions.tool.webfetch.title": "جلب الويب",
|
"settings.permissions.tool.webfetch.title": "جلب الويب",
|
||||||
|
|
|
||||||
|
|
@ -732,8 +732,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
|
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
|
||||||
"settings.permissions.tool.todoread.title": "Ler Tarefas",
|
|
||||||
"settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
|
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
|
||||||
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
|
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
|
||||||
"settings.permissions.tool.webfetch.title": "Buscar Web",
|
"settings.permissions.tool.webfetch.title": "Buscar Web",
|
||||||
|
|
|
||||||
|
|
@ -806,8 +806,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
|
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
|
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
|
||||||
"settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
|
|
||||||
"settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
|
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
|
||||||
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
|
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
|
||||||
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
|
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
|
||||||
|
|
|
||||||
|
|
@ -800,8 +800,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
||||||
"settings.permissions.tool.todoread.title": "Læs To-do",
|
|
||||||
"settings.permissions.tool.todoread.description": "Læs to-do listen",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Skriv To-do",
|
"settings.permissions.tool.todowrite.title": "Skriv To-do",
|
||||||
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
|
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
|
||||||
"settings.permissions.tool.webfetch.title": "Webhentning",
|
"settings.permissions.tool.webfetch.title": "Webhentning",
|
||||||
|
|
|
||||||
|
|
@ -743,8 +743,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
|
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
|
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
|
||||||
"settings.permissions.tool.todoread.title": "Todo lesen",
|
|
||||||
"settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Todo schreiben",
|
"settings.permissions.tool.todowrite.title": "Todo schreiben",
|
||||||
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
|
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
|
||||||
"settings.permissions.tool.webfetch.title": "Web-Abruf",
|
"settings.permissions.tool.webfetch.title": "Web-Abruf",
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,8 @@ export const dict = {
|
||||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||||
"session.review.noChanges": "No changes",
|
"session.review.noChanges": "No changes",
|
||||||
|
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
||||||
|
"session.review.noBranchChanges": "No branch changes yet",
|
||||||
|
|
||||||
"session.files.selectToOpen": "Select a file to open",
|
"session.files.selectToOpen": "Select a file to open",
|
||||||
"session.files.all": "All files",
|
"session.files.all": "All files",
|
||||||
|
|
@ -900,8 +902,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Load a skill by name",
|
"settings.permissions.tool.skill.description": "Load a skill by name",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Run language server queries",
|
"settings.permissions.tool.lsp.description": "Run language server queries",
|
||||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
|
||||||
"settings.permissions.tool.todoread.description": "Read the todo list",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||||
"settings.permissions.tool.todowrite.description": "Update the todo list",
|
"settings.permissions.tool.todowrite.description": "Update the todo list",
|
||||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||||
|
|
|
||||||
|
|
@ -813,8 +813,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
|
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
|
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
|
||||||
"settings.permissions.tool.todoread.title": "Leer Todo",
|
|
||||||
"settings.permissions.tool.todoread.description": "Leer la lista de tareas",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Escribir Todo",
|
"settings.permissions.tool.todowrite.title": "Escribir Todo",
|
||||||
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
|
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
|
||||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||||
|
|
|
||||||
|
|
@ -741,8 +741,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
|
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
|
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
|
||||||
"settings.permissions.tool.todoread.title": "Lire Todo",
|
|
||||||
"settings.permissions.tool.todoread.description": "Lire la liste de tâches",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Écrire Todo",
|
"settings.permissions.tool.todowrite.title": "Écrire Todo",
|
||||||
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
|
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
|
||||||
"settings.permissions.tool.webfetch.title": "Récupération Web",
|
"settings.permissions.tool.webfetch.title": "Récupération Web",
|
||||||
|
|
|
||||||
|
|
@ -727,8 +727,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
|
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
|
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
|
||||||
"settings.permissions.tool.todoread.title": "Todo読み込み",
|
|
||||||
"settings.permissions.tool.todoread.description": "Todoリストの読み込み",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Todo書き込み",
|
"settings.permissions.tool.todowrite.title": "Todo書き込み",
|
||||||
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
|
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
|
||||||
"settings.permissions.tool.webfetch.title": "Web取得",
|
"settings.permissions.tool.webfetch.title": "Web取得",
|
||||||
|
|
|
||||||
|
|
@ -726,8 +726,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
|
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
|
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
|
||||||
"settings.permissions.tool.todoread.title": "할 일 읽기",
|
|
||||||
"settings.permissions.tool.todoread.description": "할 일 목록 읽기",
|
|
||||||
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
|
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
|
||||||
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
|
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
|
||||||
"settings.permissions.tool.webfetch.title": "웹 가져오기",
|
"settings.permissions.tool.webfetch.title": "웹 가져오기",
|
||||||
|
|
|
||||||
|
|
@ -807,8 +807,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
|
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
|
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
|
||||||
"settings.permissions.tool.todoread.title": "Les gjøremål",
|
|
||||||
"settings.permissions.tool.todoread.description": "Les gjøremålslisten",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
|
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
|
||||||
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
|
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
|
||||||
"settings.permissions.tool.webfetch.title": "Webhenting",
|
"settings.permissions.tool.webfetch.title": "Webhenting",
|
||||||
|
|
|
||||||
|
|
@ -729,8 +729,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
|
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
|
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
|
||||||
"settings.permissions.tool.todoread.title": "Odczyt Todo",
|
|
||||||
"settings.permissions.tool.todoread.description": "Odczyt listy zadań",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Zapis Todo",
|
"settings.permissions.tool.todowrite.title": "Zapis Todo",
|
||||||
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
|
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
|
||||||
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
|
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
|
||||||
|
|
|
||||||
|
|
@ -808,8 +808,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
|
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
|
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
|
||||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
|
||||||
"settings.permissions.tool.todoread.description": "Чтение списка задач",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||||
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
|
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
|
||||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||||
|
|
|
||||||
|
|
@ -796,8 +796,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
|
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
|
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
|
||||||
"settings.permissions.tool.todoread.title": "อ่านรายการงาน",
|
|
||||||
"settings.permissions.tool.todoread.description": "อ่านรายการงาน",
|
|
||||||
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
|
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
|
||||||
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
|
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
|
||||||
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
|
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
|
||||||
|
|
|
||||||
|
|
@ -816,8 +816,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
|
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
|
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
|
||||||
"settings.permissions.tool.todoread.title": "Görev Oku",
|
|
||||||
"settings.permissions.tool.todoread.description": "Görev listesini oku",
|
|
||||||
"settings.permissions.tool.todowrite.title": "Görev Yaz",
|
"settings.permissions.tool.todowrite.title": "Görev Yaz",
|
||||||
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
|
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
|
||||||
"settings.permissions.tool.webfetch.title": "Web Getir",
|
"settings.permissions.tool.webfetch.title": "Web Getir",
|
||||||
|
|
|
||||||
|
|
@ -795,8 +795,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "按名称加载技能",
|
"settings.permissions.tool.skill.description": "按名称加载技能",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
|
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
|
||||||
"settings.permissions.tool.todoread.title": "读取待办",
|
|
||||||
"settings.permissions.tool.todoread.description": "读取待办列表",
|
|
||||||
"settings.permissions.tool.todowrite.title": "更新待办",
|
"settings.permissions.tool.todowrite.title": "更新待办",
|
||||||
"settings.permissions.tool.todowrite.description": "更新待办列表",
|
"settings.permissions.tool.todowrite.description": "更新待办列表",
|
||||||
"settings.permissions.tool.webfetch.title": "网页获取",
|
"settings.permissions.tool.webfetch.title": "网页获取",
|
||||||
|
|
|
||||||
|
|
@ -790,8 +790,6 @@ export const dict = {
|
||||||
"settings.permissions.tool.skill.description": "按名稱載入技能",
|
"settings.permissions.tool.skill.description": "按名稱載入技能",
|
||||||
"settings.permissions.tool.lsp.title": "LSP",
|
"settings.permissions.tool.lsp.title": "LSP",
|
||||||
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
|
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
|
||||||
"settings.permissions.tool.todoread.title": "讀取待辦",
|
|
||||||
"settings.permissions.tool.todoread.description": "讀取待辦清單",
|
|
||||||
"settings.permissions.tool.todowrite.title": "更新待辦",
|
"settings.permissions.tool.todowrite.title": "更新待辦",
|
||||||
"settings.permissions.tool.todowrite.description": "更新待辦清單",
|
"settings.permissions.tool.todowrite.description": "更新待辦清單",
|
||||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export { AppBaseProviders, AppInterface } from "./app"
|
export { AppBaseProviders, AppInterface } from "./app"
|
||||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||||
export { useCommand } from "./context/command"
|
export { useCommand } from "./context/command"
|
||||||
|
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||||
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
|
||||||
export { ServerConnection } from "./context/server"
|
export { ServerConnection } from "./context/server"
|
||||||
export { handleNotificationClick } from "./utils/notification-click"
|
export { handleNotificationClick } from "./utils/notification-click"
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
|
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
|
|
@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||||
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const slug = createMemo(() => base64Encode(props.directory))
|
const slug = createMemo(() => base64Encode(props.directory))
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const next = sync.data.path.directory
|
||||||
|
if (!next || next === props.directory) return
|
||||||
|
const path = location.pathname.slice(slug().length + 1)
|
||||||
|
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataProvider
|
<DataProvider
|
||||||
data={sync.data}
|
data={sync.data}
|
||||||
|
|
@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const location = useLocation()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const globalSDK = useGlobalSDK()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
let invalid = ""
|
let invalid = ""
|
||||||
|
|
||||||
const [resolved] = createResource(
|
const resolved = createMemo(() => {
|
||||||
() => {
|
if (!params.dir) return ""
|
||||||
if (params.dir) return [location.pathname, params.dir] as const
|
return decode64(params.dir) ?? ""
|
||||||
},
|
})
|
||||||
async ([pathname, b64Dir]) => {
|
|
||||||
const directory = decode64(b64Dir)
|
|
||||||
|
|
||||||
if (!directory) {
|
createEffect(() => {
|
||||||
if (invalid === params.dir) return
|
const dir = params.dir
|
||||||
invalid = b64Dir
|
if (!dir) return
|
||||||
showToast({
|
if (resolved()) {
|
||||||
variant: "error",
|
invalid = ""
|
||||||
title: language.t("common.requestFailed"),
|
return
|
||||||
description: language.t("directory.error.invalidUrl"),
|
}
|
||||||
})
|
if (invalid === dir) return
|
||||||
navigate("/", { replace: true })
|
invalid = dir
|
||||||
return
|
showToast({
|
||||||
}
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
return await globalSDK
|
description: language.t("directory.error.invalidUrl"),
|
||||||
.createClient({
|
})
|
||||||
directory,
|
navigate("/", { replace: true })
|
||||||
throwOnError: true,
|
})
|
||||||
})
|
|
||||||
.path.get()
|
|
||||||
.then((x) => {
|
|
||||||
const next = x.data?.directory ?? directory
|
|
||||||
invalid = ""
|
|
||||||
if (next === directory) return next
|
|
||||||
const path = pathname.slice(b64Dir.length + 1)
|
|
||||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
invalid = ""
|
|
||||||
return directory
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={resolved()} keyed>
|
<Show when={resolved()} keyed>
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,14 @@ export default function Home() {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={!sync.ready}>
|
||||||
|
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||||
|
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||||
|
<Button class="px-3" onClick={chooseProject}>
|
||||||
|
{language.t("command.project.open")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||||
<Icon name="folder-add-left" size="large" />
|
<Icon name="folder-add-left" size="large" />
|
||||||
|
|
|
||||||
|
|
@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { playSound, soundSrc } from "@/utils/sound"
|
import { playSoundById } from "@/utils/sound"
|
||||||
import { createAim } from "@/utils/aim"
|
import { createAim } from "@/utils/aim"
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
|
||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
|
||||||
import { DialogSettings } from "@/components/dialog-settings"
|
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
|
||||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { Titlebar } from "@/components/titlebar"
|
import { Titlebar } from "@/components/titlebar"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
|
@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
|
||||||
const pageReady = createMemo(() => ready())
|
const pageReady = createMemo(() => ready())
|
||||||
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
let dialogRun = 0
|
||||||
|
let dialogDead = false
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
|
|
@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
|
||||||
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
|
||||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||||
system: "theme.scheme.system",
|
system: "theme.scheme.system",
|
||||||
|
|
@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
dialogDead = true
|
||||||
|
dialogRun += 1
|
||||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
clearTimeout(sortNowTimeout)
|
clearTimeout(sortNowTimeout)
|
||||||
if (sortNowInterval) clearInterval(sortNowInterval)
|
if (sortNowInterval) clearInterval(sortNowInterval)
|
||||||
|
|
@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) {
|
||||||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
|
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
|
||||||
const nextThemeId = ids[nextIndex]
|
const nextThemeId = ids[nextIndex]
|
||||||
theme.setTheme(nextThemeId)
|
theme.setTheme(nextThemeId)
|
||||||
const nextTheme = theme.themes()[nextThemeId]
|
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("toast.theme.title"),
|
title: language.t("toast.theme.title"),
|
||||||
description: nextTheme?.name ?? nextThemeId,
|
description: theme.name(nextThemeId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
if (e.details.type === "permission.asked") {
|
if (e.details.type === "permission.asked") {
|
||||||
if (settings.sounds.permissionsEnabled()) {
|
if (settings.sounds.permissionsEnabled()) {
|
||||||
playSound(soundSrc(settings.sounds.permissions()))
|
void playSoundById(settings.sounds.permissions())
|
||||||
}
|
}
|
||||||
if (settings.notifications.permissions()) {
|
if (settings.notifications.permissions()) {
|
||||||
void platform.notify(title, description, href)
|
void platform.notify(title, description, href)
|
||||||
|
|
@ -967,6 +965,8 @@ export default function Layout(props: ParentProps) {
|
||||||
: projects[(index + offset + projects.length) % projects.length]
|
: projects[(index + offset + projects.length) % projects.length]
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
|
// warm up child store to prevent flicker
|
||||||
|
globalSync.child(target.worktree)
|
||||||
openProject(target.worktree)
|
openProject(target.worktree)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1152,10 +1152,10 @@ export default function Layout(props: ParentProps) {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const [id, definition] of availableThemeEntries()) {
|
for (const [id] of availableThemeEntries()) {
|
||||||
commands.push({
|
commands.push({
|
||||||
id: `theme.set.${id}`,
|
id: `theme.set.${id}`,
|
||||||
title: language.t("command.theme.set", { theme: definition.name ?? id }),
|
title: language.t("command.theme.set", { theme: theme.name(id) }),
|
||||||
category: language.t("command.category.theme"),
|
category: language.t("command.category.theme"),
|
||||||
onSelect: () => theme.commitPreview(),
|
onSelect: () => theme.commitPreview(),
|
||||||
onHighlight: () => {
|
onHighlight: () => {
|
||||||
|
|
@ -1206,15 +1206,27 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
function connectProvider() {
|
function connectProvider() {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
const run = ++dialogRun
|
||||||
|
void import("@/components/dialog-select-provider").then((x) => {
|
||||||
|
if (dialogDead || dialogRun !== run) return
|
||||||
|
dialog.show(() => <x.DialogSelectProvider />)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function openServer() {
|
function openServer() {
|
||||||
dialog.show(() => <DialogSelectServer />)
|
const run = ++dialogRun
|
||||||
|
void import("@/components/dialog-select-server").then((x) => {
|
||||||
|
if (dialogDead || dialogRun !== run) return
|
||||||
|
dialog.show(() => <x.DialogSelectServer />)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
dialog.show(() => <DialogSettings />)
|
const run = ++dialogRun
|
||||||
|
void import("@/components/dialog-settings").then((x) => {
|
||||||
|
if (dialogDead || dialogRun !== run) return
|
||||||
|
dialog.show(() => <x.DialogSettings />)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectRoot(directory: string) {
|
function projectRoot(directory: string) {
|
||||||
|
|
@ -1441,7 +1453,13 @@ export default function Layout(props: ParentProps) {
|
||||||
layout.sidebar.toggleWorkspaces(project.worktree)
|
layout.sidebar.toggleWorkspaces(project.worktree)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
|
const showEditProjectDialog = (project: LocalProject) => {
|
||||||
|
const run = ++dialogRun
|
||||||
|
void import("@/components/dialog-edit-project").then((x) => {
|
||||||
|
if (dialogDead || dialogRun !== run) return
|
||||||
|
dialog.show(() => <x.DialogEditProject project={project} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function chooseProject() {
|
async function chooseProject() {
|
||||||
function resolve(result: string | string[] | null) {
|
function resolve(result: string | string[] | null) {
|
||||||
|
|
@ -1462,10 +1480,14 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
resolve(result)
|
resolve(result)
|
||||||
} else {
|
} else {
|
||||||
dialog.show(
|
const run = ++dialogRun
|
||||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
void import("@/components/dialog-select-directory").then((x) => {
|
||||||
() => resolve(null),
|
if (dialogDead || dialogRun !== run) return
|
||||||
)
|
dialog.show(
|
||||||
|
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
||||||
|
() => resolve(null),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
|
|
@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors"
|
||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
||||||
|
|
||||||
|
type ChangeMode = "git" | "branch" | "session" | "turn"
|
||||||
|
type VcsMode = "git" | "branch"
|
||||||
|
|
||||||
type SessionHistoryWindowInput = {
|
type SessionHistoryWindowInput = {
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
messagesReady: () => boolean
|
messagesReady: () => boolean
|
||||||
|
|
@ -424,15 +427,16 @@ export default function Page() {
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
const hasReview = createMemo(() => reviewCount() > 0)
|
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||||
|
const canReview = createMemo(() => !!params.id)
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
tabs,
|
tabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
hasReview,
|
hasReview: canReview,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
|
|
@ -455,6 +459,12 @@ export default function Page() {
|
||||||
if (!id) return false
|
if (!id) return false
|
||||||
return sync.session.history.loading(id)
|
return sync.session.history.loading(id)
|
||||||
})
|
})
|
||||||
|
const diffsReady = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return true
|
||||||
|
if (!hasSessionReview()) return true
|
||||||
|
return sync.data.session_diff[id] !== undefined
|
||||||
|
})
|
||||||
|
|
||||||
const userMessages = createMemo(
|
const userMessages = createMemo(
|
||||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
|
|
@ -508,11 +518,22 @@ export default function Page() {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
mobileTab: "session" as "session" | "changes",
|
mobileTab: "session" as "session" | "changes",
|
||||||
changes: "session" as "session" | "turn",
|
changes: "git" as ChangeMode,
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
deferRender: false,
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [vcs, setVcs] = createStore({
|
||||||
|
diff: {
|
||||||
|
git: [] as FileDiff[],
|
||||||
|
branch: [] as FileDiff[],
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
git: false,
|
||||||
|
branch: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const [followup, setFollowup] = createStore({
|
const [followup, setFollowup] = createStore({
|
||||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||||
failed: {} as Record<string, string | undefined>,
|
failed: {} as Record<string, string | undefined>,
|
||||||
|
|
@ -539,6 +560,68 @@ export default function Page() {
|
||||||
let refreshTimer: number | undefined
|
let refreshTimer: number | undefined
|
||||||
let diffFrame: number | undefined
|
let diffFrame: number | undefined
|
||||||
let diffTimer: number | undefined
|
let diffTimer: number | undefined
|
||||||
|
const vcsTask = new Map<VcsMode, Promise<void>>()
|
||||||
|
const vcsRun = new Map<VcsMode, number>()
|
||||||
|
|
||||||
|
const bumpVcs = (mode: VcsMode) => {
|
||||||
|
const next = (vcsRun.get(mode) ?? 0) + 1
|
||||||
|
vcsRun.set(mode, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetVcs = (mode?: VcsMode) => {
|
||||||
|
const list = mode ? [mode] : (["git", "branch"] as const)
|
||||||
|
list.forEach((item) => {
|
||||||
|
bumpVcs(item)
|
||||||
|
vcsTask.delete(item)
|
||||||
|
setVcs("diff", item, [])
|
||||||
|
setVcs("ready", item, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadVcs = (mode: VcsMode, force = false) => {
|
||||||
|
if (sync.project?.vcs !== "git") return Promise.resolve()
|
||||||
|
if (!force && vcs.ready[mode]) return Promise.resolve()
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
if (vcsTask.has(mode)) bumpVcs(mode)
|
||||||
|
vcsTask.delete(mode)
|
||||||
|
setVcs("ready", mode, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = vcsTask.get(mode)
|
||||||
|
if (current) return current
|
||||||
|
|
||||||
|
const run = bumpVcs(mode)
|
||||||
|
|
||||||
|
const task = sdk.client.vcs
|
||||||
|
.diff({ mode })
|
||||||
|
.then((result) => {
|
||||||
|
if (vcsRun.get(mode) !== run) return
|
||||||
|
setVcs("diff", mode, result.data ?? [])
|
||||||
|
setVcs("ready", mode, true)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (vcsRun.get(mode) !== run) return
|
||||||
|
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||||
|
setVcs("diff", mode, [])
|
||||||
|
setVcs("ready", mode, true)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
||||||
|
})
|
||||||
|
|
||||||
|
vcsTask.set(mode, task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshVcs = () => {
|
||||||
|
resetVcs()
|
||||||
|
const mode = untrack(vcsMode)
|
||||||
|
if (!mode) return
|
||||||
|
if (!untrack(wantsReview)) return
|
||||||
|
void loadVcs(mode, true)
|
||||||
|
}
|
||||||
|
|
||||||
createComputed((prev) => {
|
createComputed((prev) => {
|
||||||
const open = desktopReviewOpen()
|
const open = desktopReviewOpen()
|
||||||
|
|
@ -554,7 +637,42 @@ export default function Page() {
|
||||||
}, desktopReviewOpen())
|
}, desktopReviewOpen())
|
||||||
|
|
||||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||||
|
const list: ChangeMode[] = []
|
||||||
|
if (sync.project?.vcs === "git") list.push("git")
|
||||||
|
if (
|
||||||
|
sync.project?.vcs === "git" &&
|
||||||
|
sync.data.vcs?.branch &&
|
||||||
|
sync.data.vcs?.default_branch &&
|
||||||
|
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
||||||
|
) {
|
||||||
|
list.push("branch")
|
||||||
|
}
|
||||||
|
list.push("session", "turn")
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||||
|
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||||
|
})
|
||||||
|
const reviewDiffs = createMemo(() => {
|
||||||
|
if (store.changes === "git") return vcs.diff.git
|
||||||
|
if (store.changes === "branch") return vcs.diff.branch
|
||||||
|
if (store.changes === "session") return diffs()
|
||||||
|
return turnDiffs()
|
||||||
|
})
|
||||||
|
const reviewCount = createMemo(() => {
|
||||||
|
if (store.changes === "git") return vcs.diff.git.length
|
||||||
|
if (store.changes === "branch") return vcs.diff.branch.length
|
||||||
|
if (store.changes === "session") return sessionCount()
|
||||||
|
return turnDiffs().length
|
||||||
|
})
|
||||||
|
const hasReview = createMemo(() => reviewCount() > 0)
|
||||||
|
const reviewReady = createMemo(() => {
|
||||||
|
if (store.changes === "git") return vcs.ready.git
|
||||||
|
if (store.changes === "branch") return vcs.ready.branch
|
||||||
|
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
|
|
@ -620,13 +738,7 @@ export default function Page() {
|
||||||
scrollToMessage(msgs[targetIndex], "auto")
|
scrollToMessage(msgs[targetIndex], "auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffsReady = createMemo(() => {
|
const sessionEmptyKey = createMemo(() => {
|
||||||
const id = params.id
|
|
||||||
if (!id) return true
|
|
||||||
if (!hasReview()) return true
|
|
||||||
return sync.data.session_diff[id] !== undefined
|
|
||||||
})
|
|
||||||
const reviewEmptyKey = createMemo(() => {
|
|
||||||
const project = sync.project
|
const project = sync.project
|
||||||
if (project && !project.vcs) return "session.review.noVcs"
|
if (project && !project.vcs) return "session.review.noVcs"
|
||||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||||
|
|
@ -748,13 +860,46 @@ export default function Page() {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
setStore("changes", "session")
|
setStore("changes", "git")
|
||||||
setUi("pendingMessage", undefined)
|
setUi("pendingMessage", undefined)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => sdk.directory,
|
||||||
|
() => {
|
||||||
|
resetVcs()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
||||||
|
(next, prev) => {
|
||||||
|
if (prev === undefined || same(next, prev)) return
|
||||||
|
refreshVcs()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const stopVcs = sdk.event.listen((evt) => {
|
||||||
|
if (evt.details.type !== "file.watcher.updated") return
|
||||||
|
const props =
|
||||||
|
typeof evt.details.properties === "object" && evt.details.properties
|
||||||
|
? (evt.details.properties as Record<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
const file = typeof props?.file === "string" ? props.file : undefined
|
||||||
|
if (!file || file.startsWith(".git/")) return
|
||||||
|
refreshVcs()
|
||||||
|
})
|
||||||
|
onCleanup(stopVcs)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => params.dir,
|
() => params.dir,
|
||||||
|
|
@ -877,6 +1022,40 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||||
|
const wantsReview = createMemo(() =>
|
||||||
|
isDesktop()
|
||||||
|
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||||
|
: store.mobileTab === "changes",
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const list = changesOptions()
|
||||||
|
if (list.includes(store.changes)) return
|
||||||
|
const next = list[0]
|
||||||
|
if (!next) return
|
||||||
|
setStore("changes", next)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const mode = vcsMode()
|
||||||
|
if (!mode) return
|
||||||
|
if (!wantsReview()) return
|
||||||
|
void loadVcs(mode)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||||
|
(next, prev) => {
|
||||||
|
const mode = vcsMode()
|
||||||
|
if (!mode) return
|
||||||
|
if (!wantsReview()) return
|
||||||
|
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||||
|
void loadVcs(mode, true)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const fileTreeTab = () => layout.fileTree.tab()
|
const fileTreeTab = () => layout.fileTree.tab()
|
||||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||||
|
|
@ -923,21 +1102,23 @@ export default function Page() {
|
||||||
loadFile: file.load,
|
loadFile: file.load,
|
||||||
})
|
})
|
||||||
|
|
||||||
const changesOptions = ["session", "turn"] as const
|
|
||||||
const changesOptionsList = [...changesOptions]
|
|
||||||
|
|
||||||
const changesTitle = () => {
|
const changesTitle = () => {
|
||||||
if (!hasReview()) {
|
if (!canReview()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label = (option: ChangeMode) => {
|
||||||
|
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||||
|
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||||
|
if (option === "session") return language.t("ui.sessionReview.title")
|
||||||
|
return language.t("ui.sessionReview.title.lastTurn")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
options={changesOptionsList}
|
options={changesOptions()}
|
||||||
current={store.changes}
|
current={store.changes}
|
||||||
label={(option) =>
|
label={label}
|
||||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
|
||||||
}
|
|
||||||
onSelect={(option) => option && setStore("changes", option)}
|
onSelect={(option) => option && setStore("changes", option)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -946,20 +1127,34 @@ export default function Page() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyTurn = () => (
|
const empty = (text: string) => (
|
||||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
const reviewEmptyText = createMemo(() => {
|
||||||
if (store.changes === "turn") return emptyTurn()
|
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||||
|
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||||
|
if (store.changes === "turn") return language.t("session.review.noChanges")
|
||||||
|
return language.t(sessionEmptyKey())
|
||||||
|
})
|
||||||
|
|
||||||
if (hasReview() && !diffsReady()) {
|
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||||
|
if (store.changes === "git" || store.changes === "branch") {
|
||||||
|
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||||
|
return empty(reviewEmptyText())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.changes === "turn") {
|
||||||
|
return empty(reviewEmptyText())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSessionReview() && !diffsReady()) {
|
||||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
if (sessionEmptyKey() === "session.review.noVcs") {
|
||||||
return (
|
return (
|
||||||
<div class={input.emptyClass}>
|
<div class={input.emptyClass}>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
|
|
@ -979,7 +1174,7 @@ export default function Page() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={input.emptyClass}>
|
<div class={input.emptyClass}>
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1083,7 +1278,7 @@ export default function Page() {
|
||||||
const pending = tree.pendingDiff
|
const pending = tree.pendingDiff
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
if (!tree.reviewScroll) return
|
if (!tree.reviewScroll) return
|
||||||
if (!diffsReady()) return
|
if (!reviewReady()) return
|
||||||
|
|
||||||
const attempt = (count: number) => {
|
const attempt = (count: number) => {
|
||||||
if (tree.pendingDiff !== pending) return
|
if (tree.pendingDiff !== pending) return
|
||||||
|
|
@ -1124,10 +1319,7 @@ export default function Page() {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
const wants = isDesktop()
|
if (!wantsReview()) return
|
||||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
|
||||||
: store.mobileTab === "changes"
|
|
||||||
if (!wants) return
|
|
||||||
if (sync.data.session_diff[id] !== undefined) return
|
if (sync.data.session_diff[id] !== undefined) return
|
||||||
if (sync.status === "loading") return
|
if (sync.status === "loading") return
|
||||||
|
|
||||||
|
|
@ -1136,13 +1328,7 @@ export default function Page() {
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() =>
|
() => [sessionKey(), wantsReview()] as const,
|
||||||
[
|
|
||||||
sessionKey(),
|
|
||||||
isDesktop()
|
|
||||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
|
||||||
: store.mobileTab === "changes",
|
|
||||||
] as const,
|
|
||||||
([key, wants]) => {
|
([key, wants]) => {
|
||||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||||
|
|
@ -1184,8 +1370,6 @@ export default function Page() {
|
||||||
on(
|
on(
|
||||||
() => sdk.directory,
|
() => sdk.directory,
|
||||||
() => {
|
() => {
|
||||||
void file.tree.list("")
|
|
||||||
|
|
||||||
const tab = activeFileTab()
|
const tab = activeFileTab()
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
const path = file.pathFromTab(tab)
|
const path = file.pathFromTab(tab)
|
||||||
|
|
@ -1640,6 +1824,9 @@ export default function Page() {
|
||||||
sessionID: () => params.id,
|
sessionID: () => params.id,
|
||||||
messagesReady,
|
messagesReady,
|
||||||
visibleUserMessages,
|
visibleUserMessages,
|
||||||
|
historyMore,
|
||||||
|
historyLoading,
|
||||||
|
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||||
turnStart: historyWindow.turnStart,
|
turnStart: historyWindow.turnStart,
|
||||||
currentMessageId: () => store.messageId,
|
currentMessageId: () => store.messageId,
|
||||||
pendingMessage: () => ui.pendingMessage,
|
pendingMessage: () => ui.pendingMessage,
|
||||||
|
|
@ -1711,7 +1898,7 @@ export default function Page() {
|
||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={params.id}>
|
<Match when={params.id}>
|
||||||
<Show when={lastUserMessage()}>
|
<Show when={messagesReady()}>
|
||||||
<MessageTimeline
|
<MessageTimeline
|
||||||
mobileChanges={mobileChanges()}
|
mobileChanges={mobileChanges()}
|
||||||
mobileFallback={reviewContent({
|
mobileFallback={reviewContent({
|
||||||
|
|
@ -1827,6 +2014,12 @@ export default function Page() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionSidePanel
|
<SessionSidePanel
|
||||||
|
canReview={canReview}
|
||||||
|
diffs={reviewDiffs}
|
||||||
|
diffsReady={reviewReady}
|
||||||
|
empty={reviewEmptyText}
|
||||||
|
hasReview={hasReview}
|
||||||
|
reviewCount={reviewCount}
|
||||||
reviewPanel={reviewPanel}
|
reviewPanel={reviewPanel}
|
||||||
activeDiff={tree.activeDiff}
|
activeDiff={tree.activeDiff}
|
||||||
focusReviewDiff={focusReviewDiff}
|
focusReviewDiff={focusReviewDiff}
|
||||||
|
|
|
||||||
|
|
@ -896,7 +896,8 @@ export function MessageTimeline(props: {
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
role="log"
|
role="log"
|
||||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
data-slot="session-turn-list"
|
||||||
|
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
|
||||||
classList={{
|
classList={{
|
||||||
"w-full": true,
|
"w-full": true,
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
|
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
||||||
|
|
@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
|
||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||||
|
|
@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
|
||||||
export function SessionSidePanel(props: {
|
export function SessionSidePanel(props: {
|
||||||
|
canReview: () => boolean
|
||||||
|
diffs: () => FileDiff[]
|
||||||
|
diffsReady: () => boolean
|
||||||
|
empty: () => string
|
||||||
|
hasReview: () => boolean
|
||||||
|
reviewCount: () => number
|
||||||
reviewPanel: () => JSX.Element
|
reviewPanel: () => JSX.Element
|
||||||
activeDiff?: string
|
activeDiff?: string
|
||||||
focusReviewDiff: (path: string) => void
|
focusReviewDiff: (path: string) => void
|
||||||
|
|
@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
|
||||||
size: Sizing
|
size: Sizing
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const sync = useSync()
|
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
const { sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
|
|
@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
|
||||||
})
|
})
|
||||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||||
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 reviewEmptyKey = createMemo(() => {
|
|
||||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
|
||||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
|
||||||
return "session.review.noChanges"
|
|
||||||
})
|
|
||||||
|
|
||||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
|
||||||
const kinds = createMemo(() => {
|
const kinds = createMemo(() => {
|
||||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||||
if (!a) return b
|
if (!a) return b
|
||||||
|
|
@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
|
||||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||||
|
|
||||||
const out = new Map<string, "add" | "del" | "mix">()
|
const out = new Map<string, "add" | "del" | "mix">()
|
||||||
for (const diff of diffs()) {
|
for (const diff of props.diffs()) {
|
||||||
const file = normalize(diff.file)
|
const file = normalize(diff.file)
|
||||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||||
|
|
||||||
|
|
@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
hasReview,
|
hasReview: props.canReview,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
|
|
@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
|
||||||
onCleanup(stop)
|
onCleanup(stop)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
<Tabs.Trigger value="review">
|
<Tabs.Trigger value="review">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<div>{language.t("session.tab.review")}</div>
|
<div>{language.t("session.tab.review")}</div>
|
||||||
<Show when={hasReview()}>
|
<Show when={props.hasReview()}>
|
||||||
<div>{reviewCount()}</div>
|
<div>{props.reviewCount()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
|
@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
@ -377,8 +365,10 @@ export function SessionSidePanel(props: {
|
||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{reviewCount()}{" "}
|
{props.reviewCount()}{" "}
|
||||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
{language.t(
|
||||||
|
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||||
|
)}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{language.t("session.files.all")}
|
{language.t("session.files.all")}
|
||||||
|
|
@ -386,9 +376,9 @@ export function SessionSidePanel(props: {
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={hasReview()}>
|
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||||
<Show
|
<Show
|
||||||
when={diffsReady()}
|
when={props.diffsReady()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
{language.t("common.loading")}
|
{language.t("common.loading")}
|
||||||
|
|
@ -407,11 +397,7 @@ export function SessionSidePanel(props: {
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>{empty(props.empty())}</Match>
|
||||||
{empty(
|
|
||||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
return sync.session.get(id)
|
return sync.session.get(id)
|
||||||
}
|
}
|
||||||
const hasReview = () => {
|
const hasReview = () => !!params.id
|
||||||
const id = params.id
|
|
||||||
if (!id) return false
|
|
||||||
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
|
|
||||||
}
|
|
||||||
const normalizeTab = (tab: string) => {
|
const normalizeTab = (tab: string) => {
|
||||||
if (!tab.startsWith("file://")) return tab
|
if (!tab.startsWith("file://")) return tab
|
||||||
return file.tab(tab)
|
return file.tab(tab)
|
||||||
|
|
@ -333,7 +329,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
id: "message.previous",
|
id: "message.previous",
|
||||||
title: language.t("command.message.previous"),
|
title: language.t("command.message.previous"),
|
||||||
description: language.t("command.message.previous.description"),
|
description: language.t("command.message.previous.description"),
|
||||||
keybind: "mod+arrowup",
|
keybind: "mod+alt+[",
|
||||||
disabled: !params.id,
|
disabled: !params.id,
|
||||||
onSelect: () => navigateMessageByOffset(-1),
|
onSelect: () => navigateMessageByOffset(-1),
|
||||||
}),
|
}),
|
||||||
|
|
@ -341,7 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
id: "message.next",
|
id: "message.next",
|
||||||
title: language.t("command.message.next"),
|
title: language.t("command.message.next"),
|
||||||
description: language.t("command.message.next.description"),
|
description: language.t("command.message.next.description"),
|
||||||
keybind: "mod+arrowdown",
|
keybind: "mod+alt+]",
|
||||||
disabled: !params.id,
|
disabled: !params.id,
|
||||||
onSelect: () => navigateMessageByOffset(1),
|
onSelect: () => navigateMessageByOffset(1),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
messagesReady: () => boolean
|
messagesReady: () => boolean
|
||||||
visibleUserMessages: () => UserMessage[]
|
visibleUserMessages: () => UserMessage[]
|
||||||
|
historyMore: () => boolean
|
||||||
|
historyLoading: () => boolean
|
||||||
|
loadMore: (sessionID: string) => Promise<void>
|
||||||
turnStart: () => number
|
turnStart: () => number
|
||||||
currentMessageId: () => string | undefined
|
currentMessageId: () => string | undefined
|
||||||
pendingMessage: () => string | undefined
|
pendingMessage: () => string | undefined
|
||||||
|
|
@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
|
||||||
queue(() => scrollToMessage(msg, "auto"))
|
queue(() => scrollToMessage(msg, "auto"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const sessionID = input.sessionID()
|
||||||
|
if (!sessionID || !input.messagesReady()) return
|
||||||
|
|
||||||
|
visibleUserMessages()
|
||||||
|
|
||||||
|
let targetId = input.pendingMessage()
|
||||||
|
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||||
|
if (!targetId) return
|
||||||
|
if (messageById().has(targetId)) return
|
||||||
|
if (!input.historyMore() || input.historyLoading()) return
|
||||||
|
|
||||||
|
void input.loadMore(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||||
window.history.scrollRestoration = "manual"
|
window.history.scrollRestoration = "manual"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
|
||||||
const defaultTimeoutMs = 3000
|
const defaultTimeoutMs = 3000
|
||||||
const defaultRetryCount = 2
|
const defaultRetryCount = 2
|
||||||
const defaultRetryDelayMs = 100
|
const defaultRetryDelayMs = 100
|
||||||
|
const cacheMs = 750
|
||||||
|
const healthCache = new Map<
|
||||||
|
string,
|
||||||
|
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
|
||||||
|
>()
|
||||||
|
|
||||||
|
function cacheKey(server: ServerConnection.HttpBase) {
|
||||||
|
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
|
||||||
|
}
|
||||||
|
|
||||||
function timeoutSignal(timeoutMs: number) {
|
function timeoutSignal(timeoutMs: number) {
|
||||||
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
|
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
|
||||||
|
|
@ -87,5 +96,18 @@ export function useCheckServerHealth() {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
|
||||||
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
|
return (http: ServerConnection.HttpBase) => {
|
||||||
|
const key = cacheKey(http)
|
||||||
|
const hit = healthCache.get(key)
|
||||||
|
const now = Date.now()
|
||||||
|
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
|
||||||
|
const promise = checkServerHealth(http, fetcher).finally(() => {
|
||||||
|
const next = healthCache.get(key)
|
||||||
|
if (!next || next.promise !== promise) return
|
||||||
|
next.done = true
|
||||||
|
next.at = Date.now()
|
||||||
|
})
|
||||||
|
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
|
||||||
|
return promise
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,89 @@
|
||||||
import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
|
let files: Record<string, () => Promise<string>> | undefined
|
||||||
import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
|
let loads: Record<SoundID, () => Promise<string>> | undefined
|
||||||
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
|
|
||||||
import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
|
function getFiles() {
|
||||||
import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
|
if (files) return files
|
||||||
import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
|
files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
|
||||||
import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
|
string,
|
||||||
import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
|
() => Promise<string>
|
||||||
import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
|
>
|
||||||
import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
|
return files
|
||||||
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
|
}
|
||||||
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
|
|
||||||
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
|
|
||||||
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
|
|
||||||
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
|
|
||||||
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
|
|
||||||
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
|
|
||||||
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
|
|
||||||
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
|
|
||||||
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
|
|
||||||
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
|
|
||||||
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
|
|
||||||
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
|
|
||||||
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
|
|
||||||
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
|
|
||||||
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
|
|
||||||
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
|
|
||||||
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
|
|
||||||
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
|
|
||||||
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
|
|
||||||
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
|
|
||||||
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
|
|
||||||
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
|
|
||||||
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
|
|
||||||
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
|
|
||||||
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
|
|
||||||
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
|
|
||||||
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
|
|
||||||
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
|
|
||||||
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
|
|
||||||
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
|
|
||||||
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
|
|
||||||
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
|
|
||||||
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
|
|
||||||
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
|
|
||||||
|
|
||||||
export const SOUND_OPTIONS = [
|
export const SOUND_OPTIONS = [
|
||||||
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
|
{ id: "alert-01", label: "sound.option.alert01" },
|
||||||
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
|
{ id: "alert-02", label: "sound.option.alert02" },
|
||||||
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
|
{ id: "alert-03", label: "sound.option.alert03" },
|
||||||
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
|
{ id: "alert-04", label: "sound.option.alert04" },
|
||||||
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
|
{ id: "alert-05", label: "sound.option.alert05" },
|
||||||
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
|
{ id: "alert-06", label: "sound.option.alert06" },
|
||||||
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
|
{ id: "alert-07", label: "sound.option.alert07" },
|
||||||
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
|
{ id: "alert-08", label: "sound.option.alert08" },
|
||||||
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
|
{ id: "alert-09", label: "sound.option.alert09" },
|
||||||
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
|
{ id: "alert-10", label: "sound.option.alert10" },
|
||||||
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
|
{ id: "bip-bop-01", label: "sound.option.bipbop01" },
|
||||||
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
|
{ id: "bip-bop-02", label: "sound.option.bipbop02" },
|
||||||
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
|
{ id: "bip-bop-03", label: "sound.option.bipbop03" },
|
||||||
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
|
{ id: "bip-bop-04", label: "sound.option.bipbop04" },
|
||||||
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
|
{ id: "bip-bop-05", label: "sound.option.bipbop05" },
|
||||||
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
|
{ id: "bip-bop-06", label: "sound.option.bipbop06" },
|
||||||
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
|
{ id: "bip-bop-07", label: "sound.option.bipbop07" },
|
||||||
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
|
{ id: "bip-bop-08", label: "sound.option.bipbop08" },
|
||||||
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
|
{ id: "bip-bop-09", label: "sound.option.bipbop09" },
|
||||||
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
|
{ id: "bip-bop-10", label: "sound.option.bipbop10" },
|
||||||
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
|
{ id: "staplebops-01", label: "sound.option.staplebops01" },
|
||||||
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
|
{ id: "staplebops-02", label: "sound.option.staplebops02" },
|
||||||
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
|
{ id: "staplebops-03", label: "sound.option.staplebops03" },
|
||||||
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
|
{ id: "staplebops-04", label: "sound.option.staplebops04" },
|
||||||
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
|
{ id: "staplebops-05", label: "sound.option.staplebops05" },
|
||||||
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
|
{ id: "staplebops-06", label: "sound.option.staplebops06" },
|
||||||
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
|
{ id: "staplebops-07", label: "sound.option.staplebops07" },
|
||||||
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
|
{ id: "nope-01", label: "sound.option.nope01" },
|
||||||
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
|
{ id: "nope-02", label: "sound.option.nope02" },
|
||||||
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
|
{ id: "nope-03", label: "sound.option.nope03" },
|
||||||
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
|
{ id: "nope-04", label: "sound.option.nope04" },
|
||||||
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
|
{ id: "nope-05", label: "sound.option.nope05" },
|
||||||
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
|
{ id: "nope-06", label: "sound.option.nope06" },
|
||||||
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
|
{ id: "nope-07", label: "sound.option.nope07" },
|
||||||
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
|
{ id: "nope-08", label: "sound.option.nope08" },
|
||||||
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
|
{ id: "nope-09", label: "sound.option.nope09" },
|
||||||
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
|
{ id: "nope-10", label: "sound.option.nope10" },
|
||||||
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
|
{ id: "nope-11", label: "sound.option.nope11" },
|
||||||
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
|
{ id: "nope-12", label: "sound.option.nope12" },
|
||||||
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
|
{ id: "yup-01", label: "sound.option.yup01" },
|
||||||
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
|
{ id: "yup-02", label: "sound.option.yup02" },
|
||||||
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
|
{ id: "yup-03", label: "sound.option.yup03" },
|
||||||
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
|
{ id: "yup-04", label: "sound.option.yup04" },
|
||||||
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
|
{ id: "yup-05", label: "sound.option.yup05" },
|
||||||
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
|
{ id: "yup-06", label: "sound.option.yup06" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type SoundOption = (typeof SOUND_OPTIONS)[number]
|
export type SoundOption = (typeof SOUND_OPTIONS)[number]
|
||||||
export type SoundID = SoundOption["id"]
|
export type SoundID = SoundOption["id"]
|
||||||
|
|
||||||
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
|
function getLoads() {
|
||||||
|
if (loads) return loads
|
||||||
|
loads = Object.fromEntries(
|
||||||
|
Object.entries(getFiles()).flatMap(([path, load]) => {
|
||||||
|
const file = path.split("/").at(-1)
|
||||||
|
if (!file) return []
|
||||||
|
return [[file.replace(/\.aac$/, ""), load] as const]
|
||||||
|
}),
|
||||||
|
) as Record<SoundID, () => Promise<string>>
|
||||||
|
return loads
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<SoundID, Promise<string | undefined>>()
|
||||||
|
|
||||||
export function soundSrc(id: string | undefined) {
|
export function soundSrc(id: string | undefined) {
|
||||||
if (!id) return
|
const loads = getLoads()
|
||||||
if (!(id in soundById)) return
|
if (!id || !(id in loads)) return Promise.resolve(undefined)
|
||||||
return soundById[id as SoundID]
|
const key = id as SoundID
|
||||||
|
const hit = cache.get(key)
|
||||||
|
if (hit) return hit
|
||||||
|
const next = loads[key]().catch(() => undefined)
|
||||||
|
cache.set(key, next)
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export function playSound(src: string | undefined) {
|
export function playSound(src: string | undefined) {
|
||||||
|
|
@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
|
||||||
if (!src) return
|
if (!src) return
|
||||||
const audio = new Audio(src)
|
const audio = new Audio(src)
|
||||||
audio.play().catch(() => undefined)
|
audio.play().catch(() => undefined)
|
||||||
|
|
||||||
// Return a cleanup function to pause the sound.
|
|
||||||
return () => {
|
return () => {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
audio.currentTime = 0
|
audio.currentTime = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function playSoundById(id: string | undefined) {
|
||||||
|
return soundSrc(id).then((src) => playSound(src))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
import solidPlugin from "vite-plugin-solid"
|
import solidPlugin from "vite-plugin-solid"
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import("vite").PluginOption}
|
* @type {import("vite").PluginOption}
|
||||||
*/
|
*/
|
||||||
|
|
@ -21,6 +24,15 @@ export default [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "opencode-desktop:theme-preload",
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
|
||||||
|
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
solidPlugin(),
|
solidPlugin(),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||||
"open-directory-picker",
|
"open-directory-picker",
|
||||||
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||||
title: opts?.title ?? "Choose a folder",
|
title: opts?.title ?? "Choose a folder",
|
||||||
defaultPath: opts?.defaultPath,
|
defaultPath: opts?.defaultPath,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
|
||||||
export function getStore(name = SETTINGS_STORE) {
|
export function getStore(name = SETTINGS_STORE) {
|
||||||
const cached = cache.get(name)
|
const cached = cache.get(name)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const next = new Store({ name })
|
const next = new Store({ name, fileExtension: "" })
|
||||||
cache.set(name, next)
|
cache.set(name, next)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import {
|
||||||
AppBaseProviders,
|
AppBaseProviders,
|
||||||
AppInterface,
|
AppInterface,
|
||||||
handleNotificationClick,
|
handleNotificationClick,
|
||||||
|
loadLocaleDict,
|
||||||
|
normalizeLocale,
|
||||||
|
type Locale,
|
||||||
type Platform,
|
type Platform,
|
||||||
PlatformProvider,
|
PlatformProvider,
|
||||||
ServerConnection,
|
ServerConnection,
|
||||||
|
|
@ -246,6 +249,17 @@ listenForDeepLinks()
|
||||||
|
|
||||||
render(() => {
|
render(() => {
|
||||||
const platform = createPlatform()
|
const platform = createPlatform()
|
||||||
|
const loadLocale = async () => {
|
||||||
|
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||||
|
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||||
|
const raw = current ?? legacy
|
||||||
|
if (!raw) return
|
||||||
|
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
|
||||||
|
if (!locale) return
|
||||||
|
const next = normalizeLocale(locale)
|
||||||
|
if (next !== "en") await loadLocaleDict(next)
|
||||||
|
return next satisfies Locale
|
||||||
|
}
|
||||||
|
|
||||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||||
|
|
||||||
|
|
@ -257,6 +271,7 @@ render(() => {
|
||||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const [locale] = createResource(loadLocale)
|
||||||
|
|
||||||
const servers = () => {
|
const servers = () => {
|
||||||
const data = sidecar()
|
const data = sidecar()
|
||||||
|
|
@ -309,15 +324,14 @@ render(() => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders locale={locale.latest}>
|
||||||
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
|
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
return (
|
return (
|
||||||
<AppInterface
|
<AppInterface
|
||||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||||
servers={servers()}
|
servers={servers()}
|
||||||
router={MemoryRouter}
|
router={MemoryRouter}
|
||||||
disableHealthCheck={(windowCount() ?? 0) > 1}
|
|
||||||
>
|
>
|
||||||
<Inner />
|
<Inner />
|
||||||
</AppInterface>
|
</AppInterface>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import {
|
||||||
AppBaseProviders,
|
AppBaseProviders,
|
||||||
AppInterface,
|
AppInterface,
|
||||||
handleNotificationClick,
|
handleNotificationClick,
|
||||||
|
loadLocaleDict,
|
||||||
|
normalizeLocale,
|
||||||
|
type Locale,
|
||||||
type Platform,
|
type Platform,
|
||||||
PlatformProvider,
|
PlatformProvider,
|
||||||
ServerConnection,
|
ServerConnection,
|
||||||
|
|
@ -414,6 +417,17 @@ void listenForDeepLinks()
|
||||||
|
|
||||||
render(() => {
|
render(() => {
|
||||||
const platform = createPlatform()
|
const platform = createPlatform()
|
||||||
|
const loadLocale = async () => {
|
||||||
|
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||||
|
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||||
|
const raw = current ?? legacy
|
||||||
|
if (!raw) return
|
||||||
|
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
|
||||||
|
if (!locale) return
|
||||||
|
const next = normalizeLocale(locale)
|
||||||
|
if (next !== "en") await loadLocaleDict(next)
|
||||||
|
return next satisfies Locale
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch sidecar credentials from Rust (available immediately, before health check)
|
// Fetch sidecar credentials from Rust (available immediately, before health check)
|
||||||
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
|
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
|
||||||
|
|
@ -423,6 +437,7 @@ render(() => {
|
||||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const [locale] = createResource(loadLocale)
|
||||||
|
|
||||||
// Build the sidecar server connection once credentials arrive
|
// Build the sidecar server connection once credentials arrive
|
||||||
const servers = () => {
|
const servers = () => {
|
||||||
|
|
@ -465,8 +480,8 @@ render(() => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders locale={locale.latest}>
|
||||||
<Show when={!defaultServer.loading && !sidecar.loading}>
|
<Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
return (
|
return (
|
||||||
<AppInterface
|
<AppInterface
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,14 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||||
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
|
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
|
||||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||||
|
|
||||||
## Runtime vs Instances
|
## Runtime vs InstanceState
|
||||||
|
|
||||||
- Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
|
||||||
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
|
- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
|
||||||
- If two open directories should not share one copy of the service, it belongs in `Instances`.
|
- If two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||||
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
|
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
|
||||||
|
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
|
||||||
|
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
|
||||||
|
|
||||||
## Preferred Effect services
|
## Preferred Effect services
|
||||||
|
|
||||||
|
|
@ -51,7 +53,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||||
|
|
||||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||||
|
|
||||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
|
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
|
||||||
|
|
||||||
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
CREATE TABLE `event_sequence` (
|
||||||
|
`aggregate_id` text PRIMARY KEY,
|
||||||
|
`seq` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `event` (
|
||||||
|
`id` text PRIMARY KEY,
|
||||||
|
`aggregate_id` text NOT NULL,
|
||||||
|
`seq` integer NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`data` text NOT NULL,
|
||||||
|
CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
1271
packages/opencode/migration/20260323234822_events/snapshot.json
Normal file
1271
packages/opencode/migration/20260323234822_events/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -92,7 +92,7 @@
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
"@modelcontextprotocol/sdk": "1.25.2",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "5.3.2",
|
"gitlab-ai-provider": "5.3.3",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
|
|
@ -133,9 +133,9 @@
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opencode-gitlab-auth": "2.0.0",
|
"opencode-gitlab-auth": "2.0.0",
|
||||||
|
"opencode-poe-auth": "0.0.1",
|
||||||
"opentui-spinner": "0.0.6",
|
"opentui-spinner": "0.0.6",
|
||||||
"partial-json": "0.1.7",
|
"partial-json": "0.1.7",
|
||||||
"opencode-poe-auth": "0.0.1",
|
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
||||||
|
|
||||||
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
||||||
|
|
||||||
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
|
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||||
|
|
||||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||||
|
|
@ -46,7 +46,7 @@ export namespace Foo {
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||||
|
|
||||||
// Per-service runtime (inside the namespace)
|
// Per-service runtime (inside the namespace)
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
// Async facade functions
|
// Async facade functions
|
||||||
export async function get(id: FooID) {
|
export async function get(id: FooID) {
|
||||||
|
|
@ -79,22 +79,24 @@ See `Auth.ZodInfo` for the canonical example.
|
||||||
|
|
||||||
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
|
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
|
||||||
|
|
||||||
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
|
- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
const bus = yield * Bus.Service
|
||||||
|
|
||||||
const cache =
|
const cache =
|
||||||
yield *
|
yield *
|
||||||
InstanceState.make<State>(
|
InstanceState.make<State>(
|
||||||
Effect.fn("Foo.state")(function* (ctx) {
|
Effect.fn("Foo.state")(function* (ctx) {
|
||||||
// ... load state ...
|
// ... load state ...
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
yield* bus.subscribeAll().pipe(
|
||||||
Effect.sync(() =>
|
Stream.runForEach((event) =>
|
||||||
Bus.subscribeAll((event) => {
|
Effect.sync(() => {
|
||||||
/* handle */
|
/* handle */
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
(unsub) => Effect.sync(unsub),
|
Effect.forkScoped,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -104,6 +106,16 @@ const cache =
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.sync(() => nativeAddon.watch(dir)),
|
||||||
|
(watcher) => Effect.sync(() => watcher.close()),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
|
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
|
||||||
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
|
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
|
||||||
|
|
||||||
|
|
@ -165,7 +177,7 @@ Still open and likely worth migrating:
|
||||||
- [x] `ToolRegistry`
|
- [x] `ToolRegistry`
|
||||||
- [ ] `Pty`
|
- [ ] `Pty`
|
||||||
- [x] `Worktree`
|
- [x] `Worktree`
|
||||||
- [ ] `Bus`
|
- [x] `Bus`
|
||||||
- [x] `Command`
|
- [x] `Command`
|
||||||
- [ ] `Config`
|
- [ ] `Config`
|
||||||
- [ ] `Session`
|
- [ ] `Session`
|
||||||
|
|
@ -175,4 +187,4 @@ Still open and likely worth migrating:
|
||||||
- [ ] `Provider`
|
- [ ] `Provider`
|
||||||
- [x] `Project`
|
- [x] `Project`
|
||||||
- [ ] `LSP`
|
- [ ] `LSP`
|
||||||
- [ ] `MCP`
|
- [x] `MCP`
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
import { AccountRepo, type AccountRow } from "./repo"
|
||||||
import {
|
import {
|
||||||
|
|
@ -379,7 +379,7 @@ export namespace Account {
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||||
|
|
||||||
export const runPromise = makeRunPromise(Service, defaultLayer)
|
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function active(): Promise<Info | undefined> {
|
export async function active(): Promise<Info | undefined> {
|
||||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } f
|
||||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||||
|
|
||||||
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
|
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
|
||||||
|
type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
|
||||||
|
|
||||||
const ACCOUNT_STATE_ID = 1
|
const ACCOUNT_STATE_ID = 1
|
||||||
|
|
||||||
|
|
@ -42,13 +43,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
const query = <A>(f: (db: DbClient) => A) =>
|
const query = <A>(f: DbTransactionCallback<A>) =>
|
||||||
Effect.try({
|
Effect.try({
|
||||||
try: () => Database.use(f),
|
try: () => Database.use(f),
|
||||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const tx = <A>(f: (db: DbClient) => A) =>
|
const tx = <A>(f: DbTransactionCallback<A>) =>
|
||||||
Effect.try({
|
Effect.try({
|
||||||
try: () => Database.transaction(f),
|
try: () => Database.transaction(f),
|
||||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { Plugin } from "@/plugin"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Effect, ServiceMap, Layer } from "effect"
|
import { Effect, ServiceMap, Layer } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace Agent {
|
export namespace Agent {
|
||||||
export const Info = z
|
export const Info = z
|
||||||
|
|
@ -148,7 +148,6 @@ export namespace Agent {
|
||||||
permission: Permission.merge(
|
permission: Permission.merge(
|
||||||
defaults,
|
defaults,
|
||||||
Permission.fromConfig({
|
Permission.fromConfig({
|
||||||
todoread: "deny",
|
|
||||||
todowrite: "deny",
|
todowrite: "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
|
|
@ -394,7 +393,7 @@ export namespace Agent {
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function get(agent: string) {
|
export async function get(agent: string) {
|
||||||
return runPromise((svc) => svc.get(agent))
|
return runPromise((svc) => svc.get(agent))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
@ -95,7 +95,7 @@ export namespace Auth {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export async function get(providerID: string) {
|
export async function get(providerID: string) {
|
||||||
return runPromise((service) => service.get(providerID))
|
return runPromise((service) => service.get(providerID))
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodType } from "zod"
|
import type { ZodType } from "zod"
|
||||||
import { Log } from "../util/log"
|
|
||||||
|
|
||||||
export namespace BusEvent {
|
export namespace BusEvent {
|
||||||
const log = Log.create({ service: "event" })
|
|
||||||
|
|
||||||
export type Definition = ReturnType<typeof define>
|
export type Definition = ReturnType<typeof define>
|
||||||
|
|
||||||
const registry = new Map<string, Definition>()
|
const registry = new Map<string, Definition>()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { BusEvent } from "./bus-event"
|
import { BusEvent } from "./bus-event"
|
||||||
import { GlobalBus } from "./global"
|
import { GlobalBus } from "./global"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace Bus {
|
export namespace Bus {
|
||||||
const log = Log.create({ service: "bus" })
|
const log = Log.create({ service: "bus" })
|
||||||
type Subscription = (event: any) => void
|
|
||||||
|
|
||||||
export const InstanceDisposed = BusEvent.define(
|
export const InstanceDisposed = BusEvent.define(
|
||||||
"server.instance.disposed",
|
"server.instance.disposed",
|
||||||
|
|
@ -15,91 +17,168 @@ export namespace Bus {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const state = Instance.state(
|
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||||
() => {
|
type: D["type"]
|
||||||
const subscriptions = new Map<any, Subscription[]>()
|
properties: z.infer<D["properties"]>
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
type State = {
|
||||||
subscriptions,
|
wildcard: PubSub.PubSub<Payload>
|
||||||
|
typed: Map<string, PubSub.PubSub<Payload>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly publish: <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
properties: z.output<D["properties"]>,
|
||||||
|
) => Effect.Effect<void>
|
||||||
|
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||||
|
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||||
|
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: Payload<D>) => unknown,
|
||||||
|
) => Effect.Effect<() => void>
|
||||||
|
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cache = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Bus.state")(function* (ctx) {
|
||||||
|
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||||
|
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||||
|
yield* PubSub.publish(wildcard, {
|
||||||
|
type: InstanceDisposed.type,
|
||||||
|
properties: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
yield* PubSub.shutdown(wildcard)
|
||||||
|
for (const ps of typed.values()) {
|
||||||
|
yield* PubSub.shutdown(ps)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { wildcard, typed }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
let ps = state.typed.get(def.type)
|
||||||
|
if (!ps) {
|
||||||
|
ps = yield* PubSub.unbounded<Payload>()
|
||||||
|
state.typed.set(def.type, ps)
|
||||||
|
}
|
||||||
|
return ps as unknown as PubSub.PubSub<Payload<D>>
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
async (entry) => {
|
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
const wildcard = entry.subscriptions.get("*")
|
return Effect.gen(function* () {
|
||||||
if (!wildcard) return
|
const state = yield* InstanceState.get(cache)
|
||||||
const event = {
|
const payload: Payload = { type: def.type, properties }
|
||||||
type: InstanceDisposed.type,
|
log.info("publishing", { type: def.type })
|
||||||
properties: {
|
|
||||||
directory: Instance.directory,
|
const ps = state.typed.get(def.type)
|
||||||
},
|
if (ps) yield* PubSub.publish(ps, payload)
|
||||||
|
yield* PubSub.publish(state.wildcard, payload)
|
||||||
|
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: Instance.directory,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
for (const sub of [...wildcard]) {
|
|
||||||
sub(event)
|
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
||||||
|
log.info("subscribing", { type: def.type })
|
||||||
|
return Stream.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const ps = yield* getOrCreate(state, def)
|
||||||
|
return Stream.fromPubSub(ps)
|
||||||
|
}),
|
||||||
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
function subscribeAll(): Stream.Stream<Payload> {
|
||||||
|
log.info("subscribing", { type: "*" })
|
||||||
|
return Stream.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
return Stream.fromPubSub(state.wildcard)
|
||||||
|
}),
|
||||||
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
log.info("subscribing", { type })
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
||||||
|
|
||||||
|
yield* Scope.provide(scope)(
|
||||||
|
Stream.fromSubscription(subscription).pipe(
|
||||||
|
Stream.runForEach((msg) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => Promise.resolve().then(() => callback(msg)),
|
||||||
|
catch: (cause) => {
|
||||||
|
log.error("subscriber failed", { type, cause })
|
||||||
|
},
|
||||||
|
}).pipe(Effect.ignore),
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
log.info("unsubscribing", { type })
|
||||||
|
Effect.runFork(Scope.close(scope, Exit.void))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: Payload<D>) => unknown,
|
||||||
|
) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const ps = yield* getOrCreate(state, def)
|
||||||
|
return yield* on(ps, def.type, callback)
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
return yield* on(state.wildcard, "*", callback)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function publish<Definition extends BusEvent.Definition>(
|
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||||
def: Definition,
|
|
||||||
properties: z.output<Definition["properties"]>,
|
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||||
|
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||||
|
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
|
return runPromise((svc) => svc.publish(def, properties))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe<D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
|
||||||
) {
|
) {
|
||||||
const payload = {
|
return runSync((svc) => svc.subscribeCallback(def, callback))
|
||||||
type: def.type,
|
|
||||||
properties,
|
|
||||||
}
|
|
||||||
log.info("publishing", {
|
|
||||||
type: def.type,
|
|
||||||
})
|
|
||||||
const pending = []
|
|
||||||
for (const key of [def.type, "*"]) {
|
|
||||||
const match = [...(state().subscriptions.get(key) ?? [])]
|
|
||||||
for (const sub of match) {
|
|
||||||
pending.push(sub(payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: Instance.directory,
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
return Promise.all(pending)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribe<Definition extends BusEvent.Definition>(
|
export function subscribeAll(callback: (event: any) => unknown) {
|
||||||
def: Definition,
|
return runSync((svc) => svc.subscribeAllCallback(callback))
|
||||||
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
|
||||||
) {
|
|
||||||
return raw(def.type, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function once<Definition extends BusEvent.Definition>(
|
|
||||||
def: Definition,
|
|
||||||
callback: (event: {
|
|
||||||
type: Definition["type"]
|
|
||||||
properties: z.infer<Definition["properties"]>
|
|
||||||
}) => "done" | undefined,
|
|
||||||
) {
|
|
||||||
const unsub = subscribe(def, (event) => {
|
|
||||||
if (callback(event)) unsub()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeAll(callback: (event: any) => void) {
|
|
||||||
return raw("*", callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
function raw(type: string, callback: (event: any) => void) {
|
|
||||||
log.info("subscribing", { type })
|
|
||||||
const subscriptions = state().subscriptions
|
|
||||||
let match = subscriptions.get(type) ?? []
|
|
||||||
match.push(callback)
|
|
||||||
subscriptions.set(type, match)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
log.info("unsubscribing", { type })
|
|
||||||
const match = subscriptions.get(type)
|
|
||||||
if (!match) return
|
|
||||||
const index = match.indexOf(callback)
|
|
||||||
if (index === -1) return
|
|
||||||
match.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,7 @@ import type { Argv } from "yargs"
|
||||||
|
|
||||||
type AgentMode = "all" | "primary" | "subagent"
|
type AgentMode = "all" | "primary" | "subagent"
|
||||||
|
|
||||||
const AVAILABLE_TOOLS = [
|
const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"]
|
||||||
"bash",
|
|
||||||
"read",
|
|
||||||
"write",
|
|
||||||
"edit",
|
|
||||||
"list",
|
|
||||||
"glob",
|
|
||||||
"grep",
|
|
||||||
"webfetch",
|
|
||||||
"task",
|
|
||||||
"todowrite",
|
|
||||||
"todoread",
|
|
||||||
]
|
|
||||||
|
|
||||||
const AgentCreateCommand = cmd({
|
const AgentCreateCommand = cmd({
|
||||||
command: "create",
|
command: "create",
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
|
||||||
import { Bus } from "../../bus"
|
import { Bus } from "../../bus"
|
||||||
import { MessageV2 } from "../../session/message-v2"
|
import { MessageV2 } from "../../session/message-v2"
|
||||||
import { SessionPrompt } from "@/session/prompt"
|
import { SessionPrompt } from "@/session/prompt"
|
||||||
|
import { Git } from "@/git"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
import { git } from "@/util/git"
|
|
||||||
|
|
||||||
type GitHubAuthor = {
|
type GitHubAuthor = {
|
||||||
login: string
|
login: string
|
||||||
|
|
@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get repo info
|
// Get repo info
|
||||||
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||||
const parsed = parseGitHubRemote(info)
|
const parsed = parseGitHubRemote(info)
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||||
|
|
@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
|
||||||
: "issue"
|
: "issue"
|
||||||
: undefined
|
: undefined
|
||||||
const gitText = async (args: string[]) => {
|
const gitText = async (args: string[]) => {
|
||||||
const result = await git(args, { cwd: Instance.worktree })
|
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||||
}
|
}
|
||||||
return result.text().trim()
|
return result.text().trim()
|
||||||
}
|
}
|
||||||
const gitRun = async (args: string[]) => {
|
const gitRun = async (args: string[]) => {
|
||||||
const result = await git(args, { cwd: Instance.worktree })
|
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
|
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
|
||||||
const commitChanges = async (summary: string, actor?: string) => {
|
const commitChanges = async (summary: string, actor?: string) => {
|
||||||
const args = ["commit", "-m", summary]
|
const args = ["commit", "-m", summary]
|
||||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||||
|
|
@ -869,7 +869,6 @@ export const GithubRunCommand = cmd({
|
||||||
function subscribeSessionEvents() {
|
function subscribeSessionEvents() {
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL: Record<string, [string, string]> = {
|
||||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||||
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
|
||||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||||
|
|
@ -890,7 +889,7 @@ export const GithubRunCommand = cmd({
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = ""
|
let text = ""
|
||||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
|
||||||
if (evt.properties.part.sessionID !== session.id) return
|
if (evt.properties.part.sessionID !== session.id) return
|
||||||
//if (evt.properties.part.messageID === messageID) return
|
//if (evt.properties.part.messageID === messageID) return
|
||||||
const part = evt.properties.part
|
const part = evt.properties.part
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
|
import { Git } from "@/git"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
import { git } from "@/util/git"
|
|
||||||
|
|
||||||
export const PrCommand = cmd({
|
export const PrCommand = cmd({
|
||||||
command: "pr <number>",
|
command: "pr <number>",
|
||||||
|
|
@ -67,9 +67,9 @@ export const PrCommand = cmd({
|
||||||
const remoteName = forkOwner
|
const remoteName = forkOwner
|
||||||
|
|
||||||
// Check if remote already exists
|
// Check if remote already exists
|
||||||
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
|
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||||
if (!remotes.split("\n").includes(remoteName)) {
|
if (!remotes.split("\n").includes(remoteName)) {
|
||||||
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||||
cwd: Instance.worktree,
|
cwd: Instance.worktree,
|
||||||
})
|
})
|
||||||
UI.println(`Added fork remote: ${remoteName}`)
|
UI.println(`Added fork remote: ${remoteName}`)
|
||||||
|
|
@ -77,7 +77,7 @@ export const PrCommand = cmd({
|
||||||
|
|
||||||
// Set upstream to the fork so pushes go there
|
// Set upstream to the fork so pushes go there
|
||||||
const headRefName = prInfo.headRefName
|
const headRefName = prInfo.headRefName
|
||||||
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||||
cwd: Instance.worktree,
|
cwd: Instance.worktree,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -710,7 +710,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
|
sdk.event.on("session.deleted", (evt) => {
|
||||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||||
route.navigate({ type: "home" })
|
route.navigate({ type: "home" })
|
||||||
toast.show({
|
toast.show({
|
||||||
|
|
@ -720,7 +720,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
sdk.event.on("session.error", (evt) => {
|
||||||
const error = evt.properties.error
|
const error = evt.properties.error
|
||||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||||
const message = (() => {
|
const message = (() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
@ -173,7 +173,7 @@ export namespace Command {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export async function get(name: string) {
|
export async function get(name: string) {
|
||||||
return runPromise((svc) => svc.get(name))
|
return runPromise((svc) => svc.get(name))
|
||||||
|
|
|
||||||
|
|
@ -673,7 +673,6 @@ export namespace Config {
|
||||||
task: PermissionRule.optional(),
|
task: PermissionRule.optional(),
|
||||||
external_directory: PermissionRule.optional(),
|
external_directory: PermissionRule.optional(),
|
||||||
todowrite: PermissionAction.optional(),
|
todowrite: PermissionAction.optional(),
|
||||||
todoread: PermissionAction.optional(),
|
|
||||||
question: PermissionAction.optional(),
|
question: PermissionAction.optional(),
|
||||||
webfetch: PermissionAction.optional(),
|
webfetch: PermissionAction.optional(),
|
||||||
websearch: PermissionAction.optional(),
|
websearch: PermissionAction.optional(),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { ServiceMap } from "effect"
|
|
||||||
import type { Project } from "@/project/project"
|
|
||||||
|
|
||||||
export declare namespace InstanceContext {
|
|
||||||
export interface Shape {
|
|
||||||
readonly directory: string
|
|
||||||
readonly worktree: string
|
|
||||||
readonly project: Project.Info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
|
|
||||||
"opencode/InstanceContext",
|
|
||||||
) {}
|
|
||||||
|
|
@ -3,11 +3,15 @@ import * as ServiceMap from "effect/ServiceMap"
|
||||||
|
|
||||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
|
|
||||||
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||||
|
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
||||||
|
|
||||||
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
|
return {
|
||||||
rt ??= ManagedRuntime.make(layer, { memoMap })
|
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||||
return rt.runPromise(service.use(fn), options)
|
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
|
getRuntime().runPromise(service.use(fn), options),
|
||||||
|
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||||
|
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { git } from "@/util/git"
|
import { Git } from "@/git"
|
||||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
|
|
@ -323,7 +323,6 @@ export namespace File {
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
cache: Entry
|
cache: Entry
|
||||||
fiber: Fiber.Fiber<void> | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|
@ -348,7 +347,6 @@ export namespace File {
|
||||||
Effect.fn("File.state")(() =>
|
Effect.fn("File.state")(() =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
cache: { files: [], dirs: [] } as Entry,
|
cache: { files: [], dirs: [] } as Entry,
|
||||||
fiber: undefined as Fiber.Fiber<void> | undefined,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -406,21 +404,11 @@ export namespace File {
|
||||||
s.cache = next
|
s.cache = next
|
||||||
})
|
})
|
||||||
|
|
||||||
const scope = yield* Scope.Scope
|
let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||||
|
|
||||||
const ensure = Effect.fn("File.ensure")(function* () {
|
const ensure = Effect.fn("File.ensure")(function* () {
|
||||||
const s = yield* InstanceState.get(state)
|
yield* cachedScan
|
||||||
if (!s.fiber)
|
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||||
s.fiber = yield* scan().pipe(
|
|
||||||
Effect.catchCause(() => Effect.void),
|
|
||||||
Effect.ensuring(
|
|
||||||
Effect.sync(() => {
|
|
||||||
s.fiber = undefined
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.forkIn(scope),
|
|
||||||
)
|
|
||||||
yield* Fiber.join(s.fiber)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const init = Effect.fn("File.init")(function* () {
|
const init = Effect.fn("File.init")(function* () {
|
||||||
|
|
@ -432,7 +420,7 @@ export namespace File {
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const diffOutput = (
|
const diffOutput = (
|
||||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
})
|
})
|
||||||
).text()
|
).text()
|
||||||
|
|
@ -452,7 +440,7 @@ export namespace File {
|
||||||
}
|
}
|
||||||
|
|
||||||
const untrackedOutput = (
|
const untrackedOutput = (
|
||||||
await git(
|
await Git.run(
|
||||||
[
|
[
|
||||||
"-c",
|
"-c",
|
||||||
"core.fsmonitor=false",
|
"core.fsmonitor=false",
|
||||||
|
|
@ -485,7 +473,7 @@ export namespace File {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedOutput = (
|
const deletedOutput = (
|
||||||
await git(
|
await Git.run(
|
||||||
[
|
[
|
||||||
"-c",
|
"-c",
|
||||||
"core.fsmonitor=false",
|
"core.fsmonitor=false",
|
||||||
|
|
@ -576,17 +564,17 @@ export namespace File {
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
if (Instance.project.vcs === "git") {
|
||||||
let diff = (
|
let diff = (
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||||
).text()
|
).text()
|
||||||
if (!diff.trim()) {
|
if (!diff.trim()) {
|
||||||
diff = (
|
diff = (
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||||
cwd: Instance.directory,
|
cwd: Instance.directory,
|
||||||
})
|
})
|
||||||
).text()
|
).text()
|
||||||
}
|
}
|
||||||
if (diff.trim()) {
|
if (diff.trim()) {
|
||||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||||
context: Infinity,
|
context: Infinity,
|
||||||
ignoreWhitespace: true,
|
ignoreWhitespace: true,
|
||||||
|
|
@ -688,7 +676,7 @@ export namespace File {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromise((svc) => svc.init())
|
return runPromise((svc) => svc.init())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
@ -108,7 +108,7 @@ export namespace FileTime {
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.orDie)
|
).pipe(Layer.orDie)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
export function read(sessionID: SessionID, file: string) {
|
||||||
return runPromise((s) => s.read(sessionID, file))
|
return runPromise((s) => s.read(sessionID, file))
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ import z from "zod"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Git } from "@/git"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { git } from "@/util/git"
|
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { FileIgnore } from "./ignore"
|
import { FileIgnore } from "./ignore"
|
||||||
|
|
@ -130,7 +130,7 @@ export namespace FileWatcher {
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
if (Instance.project.vcs === "git") {
|
||||||
const result = yield* Effect.promise(() =>
|
const result = yield* Effect.promise(() =>
|
||||||
git(["rev-parse", "--git-dir"], {
|
Git.run(["rev-parse", "--git-dir"], {
|
||||||
cwd: Instance.project.worktree,
|
cwd: Instance.project.worktree,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -159,7 +159,7 @@ export namespace FileWatcher {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromise((svc) => svc.init())
|
return runPromise((svc) => svc.init())
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { File } from "../file"
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
@ -29,6 +27,7 @@ export namespace Format {
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
|
readonly file: (filepath: string) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||||
|
|
@ -97,53 +96,46 @@ export namespace Format {
|
||||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
return checks.filter((x) => x.enabled).map((x) => x.item)
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
async function formatFile(filepath: string) {
|
||||||
Effect.sync(() =>
|
log.info("formatting", { file: filepath })
|
||||||
Bus.subscribe(
|
const ext = path.extname(filepath)
|
||||||
File.Event.Edited,
|
|
||||||
Instance.bind(async (payload) => {
|
for (const item of await getFormatter(ext)) {
|
||||||
const file = payload.properties.file
|
log.info("running", { command: item.command })
|
||||||
log.info("formatting", { file })
|
try {
|
||||||
const ext = path.extname(file)
|
const proc = Process.spawn(
|
||||||
|
item.command.map((x) => x.replace("$FILE", filepath)),
|
||||||
|
{
|
||||||
|
cwd: Instance.directory,
|
||||||
|
env: { ...process.env, ...item.environment },
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const exit = await proc.exited
|
||||||
|
if (exit !== 0) {
|
||||||
|
log.error("failed", {
|
||||||
|
command: item.command,
|
||||||
|
...item.environment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("failed to format file", {
|
||||||
|
error,
|
||||||
|
command: item.command,
|
||||||
|
...item.environment,
|
||||||
|
file: filepath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of await getFormatter(ext)) {
|
|
||||||
log.info("running", { command: item.command })
|
|
||||||
try {
|
|
||||||
const proc = Process.spawn(
|
|
||||||
item.command.map((x) => x.replace("$FILE", file)),
|
|
||||||
{
|
|
||||||
cwd: Instance.directory,
|
|
||||||
env: { ...process.env, ...item.environment },
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const exit = await proc.exited
|
|
||||||
if (exit !== 0) {
|
|
||||||
log.error("failed", {
|
|
||||||
command: item.command,
|
|
||||||
...item.environment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("failed to format file", {
|
|
||||||
error,
|
|
||||||
command: item.command,
|
|
||||||
...item.environment,
|
|
||||||
file,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(unsubscribe) => Effect.sync(unsubscribe),
|
|
||||||
)
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
formatters,
|
formatters,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
|
formatFile,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -166,11 +158,16 @@ export namespace Format {
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ init, status })
|
const file = Effect.fn("Format.file")(function* (filepath: string) {
|
||||||
|
const { formatFile } = yield* InstanceState.get(state)
|
||||||
|
yield* Effect.promise(() => formatFile(filepath))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ init, status, file })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
return runPromise((s) => s.init())
|
return runPromise((s) => s.init())
|
||||||
|
|
@ -179,4 +176,8 @@ export namespace Format {
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromise((s) => s.status())
|
return runPromise((s) => s.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function file(filepath: string) {
|
||||||
|
return runPromise((s) => s.file(filepath))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
308
packages/opencode/src/git/index.ts
Normal file
308
packages/opencode/src/git/index.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
|
export namespace Git {
|
||||||
|
const cfg = [
|
||||||
|
"--no-optional-locks",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const out = (result: { text(): string }) => result.text().trim()
|
||||||
|
const nuls = (text: string) => text.split("\0").filter(Boolean)
|
||||||
|
const fail = (err: unknown) =>
|
||||||
|
({
|
||||||
|
exitCode: 1,
|
||||||
|
text: () => "",
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||||
|
}) satisfies Result
|
||||||
|
|
||||||
|
export type Kind = "added" | "deleted" | "modified"
|
||||||
|
|
||||||
|
export type Base = {
|
||||||
|
readonly name: string
|
||||||
|
readonly ref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Item = {
|
||||||
|
readonly file: string
|
||||||
|
readonly code: string
|
||||||
|
readonly status: Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Stat = {
|
||||||
|
readonly file: string
|
||||||
|
readonly additions: number
|
||||||
|
readonly deletions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
readonly exitCode: number
|
||||||
|
readonly text: () => string
|
||||||
|
readonly stdout: Buffer
|
||||||
|
readonly stderr: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
readonly cwd: string
|
||||||
|
readonly env?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
|
||||||
|
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
|
||||||
|
readonly prefix: (cwd: string) => Effect.Effect<string>
|
||||||
|
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
|
||||||
|
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
|
||||||
|
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
|
||||||
|
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
|
||||||
|
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
||||||
|
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
||||||
|
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = (code: string): Kind => {
|
||||||
|
if (code === "??") return "added"
|
||||||
|
if (code.includes("U")) return "modified"
|
||||||
|
if (code.includes("A") && !code.includes("D")) return "added"
|
||||||
|
if (code.includes("D") && !code.includes("A")) return "deleted"
|
||||||
|
return "modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
|
||||||
|
const run = Effect.fn("Git.run")(
|
||||||
|
function* (args: string[], opts: Options) {
|
||||||
|
const proc = ChildProcess.make("git", [...cfg, ...args], {
|
||||||
|
cwd: opts.cwd,
|
||||||
|
env: opts.env,
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const [stdout, stderr] = yield* Effect.all(
|
||||||
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
exitCode: yield* handle.exitCode,
|
||||||
|
text: () => stdout,
|
||||||
|
stdout: Buffer.from(stdout),
|
||||||
|
stderr: Buffer.from(stderr),
|
||||||
|
} satisfies Result
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch((err) => Effect.succeed(fail(err))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
|
||||||
|
return (yield* run(args, opts)).text()
|
||||||
|
})
|
||||||
|
|
||||||
|
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
|
||||||
|
return (yield* text(args, opts))
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refs = Effect.fnUntraced(function* (cwd: string) {
|
||||||
|
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
|
||||||
|
})
|
||||||
|
|
||||||
|
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
|
||||||
|
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
||||||
|
const name = out(result)
|
||||||
|
if (!name || !list.includes(name)) return
|
||||||
|
return { name, ref: name } satisfies Base
|
||||||
|
})
|
||||||
|
|
||||||
|
const primary = Effect.fnUntraced(function* (cwd: string) {
|
||||||
|
const list = yield* lines(["remote"], { cwd })
|
||||||
|
if (list.includes("origin")) return "origin"
|
||||||
|
if (list.length === 1) return list[0]
|
||||||
|
if (list.includes("upstream")) return "upstream"
|
||||||
|
return list[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
|
||||||
|
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
|
||||||
|
if (result.exitCode !== 0) return
|
||||||
|
const text = out(result)
|
||||||
|
return text || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
|
||||||
|
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
|
||||||
|
if (result.exitCode !== 0) return ""
|
||||||
|
return out(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
|
||||||
|
const remote = yield* primary(cwd)
|
||||||
|
if (remote) {
|
||||||
|
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
|
||||||
|
if (head.exitCode === 0) {
|
||||||
|
const ref = out(head).replace(/^refs\/remotes\//, "")
|
||||||
|
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
||||||
|
if (name) return { name, ref } satisfies Base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = yield* refs(cwd)
|
||||||
|
const next = yield* configured(cwd, list)
|
||||||
|
if (next) return next
|
||||||
|
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
|
||||||
|
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
|
||||||
|
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
|
||||||
|
return result.exitCode === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
|
||||||
|
const result = yield* run(["merge-base", base, head], { cwd })
|
||||||
|
if (result.exitCode !== 0) return
|
||||||
|
const text = out(result)
|
||||||
|
return text || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
|
||||||
|
const target = prefix ? `${prefix}${file}` : file
|
||||||
|
const result = yield* run(["show", `${ref}:${target}`], { cwd })
|
||||||
|
if (result.exitCode !== 0) return ""
|
||||||
|
if (result.stdout.includes(0)) return ""
|
||||||
|
return result.text()
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("Git.status")(function* (cwd: string) {
|
||||||
|
return nuls(
|
||||||
|
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
|
||||||
|
cwd,
|
||||||
|
}),
|
||||||
|
).flatMap((item) => {
|
||||||
|
const file = item.slice(3)
|
||||||
|
if (!file) return []
|
||||||
|
const code = item.slice(0, 2)
|
||||||
|
return [{ file, code, status: kind(code) } satisfies Item]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
|
||||||
|
const list = nuls(
|
||||||
|
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
|
||||||
|
)
|
||||||
|
return list.flatMap((code, idx) => {
|
||||||
|
if (idx % 2 !== 0) return []
|
||||||
|
const file = list[idx + 1]
|
||||||
|
if (!code || !file) return []
|
||||||
|
return [{ file, code, status: kind(code) } satisfies Item]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
|
||||||
|
return nuls(
|
||||||
|
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
|
||||||
|
).flatMap((item) => {
|
||||||
|
const a = item.indexOf("\t")
|
||||||
|
const b = item.indexOf("\t", a + 1)
|
||||||
|
if (a === -1 || b === -1) return []
|
||||||
|
const file = item.slice(b + 1)
|
||||||
|
if (!file) return []
|
||||||
|
const adds = item.slice(0, a)
|
||||||
|
const dels = item.slice(a + 1, b)
|
||||||
|
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
|
||||||
|
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
file,
|
||||||
|
additions: Number.isFinite(additions) ? additions : 0,
|
||||||
|
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||||
|
} satisfies Stat,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
run,
|
||||||
|
branch,
|
||||||
|
prefix,
|
||||||
|
defaultBranch,
|
||||||
|
hasHead,
|
||||||
|
mergeBase,
|
||||||
|
show,
|
||||||
|
status,
|
||||||
|
diff,
|
||||||
|
stats,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(CrossSpawnSpawner.layer),
|
||||||
|
Layer.provide(NodeFileSystem.layer),
|
||||||
|
Layer.provide(NodePath.layer),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export function run(args: string[], opts: Options) {
|
||||||
|
return runPromise((git) => git.run(args, opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function branch(cwd: string) {
|
||||||
|
return runPromise((git) => git.branch(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefix(cwd: string) {
|
||||||
|
return runPromise((git) => git.prefix(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultBranch(cwd: string) {
|
||||||
|
return runPromise((git) => git.defaultBranch(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasHead(cwd: string) {
|
||||||
|
return runPromise((git) => git.hasHead(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeBase(cwd: string, base: string, head?: string) {
|
||||||
|
return runPromise((git) => git.mergeBase(cwd, base, head))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function show(cwd: string, ref: string, file: string, prefix?: string) {
|
||||||
|
return runPromise((git) => git.show(cwd, ref, file, prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function status(cwd: string) {
|
||||||
|
return runPromise((git) => git.status(cwd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diff(cwd: string, ref: string) {
|
||||||
|
return runPromise((git) => git.diff(cwd, ref))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stats(cwd: string, ref: string) {
|
||||||
|
return runPromise((git) => git.stats(cwd, ref))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { randomBytes } from "crypto"
|
||||||
|
|
||||||
export namespace Identifier {
|
export namespace Identifier {
|
||||||
const prefixes = {
|
const prefixes = {
|
||||||
|
event: "evt",
|
||||||
session: "ses",
|
session: "ses",
|
||||||
message: "msg",
|
message: "msg",
|
||||||
permission: "per",
|
permission: "per",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
@ -346,7 +346,7 @@ export namespace Installation {
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function info(): Promise<Info> {
|
export async function info(): Promise<Info> {
|
||||||
return runPromise((svc) => svc.info())
|
return runPromise((svc) => svc.info())
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,12 @@ export namespace LSP {
|
||||||
|
|
||||||
async function getClients(file: string) {
|
async function getClients(file: string) {
|
||||||
const s = await state()
|
const s = await state()
|
||||||
|
|
||||||
|
// Only spawn LSP clients for files within the instance directory
|
||||||
|
if (!Instance.containsPath(file)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const extension = path.parse(file).ext || file
|
const extension = path.parse(file).ext || file
|
||||||
const result: LSPClient.Info[] = []
|
const result: LSPClient.Info[] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace McpAuth {
|
export namespace McpAuth {
|
||||||
export const Tokens = z.object({
|
export const Tokens = z.object({
|
||||||
|
|
@ -25,106 +27,155 @@ export namespace McpAuth {
|
||||||
clientInfo: ClientInfo.optional(),
|
clientInfo: ClientInfo.optional(),
|
||||||
codeVerifier: z.string().optional(),
|
codeVerifier: z.string().optional(),
|
||||||
oauthState: z.string().optional(),
|
oauthState: z.string().optional(),
|
||||||
serverUrl: z.string().optional(), // Track the URL these credentials are for
|
serverUrl: z.string().optional(),
|
||||||
})
|
})
|
||||||
export type Entry = z.infer<typeof Entry>
|
export type Entry = z.infer<typeof Entry>
|
||||||
|
|
||||||
const filepath = path.join(Global.Path.data, "mcp-auth.json")
|
const filepath = path.join(Global.Path.data, "mcp-auth.json")
|
||||||
|
|
||||||
export async function get(mcpName: string): Promise<Entry | undefined> {
|
export interface Interface {
|
||||||
const data = await all()
|
readonly all: () => Effect.Effect<Record<string, Entry>>
|
||||||
return data[mcpName]
|
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
|
||||||
|
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
|
||||||
|
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
|
||||||
|
readonly remove: (mcpName: string) => Effect.Effect<void>
|
||||||
|
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
|
||||||
|
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
|
||||||
|
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
|
||||||
|
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
|
||||||
|
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
|
||||||
|
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
|
||||||
|
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
|
||||||
|
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
|
||||||
* Get auth entry and validate it's for the correct URL.
|
|
||||||
* Returns undefined if URL has changed (credentials are invalid).
|
|
||||||
*/
|
|
||||||
export async function getForUrl(mcpName: string, serverUrl: string): Promise<Entry | undefined> {
|
|
||||||
const entry = await get(mcpName)
|
|
||||||
if (!entry) return undefined
|
|
||||||
|
|
||||||
// If no serverUrl is stored, this is from an old version - consider it invalid
|
export const layer = Layer.effect(
|
||||||
if (!entry.serverUrl) return undefined
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
|
||||||
// If URL has changed, credentials are invalid
|
const all = Effect.fn("McpAuth.all")(function* () {
|
||||||
if (entry.serverUrl !== serverUrl) return undefined
|
return yield* fs.readJson(filepath).pipe(
|
||||||
|
Effect.map((data) => data as Record<string, Entry>),
|
||||||
|
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return entry
|
const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
|
||||||
}
|
const data = yield* all()
|
||||||
|
return data[mcpName]
|
||||||
|
})
|
||||||
|
|
||||||
export async function all(): Promise<Record<string, Entry>> {
|
const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
|
||||||
return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
|
const entry = yield* get(mcpName)
|
||||||
}
|
if (!entry) return undefined
|
||||||
|
if (!entry.serverUrl) return undefined
|
||||||
|
if (entry.serverUrl !== serverUrl) return undefined
|
||||||
|
return entry
|
||||||
|
})
|
||||||
|
|
||||||
export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
|
const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
|
||||||
const data = await all()
|
const data = yield* all()
|
||||||
// Always update serverUrl if provided
|
if (serverUrl) entry.serverUrl = serverUrl
|
||||||
if (serverUrl) {
|
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
|
||||||
entry.serverUrl = serverUrl
|
})
|
||||||
}
|
|
||||||
await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(mcpName: string): Promise<void> {
|
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
|
||||||
const data = await all()
|
const data = yield* all()
|
||||||
delete data[mcpName]
|
delete data[mcpName]
|
||||||
await Filesystem.writeJson(filepath, data, 0o600)
|
yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
|
||||||
}
|
})
|
||||||
|
|
||||||
export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
|
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
|
||||||
const entry = (await get(mcpName)) ?? {}
|
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
|
||||||
entry.tokens = tokens
|
const entry = (yield* get(mcpName)) ?? {}
|
||||||
await set(mcpName, entry, serverUrl)
|
entry[field] = value
|
||||||
}
|
yield* set(mcpName, entry, serverUrl)
|
||||||
|
})
|
||||||
|
|
||||||
export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> {
|
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
|
||||||
const entry = (await get(mcpName)) ?? {}
|
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
|
||||||
entry.clientInfo = clientInfo
|
const entry = yield* get(mcpName)
|
||||||
await set(mcpName, entry, serverUrl)
|
if (entry) {
|
||||||
}
|
delete entry[field]
|
||||||
|
yield* set(mcpName, entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
|
const updateTokens = updateField("tokens", "updateTokens")
|
||||||
const entry = (await get(mcpName)) ?? {}
|
const updateClientInfo = updateField("clientInfo", "updateClientInfo")
|
||||||
entry.codeVerifier = codeVerifier
|
const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
|
||||||
await set(mcpName, entry)
|
const updateOAuthState = updateField("oauthState", "updateOAuthState")
|
||||||
}
|
const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
|
||||||
|
const clearOAuthState = clearField("oauthState", "clearOAuthState")
|
||||||
|
|
||||||
export async function clearCodeVerifier(mcpName: string): Promise<void> {
|
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
|
||||||
const entry = await get(mcpName)
|
const entry = yield* get(mcpName)
|
||||||
if (entry) {
|
return entry?.oauthState
|
||||||
delete entry.codeVerifier
|
})
|
||||||
await set(mcpName, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateOAuthState(mcpName: string, oauthState: string): Promise<void> {
|
const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
|
||||||
const entry = (await get(mcpName)) ?? {}
|
const entry = yield* get(mcpName)
|
||||||
entry.oauthState = oauthState
|
if (!entry?.tokens) return null
|
||||||
await set(mcpName, entry)
|
if (!entry.tokens.expiresAt) return false
|
||||||
}
|
return entry.tokens.expiresAt < Date.now() / 1000
|
||||||
|
})
|
||||||
|
|
||||||
export async function getOAuthState(mcpName: string): Promise<string | undefined> {
|
return Service.of({
|
||||||
const entry = await get(mcpName)
|
all,
|
||||||
return entry?.oauthState
|
get,
|
||||||
}
|
getForUrl,
|
||||||
|
set,
|
||||||
|
remove,
|
||||||
|
updateTokens,
|
||||||
|
updateClientInfo,
|
||||||
|
updateCodeVerifier,
|
||||||
|
clearCodeVerifier,
|
||||||
|
updateOAuthState,
|
||||||
|
getOAuthState,
|
||||||
|
clearOAuthState,
|
||||||
|
isTokenExpired,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export async function clearOAuthState(mcpName: string): Promise<void> {
|
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
const entry = await get(mcpName)
|
|
||||||
if (entry) {
|
|
||||||
delete entry.oauthState
|
|
||||||
await set(mcpName, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
* Check if stored tokens are expired.
|
|
||||||
* Returns null if no tokens exist, false if no expiry or not expired, true if expired.
|
// Async facades for backward compat (used by McpOAuthProvider, CLI)
|
||||||
*/
|
|
||||||
export async function isTokenExpired(mcpName: string): Promise<boolean | null> {
|
export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
|
||||||
const entry = await get(mcpName)
|
|
||||||
if (!entry?.tokens) return null
|
export const getForUrl = async (mcpName: string, serverUrl: string) =>
|
||||||
if (!entry.tokens.expiresAt) return false
|
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
|
||||||
return entry.tokens.expiresAt < Date.now() / 1000
|
|
||||||
}
|
export const all = async () => runPromise((svc) => svc.all())
|
||||||
|
|
||||||
|
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
|
||||||
|
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
|
||||||
|
|
||||||
|
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
|
||||||
|
|
||||||
|
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
|
||||||
|
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
|
||||||
|
|
||||||
|
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
|
||||||
|
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
|
||||||
|
|
||||||
|
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
|
||||||
|
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
|
||||||
|
|
||||||
|
export const clearCodeVerifier = async (mcpName: string) => runPromise((svc) => svc.clearCodeVerifier(mcpName))
|
||||||
|
|
||||||
|
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
|
||||||
|
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
|
||||||
|
|
||||||
|
export const getOAuthState = async (mcpName: string) => runPromise((svc) => svc.getOAuthState(mcpName))
|
||||||
|
|
||||||
|
export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName))
|
||||||
|
|
||||||
|
export const isTokenExpired = async (mcpName: string) => runPromise((svc) => svc.isTokenExpired(mcpName))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -54,6 +54,9 @@ interface PendingAuth {
|
||||||
export namespace McpOAuthCallback {
|
export namespace McpOAuthCallback {
|
||||||
let server: ReturnType<typeof Bun.serve> | undefined
|
let server: ReturnType<typeof Bun.serve> | undefined
|
||||||
const pendingAuths = new Map<string, PendingAuth>()
|
const pendingAuths = new Map<string, PendingAuth>()
|
||||||
|
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
|
||||||
|
// find the right entry in pendingAuths (which is keyed by oauthState).
|
||||||
|
const mcpNameToState = new Map<string, string>()
|
||||||
|
|
||||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
|
@ -98,6 +101,12 @@ export namespace McpOAuthCallback {
|
||||||
const pending = pendingAuths.get(state)!
|
const pending = pendingAuths.get(state)!
|
||||||
clearTimeout(pending.timeout)
|
clearTimeout(pending.timeout)
|
||||||
pendingAuths.delete(state)
|
pendingAuths.delete(state)
|
||||||
|
for (const [name, s] of mcpNameToState) {
|
||||||
|
if (s === state) {
|
||||||
|
mcpNameToState.delete(name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
pending.reject(new Error(errorMsg))
|
pending.reject(new Error(errorMsg))
|
||||||
}
|
}
|
||||||
return new Response(HTML_ERROR(errorMsg), {
|
return new Response(HTML_ERROR(errorMsg), {
|
||||||
|
|
@ -126,6 +135,13 @@ export namespace McpOAuthCallback {
|
||||||
|
|
||||||
clearTimeout(pending.timeout)
|
clearTimeout(pending.timeout)
|
||||||
pendingAuths.delete(state)
|
pendingAuths.delete(state)
|
||||||
|
// Clean up reverse index
|
||||||
|
for (const [name, s] of mcpNameToState) {
|
||||||
|
if (s === state) {
|
||||||
|
mcpNameToState.delete(name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
pending.resolve(code)
|
pending.resolve(code)
|
||||||
|
|
||||||
return new Response(HTML_SUCCESS, {
|
return new Response(HTML_SUCCESS, {
|
||||||
|
|
@ -137,11 +153,13 @@ export namespace McpOAuthCallback {
|
||||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function waitForCallback(oauthState: string): Promise<string> {
|
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
|
||||||
|
if (mcpName) mcpNameToState.set(mcpName, oauthState)
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (pendingAuths.has(oauthState)) {
|
if (pendingAuths.has(oauthState)) {
|
||||||
pendingAuths.delete(oauthState)
|
pendingAuths.delete(oauthState)
|
||||||
|
if (mcpName) mcpNameToState.delete(mcpName)
|
||||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||||
}
|
}
|
||||||
}, CALLBACK_TIMEOUT_MS)
|
}, CALLBACK_TIMEOUT_MS)
|
||||||
|
|
@ -151,10 +169,14 @@ export namespace McpOAuthCallback {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelPending(mcpName: string): void {
|
export function cancelPending(mcpName: string): void {
|
||||||
const pending = pendingAuths.get(mcpName)
|
// Look up the oauthState for this mcpName via the reverse index
|
||||||
|
const oauthState = mcpNameToState.get(mcpName)
|
||||||
|
const key = oauthState ?? mcpName
|
||||||
|
const pending = pendingAuths.get(key)
|
||||||
if (pending) {
|
if (pending) {
|
||||||
clearTimeout(pending.timeout)
|
clearTimeout(pending.timeout)
|
||||||
pendingAuths.delete(mcpName)
|
pendingAuths.delete(key)
|
||||||
|
mcpNameToState.delete(mcpName)
|
||||||
pending.reject(new Error("Authorization cancelled"))
|
pending.reject(new Error("Authorization cancelled"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +206,7 @@ export namespace McpOAuthCallback {
|
||||||
pending.reject(new Error("OAuth callback server stopped"))
|
pending.reject(new Error("OAuth callback server stopped"))
|
||||||
}
|
}
|
||||||
pendingAuths.clear()
|
pendingAuths.clear()
|
||||||
|
mcpNameToState.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRunning(): boolean {
|
export function isRunning(): boolean {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
|
@ -306,7 +306,7 @@ export namespace Permission {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runPromise = makeRunPromise(Service, layer)
|
export const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export async function ask(input: z.infer<typeof AskInput>) {
|
export async function ask(input: z.infer<typeof AskInput>) {
|
||||||
return runPromise((s) => s.ask(input))
|
return runPromise((s) => s.ask(input))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { Config } from "../config/config"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { Server } from "../server/server"
|
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { CodexAuthPlugin } from "./codex"
|
import { CodexAuthPlugin } from "./codex"
|
||||||
|
|
@ -12,9 +11,9 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { CopilotAuthPlugin } from "./copilot"
|
import { CopilotAuthPlugin } from "./copilot"
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
@ -53,11 +52,15 @@ export namespace Plugin {
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
const cache = yield* InstanceState.make<State>(
|
const cache = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Plugin.state")(function* (ctx) {
|
Effect.fn("Plugin.state")(function* (ctx) {
|
||||||
const hooks: Hooks[] = []
|
const hooks: Hooks[] = []
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
yield* Effect.promise(async () => {
|
||||||
|
const { Server } = await import("../server/server")
|
||||||
|
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
baseUrl: "http://localhost:4096",
|
baseUrl: "http://localhost:4096",
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
|
|
@ -145,16 +148,16 @@ export namespace Plugin {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Subscribe to bus events, clean up when scope is closed
|
// Subscribe to bus events, fiber interrupted when scope closes
|
||||||
yield* Effect.acquireRelease(
|
yield* bus.subscribeAll().pipe(
|
||||||
Effect.sync(() =>
|
Stream.runForEach((input) =>
|
||||||
Bus.subscribeAll(async (input) => {
|
Effect.sync(() => {
|
||||||
for (const hook of hooks) {
|
for (const hook of hooks) {
|
||||||
hook["event"]?.({ event: input })
|
hook["event"]?.({ event: input as any })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
(unsub) => Effect.sync(unsub),
|
Effect.forkScoped,
|
||||||
)
|
)
|
||||||
|
|
||||||
return { hooks }
|
return { hooks }
|
||||||
|
|
@ -191,7 +194,8 @@ export namespace Plugin {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function trigger<
|
export async function trigger<
|
||||||
Name extends TriggerName,
|
Name extends TriggerName,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { ProjectID } from "./schema"
|
||||||
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
|
||||||
|
|
@ -462,7 +462,7 @@ export namespace Project {
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
)
|
)
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Promise-based API (delegates to Effect service via runPromise)
|
// Promise-based API (delegates to Effect service via runPromise)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,111 @@
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
|
import path from "path"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { git } from "@/util/git"
|
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
export namespace Vcs {
|
export namespace Vcs {
|
||||||
const log = Log.create({ service: "vcs" })
|
const log = Log.create({ service: "vcs" })
|
||||||
|
|
||||||
|
const count = (text: string) => {
|
||||||
|
if (!text) return 0
|
||||||
|
if (!text.endsWith("\n")) return text.split("\n").length
|
||||||
|
return text.slice(0, -1).split("\n").length
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||||
|
const full = path.join(cwd, file)
|
||||||
|
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||||
|
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||||
|
if (Buffer.from(buf).includes(0)) return ""
|
||||||
|
return Buffer.from(buf).toString("utf8")
|
||||||
|
})
|
||||||
|
|
||||||
|
const nums = (list: Git.Stat[]) =>
|
||||||
|
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||||
|
|
||||||
|
const merge = (...lists: Git.Item[][]) => {
|
||||||
|
const out = new Map<string, Git.Item>()
|
||||||
|
lists.flat().forEach((item) => {
|
||||||
|
if (!out.has(item.file)) out.set(item.file, item)
|
||||||
|
})
|
||||||
|
return [...out.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Effect.fnUntraced(function* (
|
||||||
|
fs: AppFileSystem.Interface,
|
||||||
|
git: Git.Interface,
|
||||||
|
cwd: string,
|
||||||
|
ref: string | undefined,
|
||||||
|
list: Git.Item[],
|
||||||
|
map: Map<string, { additions: number; deletions: number }>,
|
||||||
|
) {
|
||||||
|
const base = ref ? yield* git.prefix(cwd) : ""
|
||||||
|
const next = yield* Effect.forEach(
|
||||||
|
list,
|
||||||
|
(item) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||||
|
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||||
|
const stat = map.get(item.file)
|
||||||
|
return {
|
||||||
|
file: item.file,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||||
|
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||||
|
status: item.status,
|
||||||
|
} satisfies Snapshot.FileDiff
|
||||||
|
}),
|
||||||
|
{ concurrency: 8 },
|
||||||
|
)
|
||||||
|
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||||
|
})
|
||||||
|
|
||||||
|
const track = Effect.fnUntraced(function* (
|
||||||
|
fs: AppFileSystem.Interface,
|
||||||
|
git: Git.Interface,
|
||||||
|
cwd: string,
|
||||||
|
ref: string | undefined,
|
||||||
|
) {
|
||||||
|
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||||
|
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||||
|
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||||
|
})
|
||||||
|
|
||||||
|
const compare = Effect.fnUntraced(function* (
|
||||||
|
fs: AppFileSystem.Interface,
|
||||||
|
git: Git.Interface,
|
||||||
|
cwd: string,
|
||||||
|
ref: string,
|
||||||
|
) {
|
||||||
|
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||||
|
concurrency: 3,
|
||||||
|
})
|
||||||
|
return yield* files(
|
||||||
|
fs,
|
||||||
|
git,
|
||||||
|
cwd,
|
||||||
|
ref,
|
||||||
|
merge(
|
||||||
|
list,
|
||||||
|
extra.filter((item) => item.code === "??"),
|
||||||
|
),
|
||||||
|
nums(stats),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Mode = z.enum(["git", "branch"])
|
||||||
|
export type Mode = z.infer<typeof Mode>
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
BranchUpdated: BusEvent.define(
|
BranchUpdated: BusEvent.define(
|
||||||
"vcs.branch.updated",
|
"vcs.branch.updated",
|
||||||
|
|
@ -23,7 +117,8 @@ export namespace Vcs {
|
||||||
|
|
||||||
export const Info = z
|
export const Info = z
|
||||||
.object({
|
.object({
|
||||||
branch: z.string(),
|
branch: z.string().optional(),
|
||||||
|
default_branch: z.string().optional(),
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
ref: "VcsInfo",
|
ref: "VcsInfo",
|
||||||
|
|
@ -33,54 +128,50 @@ export namespace Vcs {
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly branch: () => Effect.Effect<string | undefined>
|
readonly branch: () => Effect.Effect<string | undefined>
|
||||||
|
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||||
|
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
current: string | undefined
|
current: string | undefined
|
||||||
|
root: Git.Base | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Vcs.state")((ctx) =>
|
Effect.fn("Vcs.state")((ctx) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (ctx.project.vcs !== "git") {
|
if (ctx.project.vcs !== "git") {
|
||||||
return { current: undefined }
|
return { current: undefined, root: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCurrentBranch = async () => {
|
const get = () => Effect.runPromise(git.branch(ctx.directory))
|
||||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||||
cwd: ctx.worktree,
|
concurrency: 2,
|
||||||
})
|
})
|
||||||
if (result.exitCode !== 0) return undefined
|
const value = { current, root }
|
||||||
const text = result.text().trim()
|
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||||
return text || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = {
|
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||||
current: yield* Effect.promise(() => getCurrentBranch()),
|
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||||
}
|
Stream.runForEach((_evt) =>
|
||||||
log.info("initialized", { branch: value.current })
|
Effect.gen(function* () {
|
||||||
|
const next = yield* Effect.promise(() => get())
|
||||||
yield* Effect.acquireRelease(
|
if (next !== value.current) {
|
||||||
Effect.sync(() =>
|
log.info("branch changed", { from: value.current, to: next })
|
||||||
Bus.subscribe(
|
value.current = next
|
||||||
FileWatcher.Event.Updated,
|
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||||
Instance.bind(async (evt) => {
|
}
|
||||||
if (!evt.properties.file.endsWith("HEAD")) return
|
}),
|
||||||
const next = await getCurrentBranch()
|
|
||||||
if (next !== value.current) {
|
|
||||||
log.info("branch changed", { from: value.current, to: next })
|
|
||||||
value.current = next
|
|
||||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(unsubscribe) => Effect.sync(unsubscribe),
|
Effect.forkScoped,
|
||||||
)
|
)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
@ -95,11 +186,38 @@ export namespace Vcs {
|
||||||
branch: Effect.fn("Vcs.branch")(function* () {
|
branch: Effect.fn("Vcs.branch")(function* () {
|
||||||
return yield* InstanceState.use(state, (x) => x.current)
|
return yield* InstanceState.use(state, (x) => x.current)
|
||||||
}),
|
}),
|
||||||
|
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||||
|
}),
|
||||||
|
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||||
|
const value = yield* InstanceState.get(state)
|
||||||
|
if (Instance.project.vcs !== "git") return []
|
||||||
|
if (mode === "git") {
|
||||||
|
return yield* track(
|
||||||
|
fs,
|
||||||
|
git,
|
||||||
|
Instance.directory,
|
||||||
|
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.root) return []
|
||||||
|
if (value.current && value.current === value.root.name) return []
|
||||||
|
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
||||||
|
if (!ref) return []
|
||||||
|
return yield* compare(fs, git, Instance.directory, ref)
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Bus.layer),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromise((svc) => svc.init())
|
return runPromise((svc) => svc.init())
|
||||||
|
|
@ -108,4 +226,12 @@ export namespace Vcs {
|
||||||
export function branch() {
|
export function branch() {
|
||||||
return runPromise((svc) => svc.branch())
|
return runPromise((svc) => svc.branch())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function defaultBranch() {
|
||||||
|
return runPromise((svc) => svc.defaultBranch())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diff(mode: Mode) {
|
||||||
|
return runPromise((svc) => svc.diff(mode))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import { ProviderID } from "./schema"
|
import { ProviderID } from "./schema"
|
||||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
|
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
|
||||||
|
|
@ -231,7 +231,7 @@ export namespace ProviderAuth {
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function methods() {
|
export async function methods() {
|
||||||
return runPromise((svc) => svc.methods())
|
return runPromise((svc) => svc.methods())
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { type IPty } from "bun-pty"
|
import { type IPty } from "bun-pty"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
@ -361,7 +361,7 @@ export namespace Pty {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromise((svc) => svc.list())
|
return runPromise((svc) => svc.list())
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRunPromise } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
@ -197,7 +197,7 @@ export namespace Question {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const runPromise = makeRunPromise(Service, layer)
|
const { runPromise } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
export async function ask(input: {
|
export async function ask(input: {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
|
|
|
||||||
28
packages/opencode/src/server/projectors.ts
Normal file
28
packages/opencode/src/server/projectors.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import z from "zod"
|
||||||
|
import sessionProjectors from "../session/projectors"
|
||||||
|
import { SyncEvent } from "@/sync"
|
||||||
|
import { Session } from "@/session"
|
||||||
|
import { SessionTable } from "@/session/session.sql"
|
||||||
|
import { Database, eq } from "@/storage/db"
|
||||||
|
|
||||||
|
export function initProjectors() {
|
||||||
|
SyncEvent.init({
|
||||||
|
projectors: sessionProjectors,
|
||||||
|
convertEvent: (type, data) => {
|
||||||
|
if (type === "session.updated") {
|
||||||
|
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
|
||||||
|
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||||
|
|
||||||
|
if (!row) return data
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionID: id,
|
||||||
|
info: Session.fromRow(row),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
initProjectors()
|
||||||
|
|
@ -6,7 +6,6 @@ import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
import { AsyncQueue } from "../../util/queue"
|
import { AsyncQueue } from "../../util/queue"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
|
|
@ -53,13 +52,6 @@ export const EventRoutes = lazy(() =>
|
||||||
)
|
)
|
||||||
}, 10_000)
|
}, 10_000)
|
||||||
|
|
||||||
const unsub = Bus.subscribeAll((event) => {
|
|
||||||
q.push(JSON.stringify(event))
|
|
||||||
if (event.type === Bus.InstanceDisposed.type) {
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
|
|
@ -69,6 +61,13 @@ export const EventRoutes = lazy(() =>
|
||||||
log.info("event disconnected")
|
log.info("event disconnected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unsub = Bus.subscribeAll((event) => {
|
||||||
|
q.push(JSON.stringify(event))
|
||||||
|
if (event.type === Bus.InstanceDisposed.type) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
stream.onAbort(stop)
|
stream.onAbort(stop)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Hono } from "hono"
|
import { Hono, type Context } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import { streamSSE } from "hono/streaming"
|
import { streamSSE } from "hono/streaming"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Bus } from "../../bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { SyncEvent } from "@/sync"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { AsyncQueue } from "@/util/queue"
|
import { AsyncQueue } from "@/util/queue"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
|
|
@ -17,6 +17,56 @@ const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
|
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
|
||||||
|
|
||||||
|
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
const q = new AsyncQueue<string | null>()
|
||||||
|
let done = false
|
||||||
|
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
|
payload: {
|
||||||
|
type: "server.connected",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
|
payload: {
|
||||||
|
type: "server.heartbeat",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
unsub()
|
||||||
|
q.push(null)
|
||||||
|
log.info("global event disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsub = subscribe(q)
|
||||||
|
|
||||||
|
stream.onAbort(stop)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const data of q) {
|
||||||
|
if (data === null) return
|
||||||
|
await stream.writeSSE({ data })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const GlobalRoutes = lazy(() =>
|
export const GlobalRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
.get(
|
.get(
|
||||||
|
|
@ -70,55 +120,58 @@ export const GlobalRoutes = lazy(() =>
|
||||||
log.info("global event connected")
|
log.info("global event connected")
|
||||||
c.header("X-Accel-Buffering", "no")
|
c.header("X-Accel-Buffering", "no")
|
||||||
c.header("X-Content-Type-Options", "nosniff")
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
return streamSSE(c, async (stream) => {
|
|
||||||
const q = new AsyncQueue<string | null>()
|
|
||||||
let done = false
|
|
||||||
|
|
||||||
q.push(
|
|
||||||
JSON.stringify({
|
|
||||||
payload: {
|
|
||||||
type: "server.connected",
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
q.push(
|
|
||||||
JSON.stringify({
|
|
||||||
payload: {
|
|
||||||
type: "server.heartbeat",
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, 10_000)
|
|
||||||
|
|
||||||
|
return streamEvents(c, (q) => {
|
||||||
async function handler(event: any) {
|
async function handler(event: any) {
|
||||||
q.push(JSON.stringify(event))
|
q.push(JSON.stringify(event))
|
||||||
}
|
}
|
||||||
GlobalBus.on("event", handler)
|
GlobalBus.on("event", handler)
|
||||||
|
return () => GlobalBus.off("event", handler)
|
||||||
const stop = () => {
|
})
|
||||||
if (done) return
|
},
|
||||||
done = true
|
)
|
||||||
clearInterval(heartbeat)
|
.get(
|
||||||
GlobalBus.off("event", handler)
|
"/sync-event",
|
||||||
q.push(null)
|
describeRoute({
|
||||||
log.info("event disconnected")
|
summary: "Subscribe to global sync events",
|
||||||
}
|
description: "Get global sync events",
|
||||||
|
operationId: "global.sync-event.subscribe",
|
||||||
stream.onAbort(stop)
|
responses: {
|
||||||
|
200: {
|
||||||
try {
|
description: "Event stream",
|
||||||
for await (const data of q) {
|
content: {
|
||||||
if (data === null) return
|
"text/event-stream": {
|
||||||
await stream.writeSSE({ data })
|
schema: resolver(
|
||||||
}
|
z
|
||||||
} finally {
|
.object({
|
||||||
stop()
|
payload: SyncEvent.payloads(),
|
||||||
}
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "SyncEvent",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
log.info("global sync event connected")
|
||||||
|
c.header("X-Accel-Buffering", "no")
|
||||||
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
|
return streamEvents(c, (q) => {
|
||||||
|
return SyncEvent.subscribeAll(({ def, event }) => {
|
||||||
|
// TODO: don't pass def, just pass the type (and it should
|
||||||
|
// be versioned)
|
||||||
|
q.push(
|
||||||
|
JSON.stringify({
|
||||||
|
payload: {
|
||||||
|
...event,
|
||||||
|
type: SyncEvent.versionedType(def.type, def.version),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -281,14 +281,14 @@ export const SessionRoutes = lazy(() =>
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const updates = c.req.valid("json")
|
const updates = c.req.valid("json")
|
||||||
|
|
||||||
let session = await Session.get(sessionID)
|
|
||||||
if (updates.title !== undefined) {
|
if (updates.title !== undefined) {
|
||||||
session = await Session.setTitle({ sessionID, title: updates.title })
|
await Session.setTitle({ sessionID, title: updates.title })
|
||||||
}
|
}
|
||||||
if (updates.time?.archived !== undefined) {
|
if (updates.time?.archived !== undefined) {
|
||||||
session = await Session.setArchived({ sessionID, time: updates.time.archived })
|
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await Session.get(sessionID)
|
||||||
return c.json(session)
|
return c.json(session)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
@ -38,15 +39,22 @@ import { websocket } from "hono/bun"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import { errors } from "./error"
|
import { errors } from "./error"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
import { QuestionRoutes } from "./routes/question"
|
import { QuestionRoutes } from "./routes/question"
|
||||||
import { PermissionRoutes } from "./routes/permission"
|
import { PermissionRoutes } from "./routes/permission"
|
||||||
import { GlobalRoutes } from "./routes/global"
|
import { GlobalRoutes } from "./routes/global"
|
||||||
import { MDNS } from "./mdns"
|
import { MDNS } from "./mdns"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
|
import { initProjectors } from "./projectors"
|
||||||
|
|
||||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
|
|
||||||
|
const csp = (hash = "") =>
|
||||||
|
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||||
|
|
||||||
|
initProjectors()
|
||||||
|
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
const log = Log.create({ service: "server" })
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
|
|
@ -330,12 +338,40 @@ export namespace Server {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const branch = await Vcs.branch()
|
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||||
return c.json({
|
return c.json({
|
||||||
branch,
|
branch,
|
||||||
|
default_branch,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.get(
|
||||||
|
"/vcs/diff",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Get VCS diff",
|
||||||
|
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||||
|
operationId: "vcs.diff",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "VCS diff",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(Snapshot.FileDiff.array()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
mode: Vcs.Mode,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
||||||
|
},
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"/command",
|
"/command",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|
@ -506,10 +542,13 @@ export namespace Server {
|
||||||
host: "app.opencode.ai",
|
host: "app.opencode.ai",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
response.headers.set(
|
const match = response.headers.get("content-type")?.includes("text/html")
|
||||||
"Content-Security-Policy",
|
? (await response.clone().text()).match(
|
||||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
|
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||||
)
|
)
|
||||||
|
: undefined
|
||||||
|
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||||
|
response.headers.set("Content-Security-Policy", csp(hash))
|
||||||
return response
|
return response
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { fn } from "@/util/fn"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { NotFoundError } from "@/storage/db"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
|
||||||
|
|
@ -60,7 +61,11 @@ export namespace SessionCompaction {
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
if (config.compaction?.prune === false) return
|
if (config.compaction?.prune === false) return
|
||||||
log.info("pruning")
|
log.info("pruning")
|
||||||
const msgs = await Session.messages({ sessionID: input.sessionID })
|
const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
|
||||||
|
if (NotFoundError.isInstance(err)) return undefined
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
if (!msgs) return
|
||||||
let total = 0
|
let total = 0
|
||||||
let pruned = 0
|
let pruned = 0
|
||||||
const toPrune = []
|
const toPrune = []
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue