mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20a953b29c | |||
| d698746951 | |||
| 72d37cda81 | |||
| ae14e78ba6 | |||
| 9ad482aab9 | |||
| fec4f20736 | |||
| 49d07ffe5f | |||
| 28f6968dda | |||
| 979ac810af | |||
| 71caa7d406 | |||
| 82b96eb495 | |||
| 7909817fba | |||
| 23b7632743 | |||
| de28ca9f0e | |||
| 9d9e57941c | |||
| 0e28d651a5 | |||
| a5b6ead43e | |||
| 7a73aad0a2 | |||
| 304c85fb2b | |||
| 85988b957b | |||
| 146aeef830 | |||
| 28b630477d | |||
| 5cd7705036 | |||
| 5816fcbc61 | |||
| 655b3c4c22 | |||
| 8023ba378b | |||
| 1bd1eb0e0b | |||
| ad8ed9b367 | |||
| 97a6bc19e7 | |||
| e8101ef7fe | |||
| 8f3e86cf4e | |||
| d9700c4cd5 | |||
| 95be07463b | |||
| 40cd6989d2 |
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": minor
|
||||||
|
"@opencode-ai/schema": minor
|
||||||
|
"@opencode-ai/protocol": minor
|
||||||
|
"@opencode-ai/client": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
|
||||||
@@ -76,6 +76,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
- name: Verify PowerShell 7 scanner conformance
|
||||||
|
if: always() && runner.os == 'Windows'
|
||||||
|
working-directory: packages/shell-scan
|
||||||
|
run: PWSH=pwsh bun run research:powershell
|
||||||
|
|
||||||
|
- name: Verify Windows PowerShell scanner conformance
|
||||||
|
if: always() && runner.os == 'Windows'
|
||||||
|
working-directory: packages/shell-scan
|
||||||
|
run: PWSH=powershell.exe bun run research:powershell
|
||||||
|
|
||||||
- name: Verify compiled service lifecycle
|
- name: Verify compiled service lifecycle
|
||||||
if: always()
|
if: always()
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# he Glossary
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Hebrew Academy approved IT terminology: https://terms.hebrew-academy.org.il/Millonim/ShowMillon?KodMillon=192
|
||||||
|
- Firefox Hebrew localization corpus: https://github.com/mozilla-l10n/firefox-l10n/tree/main/he
|
||||||
|
- KDE Hebrew localization team and corpus: https://l10n.kde.org/team-infos.php?teamcode=he
|
||||||
|
- Community-maintained VS Code Hebrew language pack: https://github.com/AMAARETS/vscode-language-pack-he
|
||||||
|
- Microsoft Hebrew developer documentation for Git terminology: https://learn.microsoft.com/he-il/power-platform/alm/tutorials/github-actions-deploy
|
||||||
|
- W3C guidance for bidirectional text: https://www.w3.org/International/articles/strings-and-bidi/
|
||||||
|
|
||||||
|
## Do Not Translate (Locale Additions)
|
||||||
|
|
||||||
|
- `OpenCode` (preserve casing in prose and UI copy)
|
||||||
|
- `API`, `MCP`, `LSP`, `OAuth`, `Git`, model names, and provider names
|
||||||
|
- Commands, flags, keyboard shortcuts, file paths, URLs, identifiers, hashes, and code literals
|
||||||
|
- Keep `commit` and `diff` when they name the exact Git artifact or operation
|
||||||
|
|
||||||
|
## Preferred Terms
|
||||||
|
|
||||||
|
| English / Context | Preferred | Notes |
|
||||||
|
| ----------------- | ------------- | ------------------------------------------------------------------------- |
|
||||||
|
| session | `הפעלה` | Use `שיחה` only when the source specifically means a chat or conversation |
|
||||||
|
| workspace | `סביבת עבודה` | |
|
||||||
|
| terminal | `מסוף` | Prefer the established Hebrew term over transliteration |
|
||||||
|
| command | `פקודה` | |
|
||||||
|
| provider | `ספק` | Use `ספק מודלים` where the bare noun is ambiguous |
|
||||||
|
| model | `מודל` | |
|
||||||
|
| API key | `מפתח API` | Keep the acronym in Latin letters |
|
||||||
|
| plugin | `תוסף` | |
|
||||||
|
| repository | `מאגר` | Use `מאגר Git` where context is ambiguous |
|
||||||
|
| branch | `ענף` | |
|
||||||
|
| context | `הקשר` | Use `חלון הקשר` for context window |
|
||||||
|
| tokens | `אסימונים` | |
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
- Prefer natural modern Israeli Hebrew over word-for-word translation or obscure coined terms.
|
||||||
|
- Use short action verbs for controls and translate complete phrases in context.
|
||||||
|
- Keep recognized developer acronyms and exact Git vocabulary in Latin script instead of phonetic transliteration.
|
||||||
|
- Treat embedded code, paths, commands, shortcuts, hashes, model IDs, and other Latin technical artifacts as LTR content inside the RTL interface.
|
||||||
|
- Keep recurring concepts consistent and do not collapse session, chat, run, and launch into one Hebrew term.
|
||||||
|
|
||||||
|
## Avoid
|
||||||
|
|
||||||
|
- Avoid transliterations such as `טרמינל`, `פלאגין`, and `קומנד` when `מסוף`, `תוסף`, and `פקודה` are clear.
|
||||||
|
- Avoid translating `commit` as `התחייבות`.
|
||||||
|
- Avoid inventing Hebrew expansions for `API`, `MCP`, or `LSP`.
|
||||||
@@ -70,19 +70,13 @@
|
|||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@pierre/trees": "1.0.0-beta.4",
|
"@pierre/trees": "1.0.0-beta.4",
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
|
||||||
"@solid-primitives/audio": "1.4.2",
|
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/event-listener": "2.4.5",
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.5",
|
"@solid-primitives/resize-observer": "2.1.5",
|
||||||
"@solid-primitives/scheduled": "1.5.3",
|
"@solid-primitives/scheduled": "1.5.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solid-primitives/timer": "1.4.4",
|
|
||||||
"@solid-primitives/websocket": "1.3.1",
|
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
@@ -145,10 +139,7 @@
|
|||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"tree-sitter-bash": "0.25.0",
|
|
||||||
"tree-sitter-powershell": "0.25.10",
|
|
||||||
"uqr": "0.1.3",
|
"uqr": "0.1.3",
|
||||||
"web-tree-sitter": "0.25.10",
|
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -391,10 +382,7 @@
|
|||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"tree-sitter-bash": "0.25.0",
|
|
||||||
"tree-sitter-powershell": "0.25.10",
|
|
||||||
"venice-ai-sdk-provider": "2.1.1",
|
"venice-ai-sdk-provider": "2.1.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
|
||||||
"which": "6.0.1",
|
"which": "6.0.1",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -402,6 +390,7 @@
|
|||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@effect/sql-sqlite-bun": "catalog:",
|
"@effect/sql-sqlite-bun": "catalog:",
|
||||||
"@opencode-ai/http-recorder": "workspace:*",
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
|
"@opencode-ai/shell-scan": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
@@ -741,8 +730,6 @@
|
|||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/stream": "catalog:",
|
"@shikijs/stream": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
|
||||||
"@solid-primitives/event-listener": "2.4.5",
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
@@ -770,6 +757,15 @@
|
|||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/shell-scan": {
|
||||||
|
"name": "@opencode-ai/shell-scan",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/simulation": {
|
"packages/simulation": {
|
||||||
"name": "@opencode-ai/simulation",
|
"name": "@opencode-ai/simulation",
|
||||||
"version": "1.17.13",
|
"version": "1.17.13",
|
||||||
@@ -956,8 +952,6 @@
|
|||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/stream": "catalog:",
|
"@shikijs/stream": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
|
||||||
"@solid-primitives/event-listener": "2.4.5",
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
@@ -970,8 +964,6 @@
|
|||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
"morphdom": "2.7.8",
|
"morphdom": "2.7.8",
|
||||||
"motion": "12.34.5",
|
"motion": "12.34.5",
|
||||||
"motion-dom": "12.34.3",
|
|
||||||
"motion-utils": "12.29.2",
|
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"remend": "catalog:",
|
"remend": "catalog:",
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
@@ -1034,7 +1026,6 @@
|
|||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"npm-package-arg": "13.0.2",
|
"npm-package-arg": "13.0.2",
|
||||||
"resolve.exports": "catalog:",
|
"resolve.exports": "catalog:",
|
||||||
"xdg-basedir": "5.1.0",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
@@ -1100,7 +1091,6 @@
|
|||||||
"unenv": "2.0.0-rc.24",
|
"unenv": "2.0.0-rc.24",
|
||||||
"vitest": "3.2.7",
|
"vitest": "3.2.7",
|
||||||
"wrangler": "4.28.0",
|
"wrangler": "4.28.0",
|
||||||
"xdg-basedir": "5.1.0",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/www": {
|
"packages/www": {
|
||||||
@@ -1120,12 +1110,9 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
|
||||||
"tree-sitter-powershell",
|
|
||||||
"protobufjs",
|
|
||||||
"electron",
|
"electron",
|
||||||
"web-tree-sitter",
|
"esbuild",
|
||||||
"tree-sitter-bash",
|
"protobufjs",
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
@@ -2156,6 +2143,8 @@
|
|||||||
|
|
||||||
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
|
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
|
||||||
|
|
||||||
|
"@opencode-ai/shell-scan": ["@opencode-ai/shell-scan@workspace:packages/shell-scan"],
|
||||||
|
|
||||||
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
|
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
|
||||||
|
|
||||||
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||||
@@ -2742,7 +2731,7 @@
|
|||||||
|
|
||||||
"@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="],
|
"@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="],
|
||||||
|
|
||||||
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
"@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||||
|
|
||||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
|
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
|
||||||
|
|
||||||
@@ -2756,11 +2745,11 @@
|
|||||||
|
|
||||||
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
|
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
|
||||||
|
|
||||||
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
|
"@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||||
|
|
||||||
"@shikijs/twoslash": ["@shikijs/twoslash@4.4.2", "", { "dependencies": { "@shikijs/core": "4.4.2", "@shikijs/types": "4.4.2", "twoslash": "^0.3.9" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-JjPKNbYCZn5+DVOxDZJ0ZruFPBM7m2JQBp4WbLh4tU2y3ed93wBCaQhKwMyfTFIsDzHks22szAkUUd2+jIPo/w=="],
|
"@shikijs/twoslash": ["@shikijs/twoslash@4.4.2", "", { "dependencies": { "@shikijs/core": "4.4.2", "@shikijs/types": "4.4.2", "twoslash": "^0.3.9" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-JjPKNbYCZn5+DVOxDZJ0ZruFPBM7m2JQBp4WbLh4tU2y3ed93wBCaQhKwMyfTFIsDzHks22szAkUUd2+jIPo/w=="],
|
||||||
|
|
||||||
"@shikijs/types": ["@shikijs/types@3.9.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw=="],
|
"@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||||
|
|
||||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||||
|
|
||||||
@@ -2878,12 +2867,6 @@
|
|||||||
|
|
||||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||||
|
|
||||||
"@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
|
|
||||||
|
|
||||||
"@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
|
|
||||||
|
|
||||||
"@solid-primitives/bounds": ["@solid-primitives/bounds@0.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/resize-observer": "^2.1.3", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q=="],
|
|
||||||
|
|
||||||
"@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
|
"@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
|
||||||
|
|
||||||
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="],
|
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="],
|
||||||
@@ -2908,20 +2891,14 @@
|
|||||||
|
|
||||||
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="],
|
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="],
|
||||||
|
|
||||||
"@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="],
|
|
||||||
|
|
||||||
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA=="],
|
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LgtVaVBtB7EbmS4+M0b8xY5Iq6pUWXBsIC4VgtrFKDGDdyCaDt88sHk0fUlx1Enxm/XZnZyLXJABRoa39RjJqA=="],
|
||||||
|
|
||||||
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12", "solid-start": "*" }, "optionalPeers": ["@tauri-apps/plugin-store", "solid-start"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
|
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12", "solid-start": "*" }, "optionalPeers": ["@tauri-apps/plugin-store", "solid-start"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
|
||||||
|
|
||||||
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
|
|
||||||
|
|
||||||
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ju0e+ZOD7hpOp7nptJimvDSZHWFvIvF9iBWMvuwt30smX7c5wmB8Kmc0AdDuv0wOTi06PQ1J5IrP1WJbH2yUBQ=="],
|
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.4", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ju0e+ZOD7hpOp7nptJimvDSZHWFvIvF9iBWMvuwt30smX7c5wmB8Kmc0AdDuv0wOTi06PQ1J5IrP1WJbH2yUBQ=="],
|
||||||
|
|
||||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg=="],
|
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ISSB5QX1qP2ynrheIpYwc4oKR5Ny4siNuUyf1qZniy+Il+p/PtDB0QK1Dnle8noiHpwRD3gpPdubOC3qI/Zamg=="],
|
||||||
|
|
||||||
"@solid-primitives/websocket": ["@solid-primitives/websocket@1.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-F06tA2FKa5VsnS4E4WEc3jHpsJfXRlMTGOtolugTzCqV3JmJTyvk9UVg1oz6PgGHKGi1CQ91OP8iW34myyJgaQ=="],
|
|
||||||
|
|
||||||
"@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="],
|
"@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="],
|
||||||
|
|
||||||
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
|
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
|
||||||
@@ -4916,9 +4893,9 @@
|
|||||||
|
|
||||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||||
|
|
||||||
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
|
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||||
|
|
||||||
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||||
|
|
||||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
|
|
||||||
@@ -5738,10 +5715,6 @@
|
|||||||
|
|
||||||
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||||
|
|
||||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
|
||||||
|
|
||||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
|
||||||
|
|
||||||
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
|
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
|
||||||
|
|
||||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||||
@@ -6000,8 +5973,6 @@
|
|||||||
|
|
||||||
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
|
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
|
||||||
|
|
||||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
|
||||||
|
|
||||||
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
|
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
|
||||||
|
|
||||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||||
@@ -6494,8 +6465,6 @@
|
|||||||
|
|
||||||
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||||
|
|
||||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
|
||||||
|
|
||||||
"@opencode-ai/workerd-spike/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="],
|
"@opencode-ai/workerd-spike/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="],
|
||||||
|
|
||||||
"@opencode-ai/workerd-spike/wrangler": ["wrangler@4.28.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.6.0", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250803.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.19", "workerd": "1.20250803.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250803.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-y0yHIuScpok9oSErLqDbxkBChC2+/jZpvqMg2NxOto1JCyUtDUuKljOfcVMaI48d9GuhOCSoWSumYxLAHNxaLA=="],
|
"@opencode-ai/workerd-spike/wrangler": ["wrangler@4.28.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.6.0", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250803.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.19", "workerd": "1.20250803.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250803.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-y0yHIuScpok9oSErLqDbxkBChC2+/jZpvqMg2NxOto1JCyUtDUuKljOfcVMaI48d9GuhOCSoWSumYxLAHNxaLA=="],
|
||||||
@@ -6520,8 +6489,6 @@
|
|||||||
|
|
||||||
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||||
|
|
||||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
|
||||||
|
|
||||||
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
"@pierre/diffs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
"@pierre/diffs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||||
@@ -6582,17 +6549,9 @@
|
|||||||
|
|
||||||
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||||
|
|
||||||
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||||
|
|
||||||
"@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
|
||||||
|
|
||||||
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
|
||||||
|
|
||||||
"@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
|
||||||
|
|
||||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
|
||||||
|
|
||||||
"@shikijs/twoslash/@shikijs/core": ["@shikijs/core@4.4.2", "", { "dependencies": { "@shikijs/primitive": "4.4.2", "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA=="],
|
"@shikijs/twoslash/@shikijs/core": ["@shikijs/core@4.4.2", "", { "dependencies": { "@shikijs/primitive": "4.4.2", "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA=="],
|
||||||
|
|
||||||
@@ -6988,10 +6947,6 @@
|
|||||||
|
|
||||||
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||||
|
|
||||||
"shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
|
||||||
|
|
||||||
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
|
||||||
|
|
||||||
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||||
@@ -7034,8 +6989,6 @@
|
|||||||
|
|
||||||
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||||
|
|
||||||
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="],
|
|
||||||
|
|
||||||
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||||
|
|
||||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||||
@@ -7660,10 +7613,6 @@
|
|||||||
|
|
||||||
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||||
|
|
||||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
|
||||||
|
|
||||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
|
||||||
|
|
||||||
"@opencode-ai/workerd-spike/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
"@opencode-ai/workerd-spike/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||||
|
|
||||||
"@opencode-ai/workerd-spike/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
"@opencode-ai/workerd-spike/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||||
@@ -7742,10 +7691,6 @@
|
|||||||
|
|
||||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||||
|
|
||||||
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
|
||||||
|
|
||||||
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
|
||||||
|
|
||||||
"@radix-ui/react-arrow/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
"@radix-ui/react-arrow/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-collapsible/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
"@radix-ui/react-collapsible/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||||
@@ -7786,8 +7731,6 @@
|
|||||||
|
|
||||||
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
|
||||||
|
|
||||||
"@shikijs/twoslash/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA=="],
|
"@shikijs/twoslash/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA=="],
|
||||||
|
|
||||||
"@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
"@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||||
@@ -8040,10 +7983,6 @@
|
|||||||
|
|
||||||
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||||
|
|
||||||
"motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
|
||||||
|
|
||||||
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
|
||||||
|
|
||||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|||||||
@@ -145,10 +145,6 @@
|
|||||||
"esbuild",
|
"esbuild",
|
||||||
"node-pty",
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
|
||||||
"tree-sitter-bash",
|
|
||||||
"tree-sitter-powershell",
|
|
||||||
"web-tree-sitter",
|
|
||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||||
|
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||||
|
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
|
||||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||||
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
||||||
|
|||||||
@@ -95,22 +95,22 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||||
await contextButton.click()
|
await contextButton.click()
|
||||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
await expect(sidebar).toBeVisible()
|
await expect(sidebar).toBeVisible()
|
||||||
await contextButton.click()
|
await contextButton.click()
|
||||||
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
|
||||||
await expect(sidebar).toBeHidden()
|
await expect(sidebar).toBeHidden()
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||||
await expect(filter).toBeFocused()
|
await expect(filter).toBeFocused()
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
|
||||||
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "README.md" }).click()
|
await panel.getByRole("button", { name: "README.md" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||||
await expect(sidebar).toHaveCount(0)
|
await expect(sidebar).toHaveCount(0)
|
||||||
@@ -125,21 +125,21 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
expect(resultID).toBeTruthy()
|
expect(resultID).toBeTruthy()
|
||||||
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||||
await filter.press("Enter")
|
await filter.press("Enter")
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||||
|
|
||||||
await panel.getByRole("button", { name: "Open file" }).click()
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "Open file", selected: true })).toBeVisible()
|
||||||
await expect(sidebarToggle).toBeDisabled()
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
await panel.locator("#session-side-panel-review-tab").click()
|
await panel.locator("#session-side-panel-review-tab").click()
|
||||||
await expect(sidebarToggle).toBeEnabled()
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||||
await page.keyboard.press("Control+w")
|
await page.keyboard.press("Control+w")
|
||||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
function fileNode(path: string) {
|
function fileNode(path: string) {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["git status", "git diff"],
|
patterns: ["git status", "git diff"],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: [],
|
always: ["git *"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,30 @@ test("navigates to a subagent child session missing from the session list", asyn
|
|||||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
|
||||||
|
await setup(page)
|
||||||
|
const requested = Promise.withResolvers<void>()
|
||||||
|
const release = Promise.withResolvers<void>()
|
||||||
|
await page.route(
|
||||||
|
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||||
|
async (route) => {
|
||||||
|
requested.resolve()
|
||||||
|
await release.promise
|
||||||
|
await route.fallback()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await page.goto(sessionHref(parentID))
|
||||||
|
await expectSessionTitle(page, parentTitle)
|
||||||
|
|
||||||
|
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||||
|
await requested.promise
|
||||||
|
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
||||||
|
() => release.resolve(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expectSessionTitle(page, taskDescription)
|
||||||
|
})
|
||||||
|
|
||||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||||
const events: EventPayload[] = []
|
const events: EventPayload[] = []
|
||||||
await setup(page, () => events.splice(0, 1))
|
await setup(page, () => events.splice(0, 1))
|
||||||
|
|||||||
@@ -267,16 +267,34 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
}
|
}
|
||||||
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
if (path === "/api/project") return json(route, [config.project])
|
if (path === "/api/project") {
|
||||||
|
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
|
||||||
|
return json(route, [
|
||||||
|
{
|
||||||
|
...project,
|
||||||
|
canonical: project.canonical ?? project.worktree ?? config.directory,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
if (path === "/api/project/current")
|
if (path === "/api/project/current")
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||||
|
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1]
|
||||||
|
if (worktree && route.request().method() === "GET")
|
||||||
|
return json(route, [
|
||||||
|
{ directory: config.directory },
|
||||||
|
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||||
|
directory,
|
||||||
|
strategy: "git",
|
||||||
|
})),
|
||||||
|
])
|
||||||
if (path === "/api/location") return json(route, location(config))
|
if (path === "/api/location") return json(route, location(config))
|
||||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
if (worktree && route.request().method() === "POST") {
|
||||||
if (projectCopy && route.request().method() === "POST") {
|
|
||||||
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
||||||
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
||||||
}
|
}
|
||||||
if (projectCopy && route.request().method() === "DELETE")
|
if (worktree && route.request().method() === "DELETE")
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path))
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
if (path === "/api/permission/request")
|
if (path === "/api/permission/request")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
@@ -343,7 +361,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||||
const sessions = config.sessions
|
const sessions = config.sessions
|
||||||
.filter((session) => !directory || session.directory === directory)
|
.filter((session) => {
|
||||||
|
const location = session.location as { directory?: string } | undefined
|
||||||
|
return !directory || location?.directory === directory || session.directory === directory
|
||||||
|
})
|
||||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||||
.filter((session) => {
|
.filter((session) => {
|
||||||
const search = url.searchParams.get("search")?.toLowerCase()
|
const search = url.searchParams.get("search")?.toLowerCase()
|
||||||
@@ -578,6 +599,7 @@ function currentPermission(value: unknown) {
|
|||||||
|
|
||||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||||
|
const location = session.location && typeof session.location === "object" ? session.location : {}
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
parentID: session.parentID,
|
parentID: session.parentID,
|
||||||
@@ -595,10 +617,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
|||||||
},
|
},
|
||||||
title: session.title ?? session.id,
|
title: session.title ?? session.id,
|
||||||
location: {
|
location: {
|
||||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
directory:
|
||||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
"directory" in location && typeof location.directory === "string"
|
||||||
|
? location.directory
|
||||||
|
: typeof session.directory === "string"
|
||||||
|
? session.directory
|
||||||
|
: fallbackDirectory,
|
||||||
|
...(typeof session.workspaceID === "string"
|
||||||
|
? { workspaceID: session.workspaceID }
|
||||||
|
: "workspaceID" in location && typeof location.workspaceID === "string"
|
||||||
|
? { workspaceID: location.workspaceID }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
subpath: session.path,
|
subpath: session.subpath ?? session.path,
|
||||||
revert: session.revert,
|
revert: session.revert,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,19 +63,13 @@
|
|||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@pierre/trees": "1.0.0-beta.4",
|
"@pierre/trees": "1.0.0-beta.4",
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
|
||||||
"@solid-primitives/audio": "1.4.2",
|
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/event-listener": "2.4.5",
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.5",
|
"@solid-primitives/resize-observer": "2.1.5",
|
||||||
"@solid-primitives/scheduled": "1.5.3",
|
"@solid-primitives/scheduled": "1.5.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solid-primitives/timer": "1.4.4",
|
|
||||||
"@solid-primitives/websocket": "1.3.1",
|
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
|
|||||||
+74
-259
@@ -1,48 +1,38 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
|
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
createResource,
|
|
||||||
createSignal,
|
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
For,
|
|
||||||
type JSX,
|
type JSX,
|
||||||
lazy,
|
lazy,
|
||||||
onCleanup,
|
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
||||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, 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 { PermissionProvider } from "@/context/permission"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
@@ -50,26 +40,26 @@ import { WslServersProvider } from "@/wsl/context"
|
|||||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
|
||||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
import { TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { Home } from "@/pages/home"
|
import { Home } from "@/pages/home"
|
||||||
|
import { ServerProvider } from "./context/server"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const DirectoryDraftRedirect = () => {
|
const DirectoryDraftRedirect = () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
const server = useServer()
|
const server = useServers()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (search.draftId || !tabs.ready()) return
|
if (search.draftId || !tabs.ready()) return
|
||||||
const directory = decode64(params.dir)
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
tabs.newDraft({ server: server.key, directory }, search.prompt)
|
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -86,36 +76,13 @@ function TargetServerRoute(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn()}>
|
<Show when={conn()} keyed>
|
||||||
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider>
|
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
||||||
</ServerSDKProvider>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetSessionRoute() {
|
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
|
||||||
return (
|
|
||||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
|
||||||
<TargetServerRoute>
|
|
||||||
<TargetSessionRouteContent />
|
|
||||||
</TargetServerRoute>
|
|
||||||
</SessionRouteErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
|
||||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
|
||||||
return (
|
|
||||||
<ServerKey>
|
|
||||||
<ServerSDKProvider>
|
|
||||||
<ServerSyncProvider>{props.children}</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
|
||||||
</ServerKey>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
@@ -138,19 +105,21 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn()}>
|
<Show when={conn()} keyed>
|
||||||
<ServerSyncProvider server={conn()}>
|
{(conn) => (
|
||||||
<ModelsProvider directory={props.draft.directory}>
|
<ServerProvider conn={conn}>
|
||||||
<SDKProvider directory={props.draft.directory}>
|
<ModelsProvider directory={props.draft.directory}>
|
||||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
<SDKProvider directory={props.draft.directory}>
|
||||||
<DraftProviders>
|
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
||||||
<NewSession />
|
<DraftProviders>
|
||||||
</DraftProviders>
|
<NewSession draftId={props.draft.draftID} />
|
||||||
</DirectoryDataProvider>
|
</DraftProviders>
|
||||||
</SDKProvider>
|
</DirectoryDataProvider>
|
||||||
</ModelsProvider>
|
</SDKProvider>
|
||||||
</ServerSyncProvider>
|
</ModelsProvider>
|
||||||
</ServerSDKProvider>
|
</ServerProvider>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -159,7 +128,13 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
<I18nProvider
|
<I18nProvider
|
||||||
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
value={{
|
||||||
|
locale: language.intl,
|
||||||
|
layoutLocale: language.layoutLocale,
|
||||||
|
t: language.t as UiI18n["t"],
|
||||||
|
plural: language.plural,
|
||||||
|
pluralForm: language.pluralForm,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
@@ -183,8 +158,8 @@ function QueryProvider(props: ParentProps) {
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -204,18 +179,6 @@ function BodyDesignClass() {
|
|||||||
|
|
||||||
// Server-agnostic providers shared across every route. These live in the shared
|
// Server-agnostic providers shared across every route. These live in the shared
|
||||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||||
function SharedProviders(props: ParentProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BodyDesignClass />
|
|
||||||
<CommandProvider>
|
|
||||||
<DesktopCommands />
|
|
||||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
|
||||||
</CommandProvider>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopCommands() {
|
function DesktopCommands() {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -239,30 +202,14 @@ function DesktopCommands() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerScopedShellProps = ParentProps<{
|
function AppLayout(props: ParentProps) {
|
||||||
directory?: string
|
|
||||||
serverScoped?: JSX.Element
|
|
||||||
}>
|
|
||||||
|
|
||||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
|
||||||
return (
|
return (
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
{props.serverScoped}
|
<Layout>{props.children}</Layout>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|
||||||
return (
|
|
||||||
<SelectedServerProviders>
|
|
||||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
|
||||||
<Layout>{props.children}</Layout>
|
|
||||||
</ServerScopedProviders>
|
|
||||||
</SelectedServerProviders>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
function DraftProviders(props: ParentProps) {
|
function DraftProviders(props: ParentProps) {
|
||||||
@@ -312,133 +259,6 @@ export function AppBaseProviders(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
|
|
||||||
const server = useServer()
|
|
||||||
const checkServerHealth = useCheckServerHealth()
|
|
||||||
|
|
||||||
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
|
|
||||||
|
|
||||||
// performs repeated health check with a grace period for
|
|
||||||
// non-http connections, otherwise fails instantly
|
|
||||||
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
|
||||||
props.disableHealthCheck
|
|
||||||
? true
|
|
||||||
: Effect.gen(function* () {
|
|
||||||
if (!server.current) return true
|
|
||||||
const { http, type } = server.current
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const res = yield* Effect.promise(() => checkServerHealth(http))
|
|
||||||
if (res.healthy) return true
|
|
||||||
if (checkMode() === "background" || type === "http") return false
|
|
||||||
}
|
|
||||||
}).pipe(
|
|
||||||
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
|
||||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
|
||||||
Effect.runPromise,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const checking = createMemo(
|
|
||||||
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
|
|
||||||
)
|
|
||||||
const [startup] = createResource(async () => {
|
|
||||||
if (!props.startup) return true
|
|
||||||
await props.startup.catch((error) => {
|
|
||||||
console.error("[startup] startup gate failed", error)
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
const startupChecking = createMemo(
|
|
||||||
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
|
|
||||||
)
|
|
||||||
const loading = createMemo(() => checking() || startupChecking())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Show when={!checking()}>
|
|
||||||
<Show
|
|
||||||
when={startupHealthCheck.latest}
|
|
||||||
fallback={
|
|
||||||
<ConnectionError
|
|
||||||
onRetry={() => {
|
|
||||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
|
||||||
}}
|
|
||||||
onServerSelected={(key) => {
|
|
||||||
setCheckMode("blocking")
|
|
||||||
server.setActive(key)
|
|
||||||
void healthCheckActions.refetch()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
<Show when={loading()}>
|
|
||||||
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
|
|
||||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
|
|
||||||
const language = useLanguage()
|
|
||||||
const server = useServer()
|
|
||||||
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
|
|
||||||
const name = createMemo(() => server.name || server.key)
|
|
||||||
const serverToken = "\u0000server\u0000"
|
|
||||||
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
|
|
||||||
|
|
||||||
const timer = setInterval(() => props.onRetry?.(), 1000)
|
|
||||||
onCleanup(() => clearInterval(timer))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
|
||||||
<div class="flex flex-col items-center max-w-md text-center">
|
|
||||||
<Splash class="w-12 h-15 mb-4" />
|
|
||||||
<p class="text-14-regular text-text-base">
|
|
||||||
{unreachable()[0]}
|
|
||||||
<span class="text-text-strong font-medium">{name()}</span>
|
|
||||||
{unreachable()[1]}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
|
|
||||||
</div>
|
|
||||||
<Show when={others().length > 0}>
|
|
||||||
<div class="flex flex-col gap-2 w-full max-w-sm">
|
|
||||||
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
|
|
||||||
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
|
||||||
<For each={others()}>
|
|
||||||
{(conn) => {
|
|
||||||
const key = ServerConnection.key(conn)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
|
||||||
onClick={() => props.onServerSelected?.(key)}
|
|
||||||
>
|
|
||||||
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServerKey(props: ParentProps) {
|
|
||||||
const server = useServer()
|
|
||||||
return (
|
|
||||||
<Show when={server.key} keyed>
|
|
||||||
{props.children}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppInterface(props: {
|
export function AppInterface(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
@@ -447,65 +267,60 @@ export function AppInterface(props: {
|
|||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
startup?: Promise<void>
|
startup?: Promise<void>
|
||||||
serverScoped?: JSX.Element
|
|
||||||
}) {
|
}) {
|
||||||
// The visual layout lives in the router root so it remains mounted across
|
// The visual layout lives in the router root so it remains mounted across
|
||||||
// route changes. Draft and session routes override only their server-bound data
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers beneath it.
|
// providers beneath it.
|
||||||
const ServerShell = (shellProps: ParentProps) => (
|
const Root = (rootProps: ParentProps) => (
|
||||||
<SharedProviders>
|
<TabsProvider>
|
||||||
{props.children}
|
<BodyDesignClass />
|
||||||
{shellProps.children}
|
<CommandProvider>
|
||||||
</SharedProviders>
|
<DesktopCommands />
|
||||||
|
<HighlightsProvider>
|
||||||
|
{props.children}
|
||||||
|
{rootProps.children}
|
||||||
|
</HighlightsProvider>
|
||||||
|
</CommandProvider>
|
||||||
|
</TabsProvider>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider
|
<ServersProvider
|
||||||
defaultServer={props.defaultServer}
|
defaultServer={props.defaultServer}
|
||||||
canonicalLocalServer={props.canonicalLocalServer}
|
canonicalLocalServer={props.canonicalLocalServer}
|
||||||
servers={props.servers}
|
servers={props.servers}
|
||||||
>
|
>
|
||||||
<GlobalProvider>
|
<SettingsProvider>
|
||||||
<SettingsProvider>
|
<GlobalProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
<Dynamic component={props.router ?? Router} root={Root}>
|
||||||
<Dynamic
|
{/* Proper Routes */}
|
||||||
component={props.router ?? Router}
|
<Route component={AppLayout}>
|
||||||
root={(routerProps) => (
|
<Route path="/" component={Home} />
|
||||||
<TabsProvider>
|
<Route
|
||||||
<PermissionProvider>
|
path="/server/:serverKey/session/:id"
|
||||||
<NotificationProvider>
|
component={() => (
|
||||||
<ServerShell>
|
<TargetServerRoute>
|
||||||
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
|
<TargetSessionRouteContent />
|
||||||
</ServerShell>
|
</TargetServerRoute>
|
||||||
</NotificationProvider>
|
)}
|
||||||
</PermissionProvider>
|
/>
|
||||||
</TabsProvider>
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
)}
|
</Route>
|
||||||
>
|
{/* Legacy Routes */}
|
||||||
<Routes />
|
<Route>
|
||||||
</Dynamic>
|
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||||
</ConnectionGate>
|
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||||
</SettingsProvider>
|
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||||
</GlobalProvider>
|
</Route>
|
||||||
</ServerProvider>
|
</Dynamic>
|
||||||
)
|
</GlobalProvider>
|
||||||
}
|
</SettingsProvider>
|
||||||
|
</ServersProvider>
|
||||||
function Routes() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Route path="/" component={Home} />
|
|
||||||
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
|
||||||
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
|
||||||
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacySessionRedirect() {
|
function LegacySessionRedirect() {
|
||||||
const server = useServer()
|
const server = useServers()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
|
||||||
@@ -516,7 +331,7 @@ function LegacySessionRedirect() {
|
|||||||
legacySessionServer(
|
legacySessionServer(
|
||||||
tabs.store.filter((item) => item.type === "session"),
|
tabs.store.filter((item) => item.type === "session"),
|
||||||
params.id,
|
params.id,
|
||||||
server.key,
|
ServerConnection.key(server.list[0]!),
|
||||||
),
|
),
|
||||||
params.id,
|
params.id,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import { useFile } from "@/context/file"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout, type LocalProject } from "@/context/layout"
|
import { useLayout, type LocalProject } from "@/context/layout"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
export type CommandPaletteEntry = {
|
export type CommandPaletteEntry = {
|
||||||
id: string
|
id: string
|
||||||
@@ -83,7 +84,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSDK = useServerSDK()()
|
const serverSDK = useServerSDK()
|
||||||
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
||||||
const appTabs = useTabs()
|
const appTabs = useTabs()
|
||||||
const { tabs: sessionTabs } = useSessionLayout()
|
const { tabs: sessionTabs } = useSessionLayout()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
|
|||||||
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ function ProviderConnection(props: {
|
|||||||
id: props.provider,
|
id: props.provider,
|
||||||
name:
|
name:
|
||||||
providers.all().get(props.provider)?.name ??
|
providers.all().get(props.provider)?.name ??
|
||||||
serverSync().data.provider.all.get(props.provider)?.name ??
|
serverSync.data.provider.all.get(props.provider)?.name ??
|
||||||
controller.integration()?.name ??
|
controller.integration()?.name ??
|
||||||
props.provider,
|
props.provider,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
const output = validateCustomProvider({
|
const output = validateCustomProvider({
|
||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: serverSync().data.config.disabled_providers ?? [],
|
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
|
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setForm("err", output.err)
|
setForm("err", output.err)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|||||||
import { For, Show, createSignal, startTransition } from "solid-js"
|
import { For, Show, createSignal, startTransition } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||||
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { createEditProjectModel } from "./edit-project"
|
import { createEditProjectModel } from "./edit-project"
|
||||||
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import type { Path } from "@/types"
|
import type { Path } from "@/types"
|
||||||
import {
|
import {
|
||||||
absoluteTreePath,
|
absoluteTreePath,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { ListRef } from "@opencode-ai/ui/list"
|
|||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||||
import type { Path } from "@/types"
|
import type { Path } from "@/types"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { formatKeybind } from "@/context/command"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
@@ -29,14 +28,13 @@ type DialogSelectFileMode = "all" | "files"
|
|||||||
|
|
||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
|
|
||||||
if (!filesOnly() && settings.general.newLayoutDesigns()) {
|
if (!filesOnly()) {
|
||||||
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
|
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
if (filesOnly() && platform.platform === "desktop") {
|
||||||
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
|
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +50,7 @@ function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogSelectFileV2
|
<DialogSelectFileV2
|
||||||
server={serverSDK().server}
|
server={serverSDK.server}
|
||||||
mode="file"
|
mode="file"
|
||||||
start={projectDirectory()}
|
start={projectDirectory()}
|
||||||
title={language.t("session.header.searchFiles")}
|
title={language.t("session.header.searchFiles")}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { useSettings } from "@/context/settings"
|
import { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||||
import { type ServerDomainController } from "@/components/server/server-management-controller"
|
|
||||||
|
|
||||||
type ServerConnectionFormController = {
|
type ServerConnectionFormController = {
|
||||||
state: {
|
state: {
|
||||||
@@ -117,12 +113,11 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ServerConnectionList(props: {
|
export function ServerConnectionList(props: {
|
||||||
domain: ServerDomainController
|
domain: ServerCollectionController
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
onEdit: (server: ServerConnection.Http) => void
|
onEdit: (server: ServerConnection.Http) => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
@@ -136,9 +131,6 @@ export function ServerConnectionList(props: {
|
|||||||
emptyMessage={language.t("dialog.server.empty")}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
items={props.domain.collection.items}
|
items={props.domain.collection.items}
|
||||||
key={(x) => x.http.url}
|
key={(x) => x.http.url}
|
||||||
onSelect={(x) => {
|
|
||||||
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
|
||||||
}}
|
|
||||||
divider={true}
|
divider={true}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
@@ -163,15 +155,6 @@ export function ServerConnectionList(props: {
|
|||||||
showCredentials
|
showCredentials
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-center gap-4 pl-4">
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
props.domain.collection.current() &&
|
|
||||||
ServerConnection.key(props.domain.collection.current()!) === key
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="check" class="h-6" />
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={i.type === "http"}>
|
<Show when={i.type === "http"}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import type { Platform } from "@/context/platform"
|
import type { Platform } from "@/context/platform"
|
||||||
|
|
||||||
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { lazy } from "solid-js"
|
import { lazy } from "solid-js"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createMemo } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { type LocalProject } from "@/context/layout"
|
import { type LocalProject } from "@/context/layout"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
|
||||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
const supported = !props.project.id || props.project.id === "global"
|
const supported = !props.project.id || props.project.id === "global"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
{ initialValue: {} },
|
{ initialValue: {} },
|
||||||
)
|
)
|
||||||
const globalMcpNames = createMemo(() =>
|
const globalMcpNames = createMemo(() =>
|
||||||
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
||||||
)
|
)
|
||||||
const projectMcpNames = createMemo(() => {
|
const projectMcpNames = createMemo(() => {
|
||||||
const shared = new Set(globalMcpNames())
|
const shared = new Set(globalMcpNames())
|
||||||
@@ -102,7 +102,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
})
|
})
|
||||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
||||||
|
|
||||||
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
|
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
||||||
const projectPlugins = createMemo(() => {
|
const projectPlugins = createMemo(() => {
|
||||||
const shared = new Set(globalPlugins())
|
const shared = new Set(globalPlugins())
|
||||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
|
|||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
|
accentSubmit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
controller={props.controller}
|
controller={props.controller}
|
||||||
|
accentSubmit={props.accentSubmit}
|
||||||
borderUnderlay={props.borderUnderlay}
|
borderUnderlay={props.borderUnderlay}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
variantControlVisible={!props.controller.model.loading}
|
variantControlVisible={!props.controller.model.loading}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import { Select } from "@opencode-ai/ui/select"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -1518,6 +1517,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={placeholder()}
|
aria-label={placeholder()}
|
||||||
|
dir={store.mode === "normal" ? "auto" : "ltr"}
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
@@ -1539,7 +1539,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
style={{ "padding-bottom": space }}
|
style={{
|
||||||
|
"padding-bottom": space,
|
||||||
|
"unicode-bidi": store.mode === "normal" ? "plaintext" : undefined,
|
||||||
|
"text-align": "start",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||||
import type { ModelSelection } from "@/context/local"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const sessionCreateInputs: Array<{
|
type SessionCreateInput = {
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: { id: string; providerID: string; variant?: string }
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
location?: { directory: string }
|
location?: { directory: string }
|
||||||
}> = []
|
}
|
||||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -22,11 +20,9 @@ const optimistic: Array<{
|
|||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
}> = []
|
}> = []
|
||||||
const optimisticSeeded: boolean[] = []
|
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
|
||||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const sentShellDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
const sentPrompts: string[] = []
|
const sentPrompts: string[] = []
|
||||||
const promptInputs: unknown[] = []
|
const promptInputs: unknown[] = []
|
||||||
@@ -37,15 +33,29 @@ const switchedModels: Array<{
|
|||||||
model: { id: string; providerID: string; variant?: string }
|
model: { id: string; providerID: string; variant?: string }
|
||||||
}> = []
|
}> = []
|
||||||
const sessionRequestOrder: string[] = []
|
const sessionRequestOrder: string[] = []
|
||||||
const commands: Array<{ name: string }> = []
|
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||||
|
const syncedServers: string[] = []
|
||||||
|
const optimisticServers: string[] = []
|
||||||
|
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||||
let serverSessionSyncs = 0
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
let permissionServer = "server-a"
|
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
let createWorktreeGate: Promise<void> | undefined
|
||||||
|
let worktreeFailure: Error | undefined
|
||||||
|
let locationFailure: Error | undefined
|
||||||
|
let worktreeCreates = 0
|
||||||
|
let activeSDK = "server-a"
|
||||||
|
let activeServerSync = "server-a"
|
||||||
|
let activeDirectorySync = "server-a"
|
||||||
|
let commands: Array<{ name: string }> = []
|
||||||
|
let worktreeDirectory = "/repo/new-0"
|
||||||
|
let worktreeID = 0
|
||||||
|
const draftServers: Record<string, string> = {}
|
||||||
|
const sessionDirectories: Record<string, string> = {}
|
||||||
|
|
||||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
@@ -73,21 +83,25 @@ const prompt = {
|
|||||||
replaceComments: () => undefined,
|
replaceComments: () => undefined,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
},
|
},
|
||||||
capture: () => prompt,
|
capture: (scope?: unknown, target?: unknown) => {
|
||||||
|
promptCaptures.push({ scope, target })
|
||||||
|
return prompt
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
|
||||||
return {
|
return {
|
||||||
api: {
|
api: {
|
||||||
session: {
|
session: {
|
||||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
create: async (input: SessionCreateInput) => {
|
||||||
await createSessionGate
|
await createSessionGate
|
||||||
const location = input.location?.directory ?? directory
|
const location = input.location?.directory ?? directory
|
||||||
createdSessions.push(location)
|
createdSessions.push(location)
|
||||||
sessionCreateInputs.push(input)
|
const id = `session-${createdSessions.length}`
|
||||||
|
sessionDirectories[id] = location
|
||||||
return {
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
id,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
@@ -100,7 +114,7 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
prompt: async (input: unknown) => {
|
prompt: async (input: unknown) => {
|
||||||
sessionRequestOrder.push("prompt")
|
sessionRequestOrder.push("prompt")
|
||||||
sentPrompts.push(directory)
|
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||||
promptInputs.push(input)
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
@@ -120,6 +134,21 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
sentShell.push(input)
|
sentShell.push(input)
|
||||||
|
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
worktree: {
|
||||||
|
create: async (_input: unknown) => {
|
||||||
|
worktreeCreates++
|
||||||
|
await createWorktreeGate
|
||||||
|
if (worktreeFailure) throw worktreeFailure
|
||||||
|
return { directory: worktreeDirectory }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
get: async () => {
|
||||||
|
if (locationFailure) throw locationFailure
|
||||||
|
return { directory: worktreeDirectory }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -127,9 +156,6 @@ const clientFor = (directory: string) => {
|
|||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
worktree: {
|
|
||||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +171,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@opencode-ai/ui/toast", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
Toast: { Region: () => null },
|
||||||
|
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -162,20 +189,13 @@ beforeAll(async () => {
|
|||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
promote(directory: string, sessionID: string) {
|
promote: () => undefined,
|
||||||
promoted.push({ directory, sessionID })
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => {
|
mock.module("@/context/permission", () => {
|
||||||
const state = (server: string) => ({
|
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
|
||||||
enabledAutoAccept.push({ server, sessionID, directory })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
@@ -184,7 +204,10 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/tabs", () => ({
|
mock.module("@/context/tabs", () => ({
|
||||||
useTabs: () => ({
|
useTabs: () => ({
|
||||||
draft: () => ({ server: "project-server" }),
|
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||||
|
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||||
|
updatedDrafts.push({ draftID, ...draft })
|
||||||
|
},
|
||||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||||
promotedDrafts.push({ draftID, ...session })
|
promotedDrafts.push({ draftID, ...session })
|
||||||
},
|
},
|
||||||
@@ -195,78 +218,72 @@ beforeAll(async () => {
|
|||||||
usePrompt: () => prompt,
|
usePrompt: () => prompt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/layout", () => ({
|
|
||||||
useLayout: () => ({
|
|
||||||
handoff: {
|
|
||||||
setTabs: () => undefined,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("@/context/sdk", () => ({
|
mock.module("@/context/sdk", () => ({
|
||||||
useSDK: () => {
|
useSDK: () => {
|
||||||
const sdk = {
|
return () => ({
|
||||||
scope: "local",
|
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||||
directory: "/repo/main",
|
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||||
api: rootClient.api,
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
}
|
})
|
||||||
return () => sdk
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => {
|
||||||
data: { command: commands },
|
const server = activeDirectorySync
|
||||||
session: {
|
return {
|
||||||
optimistic: {
|
data: { command: commands, project: "project" },
|
||||||
add: (value: {
|
session: {
|
||||||
directory?: string
|
optimistic: {
|
||||||
sessionID?: string
|
add: (value: {
|
||||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
directory?: string
|
||||||
}) => {
|
sessionID?: string
|
||||||
optimistic.push(value)
|
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||||
optimisticSeeded.push(
|
}) => {
|
||||||
!!value.directory &&
|
optimisticServers.push(server)
|
||||||
!!value.sessionID &&
|
optimistic.push(value)
|
||||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
},
|
||||||
)
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
remove: () => undefined,
|
|
||||||
},
|
},
|
||||||
},
|
set: () => undefined,
|
||||||
set: () => undefined,
|
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/server-sync", () => ({
|
mock.module("@/context/server-sync", () => ({
|
||||||
useServerSync: () => () => ({
|
useServerSync: () => {
|
||||||
session: {
|
const server = activeServerSync
|
||||||
remember: () => undefined,
|
return {
|
||||||
set: () => undefined,
|
session: {
|
||||||
sync: async () => {
|
remember: () => undefined,
|
||||||
serverSessionSyncs++
|
set: () => undefined,
|
||||||
},
|
sync: async () => {
|
||||||
},
|
serverSessionSyncs++
|
||||||
child: (directory: string) => {
|
|
||||||
syncedDirectories.push(directory)
|
|
||||||
storedSessions[directory] ??= []
|
|
||||||
return [
|
|
||||||
{ session: storedSessions[directory] },
|
|
||||||
(...args: unknown[]) => {
|
|
||||||
if (args[0] !== "session") return
|
|
||||||
const next = args[1]
|
|
||||||
if (typeof next === "function") {
|
|
||||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Array.isArray(next)) {
|
|
||||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
]
|
},
|
||||||
},
|
child: (directory: string) => {
|
||||||
}),
|
syncedServers.push(server)
|
||||||
|
storedSessions[directory] ??= []
|
||||||
|
return [
|
||||||
|
{ session: storedSessions[directory] },
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
if (args[0] !== "session") return
|
||||||
|
const next = args[1]
|
||||||
|
if (typeof next === "function") {
|
||||||
|
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(next)) {
|
||||||
|
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/platform", () => ({
|
mock.module("@/context/platform", () => ({
|
||||||
@@ -286,205 +303,141 @@ beforeAll(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
sessionCreateInputs.length = 0
|
|
||||||
enabledAutoAccept.length = 0
|
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
|
||||||
promoted.length = 0
|
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
|
updatedDrafts.length = 0
|
||||||
|
sentCommands.length = 0
|
||||||
sentPrompts.length = 0
|
sentPrompts.length = 0
|
||||||
promptInputs.length = 0
|
promptInputs.length = 0
|
||||||
sentCommands.length = 0
|
|
||||||
switchedAgents.length = 0
|
switchedAgents.length = 0
|
||||||
switchedModels.length = 0
|
switchedModels.length = 0
|
||||||
sessionRequestOrder.length = 0
|
sessionRequestOrder.length = 0
|
||||||
commands.length = 0
|
syncedServers.length = 0
|
||||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
optimisticServers.length = 0
|
||||||
|
promptCaptures.length = 0
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
sentShellDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
activeSDK = "server-a"
|
||||||
|
activeServerSync = "server-a"
|
||||||
|
activeDirectorySync = "server-a"
|
||||||
|
commands = []
|
||||||
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
serverSessionSyncs = 0
|
serverSessionSyncs = 0
|
||||||
|
createWorktreeGate = undefined
|
||||||
|
worktreeFailure = undefined
|
||||||
|
locationFailure = undefined
|
||||||
|
worktreeCreates = 0
|
||||||
|
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||||
|
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||||
|
createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
test("reads the latest worktree accessor value per submit", async () => {
|
test("admits only one concurrent new-workspace submission", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
let release = () => {}
|
||||||
info: () => undefined,
|
createWorktreeGate = new Promise<void>((resolve) => {
|
||||||
imageAttachments: () => [],
|
release = resolve
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "shell",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
const submit = makeSubmit()
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
const first = submit.handleSubmit(event)
|
||||||
|
const duplicate = submit.handleSubmit(event)
|
||||||
|
expect(worktreeCreates).toBe(1)
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
release()
|
||||||
selected = "/repo/worktree-b"
|
await Promise.all([first, duplicate])
|
||||||
await submit.handleSubmit(event)
|
expect(createdSessions).toEqual([worktreeDirectory])
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(createdClients).toEqual([])
|
expect(worktreeCreates).toBe(1)
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toHaveLength(1)
|
||||||
expect(sessionCreateInputs).toEqual([
|
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||||
{
|
|
||||||
agent: "agent",
|
|
||||||
model: { id: "model", providerID: "provider", variant: undefined },
|
|
||||||
location: { directory: "/repo/worktree-a" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
agent: "agent",
|
|
||||||
model: { id: "model", providerID: "provider", variant: undefined },
|
|
||||||
location: { directory: "/repo/worktree-b" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(sentShell).toEqual([
|
|
||||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
|
||||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
|
||||||
])
|
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
|
||||||
expect(serverSessionSyncs).toBe(0)
|
|
||||||
expect(promoted).toEqual([
|
|
||||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
|
||||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
|
||||||
])
|
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies auto-accept to newly created sessions", async () => {
|
test("stops when the created workspace cannot initialize", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
locationFailure = new Error("initialization failed")
|
||||||
info: () => undefined,
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => true,
|
|
||||||
mode: () => "shell",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
await makeSubmit().handleSubmit(event)
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
expect(worktreeCreates).toBe(1)
|
||||||
|
expect(createdSessions).toEqual([])
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(sentPrompts).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps auto-accept bound to the submission server", async () => {
|
test("keeps async submission effects bound to the initiating context", async () => {
|
||||||
|
search = { draftId: "draft-1" }
|
||||||
|
draftServers["draft-1"] = "project-server-a"
|
||||||
|
draftServers["draft-2"] = "project-server-b"
|
||||||
let release = () => {}
|
let release = () => {}
|
||||||
createSessionGate = new Promise<void>((resolve) => {
|
createSessionGate = new Promise<void>((resolve) => {
|
||||||
release = resolve
|
release = resolve
|
||||||
})
|
})
|
||||||
const submit = createPromptSubmit({
|
let submitted = 0
|
||||||
prompt,
|
const submit = makeSubmit({
|
||||||
info: () => undefined,
|
onSubmit: () => submitted++,
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => true,
|
|
||||||
mode: () => "shell",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
const result = submit.handleSubmit(event)
|
||||||
permissionServer = "server-b"
|
activeSDK = "server-b"
|
||||||
|
activeServerSync = "server-b"
|
||||||
|
activeDirectorySync = "server-b"
|
||||||
|
search.draftId = "draft-2"
|
||||||
release()
|
release()
|
||||||
await result
|
await result
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||||
})
|
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||||
|
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||||
test("promotes drafts using the selected project's server", async () => {
|
expect(optimisticServers).toEqual(["server-a"])
|
||||||
search = { draftId: "draft-1" }
|
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||||
const submit = createPromptSubmit({
|
expect(submitted).toBe(0)
|
||||||
prompt,
|
|
||||||
info: () => undefined,
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
|
||||||
|
|
||||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switches the selected agent and model before prompting", async () => {
|
test("switches the selected agent and model before prompting", async () => {
|
||||||
params = { id: "session-1" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = makeSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({
|
info: () => ({
|
||||||
id: "session-1",
|
id: "session-1",
|
||||||
agent: "old-agent",
|
agent: "old-agent",
|
||||||
model: { id: "old-model", providerID: "old-provider" },
|
model: { id: "old-model", providerID: "old-provider" },
|
||||||
}),
|
}),
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
|
|
||||||
@@ -519,24 +472,12 @@ describe("prompt submit worktree selection", () => {
|
|||||||
commands.push({ name: "review" })
|
commands.push({ name: "review" })
|
||||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = makeSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({ id: "session-1" }),
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
await submit.handleSubmit(event)
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(sentCommands).toEqual([
|
expect(sentCommands).toEqual([
|
||||||
{
|
{
|
||||||
@@ -552,66 +493,19 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(serverSessionSyncs).toBe(0)
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("sends an initial shell after synchronous workspace creation", async () => {
|
||||||
params = { id: "session-1" }
|
selected = "create"
|
||||||
const model = {
|
const submit = makeSubmit({
|
||||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
mode: () => "shell",
|
||||||
variant: { current: () => "draft-variant" },
|
|
||||||
} as unknown as ModelSelection
|
|
||||||
const submit = createPromptSubmit({
|
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
model,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
|
||||||
|
|
||||||
expect(optimistic[0]).toMatchObject({
|
|
||||||
message: {
|
|
||||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
|
||||||
const submit = createPromptSubmit({
|
|
||||||
prompt,
|
|
||||||
info: () => undefined,
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
expect(sentShell[0]).toMatchObject({
|
||||||
expect(optimisticSeeded).toEqual([true])
|
sessionID: "session-1",
|
||||||
|
command: "ls",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import { batch, startTransition, type Accessor } from "solid-js"
|
|||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
|
||||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
@@ -25,12 +23,7 @@ import { createPromptSubmissionState } from "./submission-state"
|
|||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { blobDataUrl } from "@/utils/draft-store"
|
import { blobDataUrl } from "@/utils/draft-store"
|
||||||
|
|
||||||
type PendingPrompt = {
|
const submitting = new Set<string>()
|
||||||
abort: AbortController
|
|
||||||
cleanup: VoidFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = new Map<string, PendingPrompt>()
|
|
||||||
|
|
||||||
export type FollowupDraft = {
|
export type FollowupDraft = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -50,7 +43,6 @@ type FollowupSendInput = {
|
|||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
optimisticBusy?: boolean
|
optimisticBusy?: boolean
|
||||||
before?: () => Promise<boolean> | boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
@@ -70,22 +62,11 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const wait = async () => {
|
|
||||||
const ok = await input.before?.()
|
|
||||||
if (ok === false) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const [head, ...tail] = text.split(" ")
|
const [head, ...tail] = text.split(" ")
|
||||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||||
setBusy()
|
setBusy()
|
||||||
try {
|
try {
|
||||||
if (!(await wait())) {
|
|
||||||
setIdle()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
await input.api.command({
|
await input.api.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
@@ -159,14 +140,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!(await wait())) {
|
|
||||||
batch(() => {
|
|
||||||
setIdle()
|
|
||||||
remove()
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = input.session()
|
const session = input.session()
|
||||||
if (session?.agent !== input.draft.agent) {
|
if (session?.agent !== input.draft.agent) {
|
||||||
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
||||||
@@ -258,13 +231,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = input.prompt
|
const prompt = input.prompt
|
||||||
const layout = useLayout()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
@@ -278,19 +248,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
serverSync.session.set("todo", sessionID, [])
|
||||||
serverSync().session.set("todo", sessionID, [])
|
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
const key = pendingKey(sessionID)
|
|
||||||
const queued = pending.get(key)
|
|
||||||
if (queued) {
|
|
||||||
queued.abort.abort()
|
|
||||||
queued.cleanup()
|
|
||||||
pending.delete(key)
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
return sdk()
|
return sdk()
|
||||||
.api.session.interrupt({ sessionID })
|
.api.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -319,9 +280,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: SessionInfo) => {
|
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||||
serverSync().session.remember(info)
|
target.session.remember(info)
|
||||||
const [, setStore] = serverSync().child(dir)
|
const [, setStore] = target.child(dir)
|
||||||
setStore("session", (list: SessionInfo[]) => {
|
setStore("session", (list: SessionInfo[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
@@ -353,7 +314,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (input.working()) void abort()
|
if (input.working()) void abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelSelection = input.model ?? local.model
|
const modelSelection = input.model ?? local.model
|
||||||
const currentModel = modelSelection.current()
|
const currentModel = modelSelection.current()
|
||||||
const currentAgent = local.agent.current()
|
const currentAgent = local.agent.current()
|
||||||
@@ -366,284 +326,251 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addToHistory(currentPrompt, mode)
|
const submissionSDK = sdk()
|
||||||
input.resetHistoryNavigation()
|
const submissionSync = sync()
|
||||||
|
const submissionServerSync = serverSync
|
||||||
const projectDirectory = sdk().directory
|
const submissionScope = submissionSDK.scope
|
||||||
const permissionState = permission.currentServerState()
|
const projectDirectory = submissionSDK.directory
|
||||||
const isNewSession = !params.id
|
const sessionID = params.id
|
||||||
|
const isNewSession = !sessionID
|
||||||
|
const currentSession = input.info()
|
||||||
|
const draftID = search.draftId
|
||||||
|
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||||
|
const capturePrompt = prompt.capture
|
||||||
|
const localSession = local.session
|
||||||
|
const resetWorktree = input.onNewSessionWorktreeReset
|
||||||
|
const onSubmit = input.onSubmit
|
||||||
|
const permissionState = permission
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
const submissionKey = ScopedKey.from(
|
||||||
|
submissionScope,
|
||||||
|
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||||
|
)
|
||||||
|
if (submitting.has(submissionKey)) return
|
||||||
|
submitting.add(submissionKey)
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
try {
|
||||||
if (isNewSession) {
|
input.addToHistory(currentPrompt, mode)
|
||||||
if (worktreeSelection === "create") {
|
input.resetHistoryNavigation()
|
||||||
const createdWorktree = await sdk()
|
|
||||||
.api.projectCopy.create({
|
let sessionDirectory = projectDirectory
|
||||||
projectID: sync().data.project,
|
if (isNewSession) {
|
||||||
strategy: "git_worktree",
|
if (worktreeSelection === "create") {
|
||||||
directory: getDirectory(projectDirectory),
|
const createdWorktree = await submissionSDK.api.worktree
|
||||||
location: { directory: projectDirectory },
|
.create({
|
||||||
|
projectID: submissionSync.data.project,
|
||||||
|
strategy: "git",
|
||||||
|
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
|
||||||
|
})
|
||||||
|
.then(async (created) => {
|
||||||
|
await submissionSDK.api.location.get({ location: { directory: created.directory } })
|
||||||
|
return created
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!createdWorktree) return
|
||||||
|
sessionDirectory = createdWorktree.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||||
|
sessionDirectory = worktreeSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionDirectory !== projectDirectory) {
|
||||||
|
submissionServerSync.child(sessionDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = currentSession
|
||||||
|
if (!session && isNewSession) {
|
||||||
|
const created = await submissionSDK.api.session
|
||||||
|
.create({
|
||||||
|
agent: currentAgent.name,
|
||||||
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
|
location: { directory: sessionDirectory },
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (!createdWorktree) return
|
if (created) {
|
||||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
seed(submissionServerSync, sessionDirectory, created)
|
||||||
sessionDirectory = createdWorktree.directory
|
session = created
|
||||||
}
|
await startTransition(() => {
|
||||||
|
if (!session) return
|
||||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||||
sessionDirectory = worktreeSelection
|
if (!draftID) resetWorktree?.()
|
||||||
}
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
|
localSession.promote(sessionDirectory, session.id, {
|
||||||
if (sessionDirectory !== projectDirectory) {
|
agent: currentAgent.name,
|
||||||
serverSync().child(sessionDirectory)
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
}
|
variant: variant ?? null,
|
||||||
|
})
|
||||||
input.onNewSessionWorktreeReset?.()
|
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||||
}
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
|
submission.retarget(
|
||||||
let session = input.info()
|
capturePrompt(
|
||||||
if (!session && isNewSession) {
|
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||||
const created = await sdk()
|
{ server: draftServer, scope: submissionScope },
|
||||||
.api.session.create({
|
),
|
||||||
agent: currentAgent.name,
|
)
|
||||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
|
||||||
location: { directory: sessionDirectory },
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
})
|
||||||
return undefined
|
}
|
||||||
})
|
|
||||||
if (created) {
|
|
||||||
seed(sessionDirectory, created)
|
|
||||||
session = created
|
|
||||||
await startTransition(() => {
|
|
||||||
if (!session) return
|
|
||||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
|
||||||
local.session.promote(sessionDirectory, session.id, {
|
|
||||||
agent: currentAgent.name,
|
|
||||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
|
||||||
variant: variant ?? null,
|
|
||||||
})
|
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
|
||||||
const draftID = search.draftId
|
|
||||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
|
||||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
if (!session) {
|
||||||
if (!session) {
|
showToast({
|
||||||
showToast({
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = {
|
|
||||||
modelID: currentModel.id,
|
|
||||||
providerID: currentModel.provider.id,
|
|
||||||
}
|
|
||||||
const agent = currentAgent.name
|
|
||||||
const draft: FollowupDraft = {
|
|
||||||
sessionID: session.id,
|
|
||||||
sessionDirectory,
|
|
||||||
prompt: currentPrompt,
|
|
||||||
context,
|
|
||||||
agent,
|
|
||||||
model,
|
|
||||||
variant,
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearInput = () => {
|
|
||||||
submission.clear()
|
|
||||||
input.setMode("normal")
|
|
||||||
input.setPopover(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreInput = () => {
|
|
||||||
const restored = submission.restore()
|
|
||||||
if (!restored) return false
|
|
||||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
|
||||||
if (!submission.current(prompt.capture())) return true
|
|
||||||
input.setMode(mode)
|
|
||||||
input.setPopover(null)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const editor = input.editor()
|
|
||||||
if (!editor) return
|
|
||||||
editor.focus()
|
|
||||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
|
||||||
input.queueScroll()
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
|
||||||
input.onQueue?.(draft)
|
|
||||||
clearContext(submission.target())
|
|
||||||
clearInput()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input.onSubmit?.()
|
|
||||||
|
|
||||||
if (mode === "shell") {
|
|
||||||
clearInput()
|
|
||||||
const eventID = Event.ID.create()
|
|
||||||
sdk()
|
|
||||||
.api.session.shell({
|
|
||||||
sessionID: session.id,
|
|
||||||
id: eventID,
|
|
||||||
command: text,
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
return
|
||||||
showToast({
|
}
|
||||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
restoreInput()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.startsWith("/")) {
|
const model = {
|
||||||
const [cmdName, ...args] = text.split(" ")
|
modelID: currentModel.id,
|
||||||
const commandName = cmdName.slice(1)
|
providerID: currentModel.provider.id,
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
}
|
||||||
if (customCommand) {
|
const agent = currentAgent.name
|
||||||
|
const draft: FollowupDraft = {
|
||||||
|
sessionID: session.id,
|
||||||
|
sessionDirectory,
|
||||||
|
prompt: currentPrompt,
|
||||||
|
context,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
variant,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearInput = () => {
|
||||||
|
submission.clear()
|
||||||
|
input.setMode("normal")
|
||||||
|
input.setPopover(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreInput = () => {
|
||||||
|
const restored = submission.restore()
|
||||||
|
if (!restored) return false
|
||||||
|
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||||
|
if (!submission.current(prompt.capture())) return true
|
||||||
|
input.setMode(mode)
|
||||||
|
input.setPopover(null)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const editor = input.editor()
|
||||||
|
if (!editor) return
|
||||||
|
editor.focus()
|
||||||
|
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||||
|
input.queueScroll()
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||||
|
input.onQueue?.(draft)
|
||||||
|
clearContext(submission.target())
|
||||||
clearInput()
|
clearInput()
|
||||||
const messageID = Identifier.ascending("message")
|
return
|
||||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
}
|
||||||
sdk()
|
|
||||||
.api.session.command({
|
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||||
|
|
||||||
|
if (mode === "shell") {
|
||||||
|
clearInput()
|
||||||
|
const eventID = Event.ID.create()
|
||||||
|
void submissionSDK.api.session
|
||||||
|
.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: messageID,
|
id: eventID,
|
||||||
command: commandName,
|
command: text,
|
||||||
arguments: args.join(" "),
|
|
||||||
agent,
|
|
||||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
|
||||||
files: await Promise.all(
|
|
||||||
images.map(async (attachment) => ({
|
|
||||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
|
||||||
name: attachment.filename,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
if (text.startsWith("/")) {
|
||||||
const messageID = Identifier.ascending("message")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
|
const commandName = cmdName.slice(1)
|
||||||
const removeOptimisticMessage = () => {
|
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||||
sync().session.optimistic.remove({
|
if (customCommand) {
|
||||||
directory: sessionDirectory,
|
clearInput()
|
||||||
sessionID: session.id,
|
const messageID = Identifier.ascending("message")
|
||||||
messageID,
|
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||||
})
|
void submissionSDK.api.session
|
||||||
}
|
.command({
|
||||||
|
sessionID: session.id,
|
||||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
id: messageID,
|
||||||
clearInput()
|
command: commandName,
|
||||||
|
arguments: args.join(" "),
|
||||||
const waitForWorktree = async () => {
|
agent,
|
||||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
files: await Promise.all(
|
||||||
|
images.map(async (attachment) => ({
|
||||||
if (sessionDirectory === projectDirectory) {
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
sync().set("session_status", session.id, { type: "busy" })
|
name: attachment.filename,
|
||||||
}
|
})),
|
||||||
|
),
|
||||||
const controller = new AbortController()
|
})
|
||||||
const cleanup = () => {
|
.catch((err) => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
showToast({
|
||||||
}
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
removeOptimisticMessage()
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
})
|
||||||
}
|
restoreInput()
|
||||||
|
})
|
||||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
|
||||||
|
|
||||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
resolve({ status: "failed", message: "aborted" })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
controller.signal.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
resolve({ status: "failed", message: "aborted" })
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeoutMs = 5 * 60 * 1000
|
|
||||||
const timer = { id: undefined as number | undefined }
|
|
||||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
||||||
timer.id = window.setTimeout(() => {
|
|
||||||
resolve({
|
|
||||||
status: "failed",
|
|
||||||
message: language.t("workspace.error.stillPreparing"),
|
|
||||||
})
|
|
||||||
}, timeoutMs)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await Promise.race([
|
|
||||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
|
||||||
abortWait,
|
|
||||||
timeout,
|
|
||||||
]).finally(() => {
|
|
||||||
if (timer.id === undefined) return
|
|
||||||
clearTimeout(timer.id)
|
|
||||||
})
|
|
||||||
pending.delete(pendingKey(session.id))
|
|
||||||
if (controller.signal.aborted) return false
|
|
||||||
if (result.status === "failed") throw new Error(result.message)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
void sendFollowupDraft({
|
|
||||||
api: sdk().api.session,
|
|
||||||
sync: sync(),
|
|
||||||
serverSync: serverSync(),
|
|
||||||
session: () => input.info() ?? session,
|
|
||||||
draft,
|
|
||||||
messageID,
|
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
|
||||||
before: waitForWorktree,
|
|
||||||
}).catch((err) => {
|
|
||||||
pending.delete(pendingKey(session.id))
|
|
||||||
if (sessionDirectory === projectDirectory) {
|
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
|
||||||
}
|
}
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
description: errorMessage(err),
|
const messageID = Identifier.ascending("message")
|
||||||
|
|
||||||
|
const removeOptimisticMessage = () => {
|
||||||
|
submissionSync.session.optimistic.remove({
|
||||||
|
directory: sessionDirectory,
|
||||||
|
sessionID: session.id,
|
||||||
|
messageID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||||
|
clearInput()
|
||||||
|
|
||||||
|
void sendFollowupDraft({
|
||||||
|
api: submissionSDK.api.session,
|
||||||
|
sync: submissionSync,
|
||||||
|
serverSync: submissionServerSync,
|
||||||
|
session: () => session,
|
||||||
|
draft,
|
||||||
|
messageID,
|
||||||
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
}).catch((err) => {
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||||
|
}
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
removeOptimisticMessage()
|
||||||
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
})
|
})
|
||||||
removeOptimisticMessage()
|
} finally {
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
submitting.delete(submissionKey)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,35 +1,52 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { sameDirectory } from "@/utils/workspace"
|
||||||
|
|
||||||
export function PromptWorkspaceSelector(props: {
|
export function PromptWorkspaceSelector(props: {
|
||||||
value: string
|
value: string
|
||||||
projectRoot: string
|
projectRoot: string
|
||||||
workspaces: string[]
|
workspaces: string[]
|
||||||
branch?: string
|
branch?: string
|
||||||
|
onboarding?: boolean
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
|
onViewAll: () => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
let pending: string | undefined
|
const [search, setSearch] = createSignal("")
|
||||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
let searchInput: HTMLInputElement | undefined
|
||||||
|
let focusSearch = false
|
||||||
|
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||||
|
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
|
||||||
|
const workspaces = createMemo(() => {
|
||||||
|
const query = search().trim().toLowerCase()
|
||||||
|
if (!query) return props.workspaces
|
||||||
|
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||||
|
})
|
||||||
const icon = () => {
|
const icon = () => {
|
||||||
if (selected() === "main") return "monitor"
|
if (selected() === "main") return "monitor"
|
||||||
if (selected() === "create") return "workspace-new"
|
if (selected() === "create") return "workspace-new"
|
||||||
return "workspace"
|
return "workspace-isolated"
|
||||||
}
|
}
|
||||||
const select = (value: string) => {
|
const select = (value: string) => {
|
||||||
pending = value
|
pending = { type: "select", value }
|
||||||
}
|
}
|
||||||
const onOpenChange = (open: boolean) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (open) return
|
if (open) {
|
||||||
const value = pending
|
setSearch("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const action = pending
|
||||||
pending = undefined
|
pending = undefined
|
||||||
if (value) props.onChange(value)
|
if (action?.type === "select") props.onChange(action.value)
|
||||||
|
if (action?.type === "viewAll") {
|
||||||
|
props.onViewAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
props.onDone()
|
props.onDone()
|
||||||
}
|
}
|
||||||
const label = () => {
|
const label = () => {
|
||||||
@@ -41,87 +58,214 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
<TooltipV2
|
||||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
placement="top"
|
||||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
openDelay={800}
|
||||||
<span class="min-w-0 truncate">{label()}</span>
|
value={
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
props.onboarding ? (
|
||||||
</MenuV2.Trigger>
|
<div class="flex flex-col gap-1 text-start">
|
||||||
<MenuV2.Portal>
|
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||||
<MenuV2.Content class="w-[180px]">
|
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||||
<MenuV2.Group>
|
<span>{language.t("workspace.onboarding.title")}</span>
|
||||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
</div>
|
||||||
<MenuV2.Item onSelect={() => select("main")}>
|
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||||
<IconV2 name="monitor" />
|
</div>
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
) : (
|
||||||
<Show when={selected() === "main"}>
|
language.t("session.new.workspace.trigger.tooltip")
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
)
|
||||||
</Show>
|
}
|
||||||
</MenuV2.Item>
|
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||||
<MenuV2.Item onSelect={() => select("create")}>
|
class="min-w-0"
|
||||||
<IconV2 name="workspace-new" />
|
>
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||||
<Show when={selected() === "create"}>
|
<MenuV2.Trigger
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||||
</Show>
|
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||||
</MenuV2.Item>
|
>
|
||||||
</MenuV2.Group>
|
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
<Show when={props.workspaces.length > 0}>
|
<span class="min-w-0 truncate">{label()}</span>
|
||||||
<MenuV2.Separator />
|
<Show when={props.onboarding}>
|
||||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
<span
|
||||||
<MenuV2.SubTrigger>
|
data-slot="workspace-onboarding-dot"
|
||||||
<IconV2 name="workspace" />
|
aria-hidden="true"
|
||||||
{language.t("session.new.workspace.existing")}
|
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||||
</MenuV2.SubTrigger>
|
/>
|
||||||
<MenuV2.Portal>
|
|
||||||
<MenuV2.SubContent class="max-w-[200px]">
|
|
||||||
<For each={props.workspaces}>
|
|
||||||
{(workspace) => (
|
|
||||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
|
||||||
<IconV2 name="workspace-isolated" />
|
|
||||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
|
||||||
<Show when={selected() === workspace}>
|
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</MenuV2.SubContent>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2.Sub>
|
|
||||||
</Show>
|
</Show>
|
||||||
</MenuV2.Content>
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
</MenuV2.Portal>
|
</MenuV2.Trigger>
|
||||||
</MenuV2>
|
<MenuV2.Portal>
|
||||||
<PromptGitStatus branch={props.branch} />
|
<MenuV2.Content class="w-[200px]">
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.Item onSelect={() => select("main")}>
|
||||||
|
<Icon name="monitor" />
|
||||||
|
<TooltipV2
|
||||||
|
placement="right"
|
||||||
|
openDelay={800}
|
||||||
|
value={
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span>{language.t("session.new.workspace.local")}</span>
|
||||||
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("session.new.workspace.local.tooltip")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||||
|
</TooltipV2>
|
||||||
|
<Show when={selected() === "main"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
<MenuV2.Item onSelect={() => select("create")}>
|
||||||
|
<Icon name="workspace-new" />
|
||||||
|
<TooltipV2
|
||||||
|
placement="right"
|
||||||
|
openDelay={800}
|
||||||
|
value={
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span>{language.t("workspace.new")}</span>
|
||||||
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("session.new.workspace.new.tooltip")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||||
|
</TooltipV2>
|
||||||
|
<Show when={selected() === "create"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<Show
|
||||||
|
when={props.workspaces.length > 0}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Sub
|
||||||
|
gutter={0}
|
||||||
|
overlap
|
||||||
|
overflowPadding={8}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
focusSearch = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!focusSearch || props.workspaces.length < 10) return
|
||||||
|
focusSearch = false
|
||||||
|
requestAnimationFrame(() => searchInput?.focus())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuV2.SubTrigger
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "ArrowRight" ||
|
||||||
|
event.key === "ArrowLeft" ||
|
||||||
|
event.key === "Enter" ||
|
||||||
|
event.key === " "
|
||||||
|
)
|
||||||
|
focusSearch = true
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||||
|
</span>
|
||||||
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||||
|
<Show when={props.workspaces.length >= 10}>
|
||||||
|
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
||||||
|
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
searchInput = element
|
||||||
|
}}
|
||||||
|
value={search()}
|
||||||
|
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||||
|
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||||
|
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||||
|
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "Escape" ||
|
||||||
|
event.key === "ArrowDown" ||
|
||||||
|
event.key === "ArrowUp" ||
|
||||||
|
event.key === "Enter"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<For each={workspaces()}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
<Show when={selected() === workspace}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</TooltipV2>
|
||||||
|
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const label = () => {
|
const label = () => {
|
||||||
if (props.noGit) return language.t("session.new.git.none")
|
if (props.noGit) return language.t("session.new.git.none")
|
||||||
|
if (!props.branch) return undefined
|
||||||
|
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
||||||
return props.branch
|
return props.branch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const icon = () => {
|
||||||
|
if (props.noGit) return "monitor"
|
||||||
|
if (props.from) return "branch-out"
|
||||||
|
return "branch"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={label()}>
|
<Show when={label()}>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
<>
|
<TooltipV2
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
placement="top"
|
||||||
<TooltipV2
|
value={value()}
|
||||||
placement="top"
|
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||||
value={value()}
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
class="min-w-0 max-w-[220px]"
|
>
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||||
>
|
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
</div>
|
||||||
<span class="min-w-0 truncate">{value()}</span>
|
</TooltipV2>
|
||||||
</div>
|
|
||||||
</TooltipV2>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function createProviderConnectionController(options: {
|
|||||||
const [integration] = createResource(
|
const [integration] = createResource(
|
||||||
() => ({ provider: options.provider(), directory: options.directory() }),
|
() => ({ provider: options.provider(), directory: options.directory() }),
|
||||||
(input) =>
|
(input) =>
|
||||||
serverSDK()
|
serverSDK.api.integration
|
||||||
.api.integration.get({ integrationID: input.provider, location: location() })
|
.get({ integrationID: input.provider, location: location() })
|
||||||
.then((result) => result.data),
|
.then((result) => result.data),
|
||||||
)
|
)
|
||||||
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
||||||
@@ -120,14 +120,14 @@ export function createProviderConnectionController(options: {
|
|||||||
cancelPolling()
|
cancelPolling()
|
||||||
const directory = options.directory()
|
const directory = options.directory()
|
||||||
await queryClient
|
await queryClient
|
||||||
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
|
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (polling.disposed) return
|
if (polling.disposed) return
|
||||||
options.onComplete()
|
options.onComplete()
|
||||||
}
|
}
|
||||||
const poll = async (authorization: Authorization, generation: number) => {
|
const poll = async (authorization: Authorization, generation: number) => {
|
||||||
const result = await serverSDK()
|
const result = await serverSDK.api.integration.oauth
|
||||||
.api.integration.oauth.status({
|
.status({
|
||||||
integrationID: options.provider(),
|
integrationID: options.provider(),
|
||||||
attemptID: authorization.attemptID,
|
attemptID: authorization.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
@@ -175,8 +175,8 @@ export function createProviderConnectionController(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const result = await serverSDK()
|
const result = await serverSDK.api.integration.oauth
|
||||||
.api.integration.oauth.connect({
|
.connect({
|
||||||
integrationID: options.provider(),
|
integrationID: options.provider(),
|
||||||
methodID: selected.id,
|
methodID: selected.id,
|
||||||
...(answer ? { answer } : {}),
|
...(answer ? { answer } : {}),
|
||||||
@@ -197,7 +197,7 @@ export function createProviderConnectionController(options: {
|
|||||||
dispatch({ type: "method.reset" })
|
dispatch({ type: "method.reset" })
|
||||||
}
|
}
|
||||||
const connectKey = async (key: string) => {
|
const connectKey = async (key: string) => {
|
||||||
await serverSDK().api.integration.connect.key({
|
await serverSDK.api.integration.connect.key({
|
||||||
integrationID: options.provider(),
|
integrationID: options.provider(),
|
||||||
location: location(),
|
location: location(),
|
||||||
key,
|
key,
|
||||||
@@ -208,8 +208,8 @@ export function createProviderConnectionController(options: {
|
|||||||
const completeCode = async (code: string) => {
|
const completeCode = async (code: string) => {
|
||||||
const authorization = store.authorization
|
const authorization = store.authorization
|
||||||
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
|
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
|
||||||
const result = await serverSDK()
|
const result = await serverSDK.api.integration.oauth
|
||||||
.api.integration.oauth.complete({
|
.complete({
|
||||||
integrationID: options.provider(),
|
integrationID: options.provider(),
|
||||||
attemptID: authorization.attemptID,
|
attemptID: authorization.attemptID,
|
||||||
location: location(),
|
location: location(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createMemo, createResource } from "solid-js"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServers } from "@/context/servers"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth } from "@/utils/server-health"
|
||||||
@@ -49,7 +49,7 @@ function useDefaultServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useServerActionsController() {
|
export function useServerActionsController() {
|
||||||
const server = useServer()
|
const server = useServers()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -72,28 +72,16 @@ export function useServerActionsController() {
|
|||||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||||
|
|
||||||
export function useServerCollectionController() {
|
export function useServerCollectionController() {
|
||||||
const server = useServer()
|
const server = useServers()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const actions = useServerActionsController()
|
const actions = useServerActionsController()
|
||||||
|
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => server.list)
|
||||||
const current = server.current
|
|
||||||
const list = server.list
|
|
||||||
if (!current) return list
|
|
||||||
if (!list.includes(current)) return [current, ...list]
|
|
||||||
return [current, ...list.filter((item) => item !== current)]
|
|
||||||
})
|
|
||||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
|
||||||
settings.general.newLayoutDesigns()
|
|
||||||
? undefined
|
|
||||||
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
|
||||||
)
|
|
||||||
const sorted = createMemo(() => {
|
const sorted = createMemo(() => {
|
||||||
const raw = items()
|
const raw = items()
|
||||||
const list = raw
|
const list = raw
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
|
||||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||||
const rank = (value?: ServerHealth) => {
|
const rank = (value?: ServerHealth) => {
|
||||||
if (value?.healthy === true) return 0
|
if (value?.healthy === true) return 0
|
||||||
@@ -101,8 +89,6 @@ export function useServerCollectionController() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return list.slice().sort((a, b) => {
|
return list.slice().sort((a, b) => {
|
||||||
if (a === active) return -1
|
|
||||||
if (b === active) return 1
|
|
||||||
const diff =
|
const diff =
|
||||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) return diff
|
||||||
@@ -113,7 +99,6 @@ export function useServerCollectionController() {
|
|||||||
return {
|
return {
|
||||||
collection: {
|
collection: {
|
||||||
items: sorted,
|
items: sorted,
|
||||||
current,
|
|
||||||
health: () => global.servers.health,
|
health: () => global.servers.health,
|
||||||
},
|
},
|
||||||
...actions,
|
...actions,
|
||||||
@@ -121,21 +106,3 @@ export function useServerCollectionController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
||||||
|
|
||||||
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const server = useServer()
|
|
||||||
const global = useGlobal()
|
|
||||||
const collection = useServerCollectionController()
|
|
||||||
|
|
||||||
const select = async (connection: ServerConnection.Any) => {
|
|
||||||
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
|
||||||
options.onSelect?.()
|
|
||||||
navigate("/")
|
|
||||||
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...collection, selection: { select } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||||
|
|
||||||
function deferred<T>() {
|
function deferred<T>() {
|
||||||
@@ -65,20 +65,17 @@ describe("replaceServerConnection", () => {
|
|||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
|
|
||||||
replaceServerConnection(ServerConnection.key(original), next, {
|
replaceServerConnection(ServerConnection.key(original), next, {
|
||||||
active: () => ServerConnection.key(original),
|
|
||||||
removeTabs: (key) => calls.push(`tabs:${key}`),
|
removeTabs: (key) => calls.push(`tabs:${key}`),
|
||||||
add: (server) => {
|
add: (server) => {
|
||||||
calls.push(`add:${ServerConnection.key(server)}`)
|
calls.push(`add:${ServerConnection.key(server)}`)
|
||||||
return server
|
return server
|
||||||
},
|
},
|
||||||
setActive: (key) => calls.push(`active:${key}`),
|
|
||||||
remove: (key) => calls.push(`remove:${key}`),
|
remove: (key) => calls.push(`remove:${key}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
"tabs:https://old.example.com",
|
"tabs:https://old.example.com",
|
||||||
"add:https://new.example.com",
|
"add:https://new.example.com",
|
||||||
"active:https://new.example.com",
|
|
||||||
"remove:https://old.example.com",
|
"remove:https://old.example.com",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -87,10 +84,8 @@ describe("replaceServerConnection", () => {
|
|||||||
const removed: ServerConnection.Key[] = []
|
const removed: ServerConnection.Key[] = []
|
||||||
|
|
||||||
replaceServerConnection(ServerConnection.key(original), next, {
|
replaceServerConnection(ServerConnection.key(original), next, {
|
||||||
active: () => ServerConnection.key(original),
|
|
||||||
removeTabs: () => {},
|
removeTabs: () => {},
|
||||||
add: () => undefined,
|
add: () => undefined,
|
||||||
setActive: () => {},
|
|
||||||
remove: (key) => removed.push(key),
|
remove: (key) => removed.push(key),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection } from "@/context/servers"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
export type ServerFormValues = {
|
export type ServerFormValues = {
|
||||||
@@ -42,18 +42,13 @@ export function replaceServerConnection(
|
|||||||
originalKey: ServerConnection.Key,
|
originalKey: ServerConnection.Key,
|
||||||
next: ServerConnection.Http,
|
next: ServerConnection.Http,
|
||||||
operations: {
|
operations: {
|
||||||
active: () => ServerConnection.Key | undefined
|
|
||||||
removeTabs: (key: ServerConnection.Key) => void
|
removeTabs: (key: ServerConnection.Key) => void
|
||||||
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
||||||
setActive: (key: ServerConnection.Key) => void
|
|
||||||
remove: (key: ServerConnection.Key) => void
|
remove: (key: ServerConnection.Key) => void
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const active = operations.active()
|
|
||||||
operations.removeTabs(originalKey)
|
operations.removeTabs(originalKey)
|
||||||
const added = operations.add(next)
|
const added = operations.add(next)
|
||||||
if (!added) return
|
if (!added) return
|
||||||
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
|
||||||
if (nextActive) operations.setActive(nextActive)
|
|
||||||
operations.remove(originalKey)
|
operations.remove(originalKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|||||||
import { type Component, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
|
||||||
export const ServerRowMenu: Component<{
|
export const ServerRowMenu: Component<{
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type ServerConnection, serverName } from "@/context/server"
|
import { type ServerConnection, serverName } from "@/context/servers"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface ServerRowProps extends ParentProps {
|
interface ServerRowProps extends ParentProps {
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ function openSessionContext(args: {
|
|||||||
}) {
|
}) {
|
||||||
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
|
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
|
||||||
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
|
||||||
void args.tabs.open("context")
|
void args.tabs.open("context").then(() => args.tabs.setActive("context"))
|
||||||
args.tabs.setActive("context")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||||
|
import type { Project } from "@/types"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/utils/workspace"
|
||||||
|
|
||||||
|
export function SessionWorkspaceMenu(props: {
|
||||||
|
eligible?: boolean
|
||||||
|
sessionID: string
|
||||||
|
project: Project
|
||||||
|
directory: string
|
||||||
|
placement?: ComponentProps<typeof MenuV2>["placement"]
|
||||||
|
gutter?: number
|
||||||
|
class?: string
|
||||||
|
contentClass?: string
|
||||||
|
children: JSX.Element
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const openWorkspaces = useSettingsDialog("workspaces")
|
||||||
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
|
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
||||||
|
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
|
||||||
|
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
||||||
|
const workspaces = () =>
|
||||||
|
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
||||||
|
const onOpenChange = (open: boolean) => {
|
||||||
|
props.onOpenChange?.(open)
|
||||||
|
if (!open) return
|
||||||
|
const sdk = serverSDK
|
||||||
|
void sdk.api.worktree
|
||||||
|
.refresh({ projectID: props.project.id })
|
||||||
|
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
|
||||||
|
.then((items) =>
|
||||||
|
setDirectories(
|
||||||
|
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
const move = async (selection: "create" | string) => {
|
||||||
|
if (store.selected || blocked()) return
|
||||||
|
const sdk = serverSDK
|
||||||
|
const sessionID = props.sessionID
|
||||||
|
setStore("selected", selection)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
|
||||||
|
if (!destination) return
|
||||||
|
|
||||||
|
await sdk.api.session.move({ sessionID, directory: destination })
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.move.failed"),
|
||||||
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setStore("selected", undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuV2
|
||||||
|
placement={props.placement ?? "bottom-end"}
|
||||||
|
gutter={props.gutter ?? 4}
|
||||||
|
modal={false}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
>
|
||||||
|
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
||||||
|
{props.children}
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
|
||||||
|
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
|
||||||
|
<Icon name="monitor" />
|
||||||
|
{language.t("session.new.workspace.local")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||||
|
<Icon name="workspace-new" />
|
||||||
|
{language.t("workspace.new")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
<Show when={workspaces().length > 0}>
|
||||||
|
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||||
|
<MenuV2.SubTrigger>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||||
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||||
|
<For each={workspaces()}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||||
|
<MenuV2.Item onSelect={() => openWorkspaces()}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(project: Project, serverSDK: ReturnType<typeof useServerSDK>) {
|
||||||
|
const created = await serverSDK.api.worktree.create({
|
||||||
|
projectID: project.id,
|
||||||
|
strategy: "git",
|
||||||
|
directory: getDirectory(project.worktree),
|
||||||
|
})
|
||||||
|
await serverSDK.api.location.get({ location: { directory: created.directory } })
|
||||||
|
return created.directory
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ import { createEffect, createMemo } from "solid-js"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
export const OPEN_APPS = [
|
export const OPEN_APPS = [
|
||||||
"vscode",
|
"vscode",
|
||||||
@@ -169,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
|
|||||||
app: undefined as OpenApp | undefined,
|
app: undefined as OpenApp | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal)
|
||||||
const current = createMemo(
|
const current = createMemo(
|
||||||
() =>
|
() =>
|
||||||
options().find((o) => o.id === prefs.app) ??
|
options().find((o) => o.id === prefs.app) ??
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
import { AppIcon } from "@opencode-ai/ui/app-icon"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
||||||
import { showToast } from "@/utils/toast"
|
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { useTerminal } from "@/context/terminal"
|
|
||||||
import { focusTerminalById } from "@/pages/session/helpers"
|
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
import { StatusPopoverV2 } from "../status-popover"
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
import { fileManagerApp } from "@/utils/file-manager"
|
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
|
||||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
@@ -34,206 +14,15 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|||||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||||
import { useTitlebarRightMount } from "../titlebar"
|
import { useTitlebarRightMount } from "../titlebar"
|
||||||
|
|
||||||
const OPEN_APPS = [
|
|
||||||
"vscode",
|
|
||||||
"cursor",
|
|
||||||
"zed",
|
|
||||||
"textmate",
|
|
||||||
"antigravity",
|
|
||||||
"finder",
|
|
||||||
"terminal",
|
|
||||||
"iterm2",
|
|
||||||
"ghostty",
|
|
||||||
"warp",
|
|
||||||
"xcode",
|
|
||||||
"android-studio",
|
|
||||||
"powershell",
|
|
||||||
"sublime-text",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
type OpenApp = (typeof OPEN_APPS)[number]
|
|
||||||
type OS = "macos" | "windows" | "linux" | "unknown"
|
|
||||||
|
|
||||||
const MAC_APPS = [
|
|
||||||
{
|
|
||||||
id: "vscode",
|
|
||||||
label: "session.header.open.app.vscode",
|
|
||||||
icon: "vscode",
|
|
||||||
openWith: "Visual Studio Code",
|
|
||||||
},
|
|
||||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
|
|
||||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
|
|
||||||
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
|
|
||||||
{
|
|
||||||
id: "antigravity",
|
|
||||||
label: "session.header.open.app.antigravity",
|
|
||||||
icon: "antigravity",
|
|
||||||
openWith: "Antigravity",
|
|
||||||
},
|
|
||||||
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
|
|
||||||
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
|
|
||||||
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
|
|
||||||
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
|
|
||||||
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
|
|
||||||
{
|
|
||||||
id: "android-studio",
|
|
||||||
label: "session.header.open.app.androidStudio",
|
|
||||||
icon: "android-studio",
|
|
||||||
openWith: "Android Studio",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sublime-text",
|
|
||||||
label: "session.header.open.app.sublimeText",
|
|
||||||
icon: "sublime-text",
|
|
||||||
openWith: "Sublime Text",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const WINDOWS_APPS = [
|
|
||||||
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
|
||||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
|
||||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
|
||||||
{
|
|
||||||
id: "powershell",
|
|
||||||
label: "session.header.open.app.powershell",
|
|
||||||
icon: "powershell",
|
|
||||||
openWith: "powershell",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sublime-text",
|
|
||||||
label: "session.header.open.app.sublimeText",
|
|
||||||
icon: "sublime-text",
|
|
||||||
openWith: "Sublime Text",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const LINUX_APPS = [
|
|
||||||
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
|
||||||
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
|
||||||
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
|
||||||
{
|
|
||||||
id: "sublime-text",
|
|
||||||
label: "session.header.open.app.sublimeText",
|
|
||||||
icon: "sublime-text",
|
|
||||||
openWith: "Sublime Text",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
|
|
||||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
|
||||||
if (typeof navigator !== "object") return "unknown"
|
|
||||||
const value = navigator.platform || navigator.userAgent
|
|
||||||
if (/Mac/i.test(value)) return "macos"
|
|
||||||
if (/Win/i.test(value)) return "windows"
|
|
||||||
if (/Linux/i.test(value)) return "linux"
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionHeader() {
|
export function SessionHeader() {
|
||||||
const layout = useLayout()
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const server = useServer()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const { view } = useSessionLayout()
|
||||||
const terminal = useTerminal()
|
|
||||||
const { params, view } = useSessionLayout()
|
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
|
||||||
const project = createMemo(() => {
|
|
||||||
const directory = projectDirectory()
|
|
||||||
if (!directory) return
|
|
||||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
|
||||||
})
|
|
||||||
const name = createMemo(() => {
|
|
||||||
const current = project()
|
|
||||||
if (current) return current.name || getFilename(current.worktree)
|
|
||||||
return getFilename(projectDirectory())
|
|
||||||
})
|
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
|
||||||
const os = createMemo(() => detectOS(platform))
|
|
||||||
const isV2 = settings.general.newLayoutDesigns
|
|
||||||
const search = settings.visibility.search
|
|
||||||
const status = settings.visibility.status
|
const status = settings.visibility.status
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
|
||||||
finder: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const apps = createMemo(() => {
|
|
||||||
if (os() === "macos") return MAC_APPS
|
|
||||||
if (os() === "windows") return WINDOWS_APPS
|
|
||||||
return LINUX_APPS
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileManager = createMemo(() => fileManagerApp(os()))
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (platform.platform !== "desktop") return
|
|
||||||
if (!platform.checkAppExists) return
|
|
||||||
|
|
||||||
const list = apps()
|
|
||||||
|
|
||||||
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
|
|
||||||
|
|
||||||
void Promise.all(
|
|
||||||
list.map((app) =>
|
|
||||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
|
||||||
.then((value) => Boolean(value))
|
|
||||||
.catch(() => false)
|
|
||||||
.then((ok) => [app.id, ok] as const),
|
|
||||||
),
|
|
||||||
).then((entries) => {
|
|
||||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = createMemo(() => {
|
|
||||||
return [
|
|
||||||
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
|
|
||||||
...apps()
|
|
||||||
.filter((app) => exists[app.id])
|
|
||||||
.map((app) => ({ ...app, label: language.t(app.label) })),
|
|
||||||
] as const
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleTerminal = () => {
|
|
||||||
const next = !view().terminal.opened()
|
|
||||||
view().terminal.toggle()
|
|
||||||
if (!next) return
|
|
||||||
|
|
||||||
const id = terminal.active()
|
|
||||||
if (!id) return
|
|
||||||
focusTerminalById(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
|
||||||
const [menu, setMenu] = createStore({ open: false })
|
|
||||||
const [openRequest, setOpenRequest] = createStore({
|
|
||||||
app: undefined as OpenApp | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
|
||||||
const current = createMemo(
|
|
||||||
() =>
|
|
||||||
options().find((o) => o.id === prefs.app) ??
|
|
||||||
options()[0] ??
|
|
||||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
|
||||||
)
|
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
|
||||||
const tint = createMemo(() =>
|
|
||||||
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
|
||||||
)
|
|
||||||
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||||
statusVisible: status(),
|
statusVisible: status(),
|
||||||
statusLabel: language.t("status.popover.trigger"),
|
statusLabel: language.t("status.popover.trigger"),
|
||||||
@@ -244,275 +33,16 @@ export function SessionHeader() {
|
|||||||
onReviewToggle: () => view().reviewPanel.toggle(),
|
onReviewToggle: () => view().reviewPanel.toggle(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const selectApp = (app: OpenApp) => {
|
|
||||||
if (!options().some((item) => item.id === app)) return
|
|
||||||
setPrefs("app", app)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openDir = (app: OpenApp) => {
|
|
||||||
if (opening() || !canOpen() || !platform.openPath) return
|
|
||||||
const directory = projectDirectory()
|
|
||||||
if (!directory) return
|
|
||||||
|
|
||||||
const item = options().find((o) => o.id === app)
|
|
||||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
|
||||||
setOpenRequest("app", app)
|
|
||||||
platform
|
|
||||||
.openPath(directory, openWith)
|
|
||||||
.catch((err: unknown) => showRequestError(language, err))
|
|
||||||
.finally(() => {
|
|
||||||
setOpenRequest("app", undefined)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyPath = () => {
|
|
||||||
const directory = projectDirectory()
|
|
||||||
if (!directory) return
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(directory)
|
|
||||||
.then(() => {
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("session.share.copy.copied"),
|
|
||||||
description: directory,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => showRequestError(language, err))
|
|
||||||
}
|
|
||||||
|
|
||||||
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
|
|
||||||
const rightMount = useTitlebarRightMount()
|
const rightMount = useTitlebarRightMount()
|
||||||
onMount(() => {
|
|
||||||
setCenterMount(document.getElementById("opencode-titlebar-center"))
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Show when={rightMount()} keyed>
|
||||||
<Show when={search() && centerMount()} keyed>
|
{(mount) => (
|
||||||
{(mount) => (
|
<Portal mount={mount}>
|
||||||
<Portal mount={mount}>
|
<SessionHeaderV2Actions state={v2ActionsState()} />
|
||||||
<Button
|
</Portal>
|
||||||
type="button"
|
)}
|
||||||
variant="ghost"
|
</Show>
|
||||||
size="small"
|
|
||||||
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
|
||||||
onClick={() => command.trigger("file.open")}
|
|
||||||
aria-label={language.t("session.header.searchFiles")}
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
|
||||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
|
||||||
{language.t("session.header.search.placeholder", {
|
|
||||||
project: name(),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={hotkey()} keyed>
|
|
||||||
{(keybind) => (
|
|
||||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
|
||||||
{keybind}
|
|
||||||
</Keybind>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Button>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={rightMount()} keyed>
|
|
||||||
{(mount) => (
|
|
||||||
<Portal mount={mount}>
|
|
||||||
<Show
|
|
||||||
when={isV2}
|
|
||||||
fallback={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Show when={projectDirectory()}>
|
|
||||||
<div class="hidden xl:flex items-center">
|
|
||||||
<Show
|
|
||||||
when={canOpen()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
|
|
||||||
onClick={copyPath}
|
|
||||||
aria-label={language.t("session.header.open.copyPath")}
|
|
||||||
>
|
|
||||||
<Icon name="copy" size="small" class="text-icon-base" />
|
|
||||||
<span class="text-12-regular text-text-strong">
|
|
||||||
{language.t("session.header.open.copyPath")}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-raised-base-active": opening(),
|
|
||||||
}}
|
|
||||||
onClick={() => openDir(current().id)}
|
|
||||||
disabled={opening()}
|
|
||||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
|
||||||
>
|
|
||||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
|
||||||
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
|
|
||||||
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu
|
|
||||||
gutter={4}
|
|
||||||
placement="bottom-end"
|
|
||||||
open={menu.open}
|
|
||||||
onOpenChange={(open) => setMenu("open", open)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="chevron-down"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={opening()}
|
|
||||||
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-raised-base-active": opening(),
|
|
||||||
}}
|
|
||||||
aria-label={language.t("session.header.open.menu")}
|
|
||||||
/>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
|
|
||||||
<DropdownMenu.Group>
|
|
||||||
<DropdownMenu.GroupLabel class="!px-1 !py-1">
|
|
||||||
{language.t("session.header.openIn")}
|
|
||||||
</DropdownMenu.GroupLabel>
|
|
||||||
<DropdownMenu.RadioGroup
|
|
||||||
class="mt-1"
|
|
||||||
value={current().id}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
|
||||||
selectApp(value as OpenApp)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<For each={options()}>
|
|
||||||
{(o) => (
|
|
||||||
<DropdownMenu.RadioItem
|
|
||||||
value={o.id}
|
|
||||||
disabled={opening()}
|
|
||||||
onSelect={() => {
|
|
||||||
setMenu("open", false)
|
|
||||||
openDir(o.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
|
||||||
<AppIcon id={o.icon} />
|
|
||||||
</div>
|
|
||||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
|
||||||
<DropdownMenu.ItemIndicator>
|
|
||||||
<Icon name="check-small" size="small" class="text-icon-weak" />
|
|
||||||
</DropdownMenu.ItemIndicator>
|
|
||||||
</DropdownMenu.RadioItem>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</DropdownMenu.RadioGroup>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => {
|
|
||||||
setMenu("open", false)
|
|
||||||
copyPath()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
|
||||||
<Icon name="copy" size="small" class="text-icon-weak" />
|
|
||||||
</div>
|
|
||||||
<DropdownMenu.ItemLabel>
|
|
||||||
{language.t("session.header.open.copyPath")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Show when={status()}>
|
|
||||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
|
||||||
<StatusPopover />
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
<TooltipKeybind
|
|
||||||
title={language.t("command.terminal.toggle")}
|
|
||||||
keybind={command.keybind("terminal.toggle")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
|
||||||
onClick={toggleTerminal}
|
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
|
||||||
aria-expanded={view().terminal.opened()}
|
|
||||||
aria-controls="terminal-panel"
|
|
||||||
>
|
|
||||||
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
|
||||||
<TooltipKeybind
|
|
||||||
title={language.t("command.review.toggle")}
|
|
||||||
keybind={command.keybind("review.toggle")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
|
||||||
onClick={() => view().reviewPanel.toggle()}
|
|
||||||
aria-label={language.t("command.review.toggle")}
|
|
||||||
aria-expanded={view().reviewPanel.opened()}
|
|
||||||
aria-controls="review-panel"
|
|
||||||
>
|
|
||||||
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
|
|
||||||
<TooltipKeybind
|
|
||||||
title={language.t("command.fileTree.toggle")}
|
|
||||||
keybind={command.keybind("fileTree.toggle")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
|
||||||
onClick={() => layout.fileTree.toggle()}
|
|
||||||
aria-label={language.t("command.fileTree.toggle")}
|
|
||||||
aria-expanded={layout.fileTree.opened()}
|
|
||||||
aria-controls="file-tree-panel"
|
|
||||||
>
|
|
||||||
<div class="relative flex items-center justify-center size-4">
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
|
|
||||||
classList={{
|
|
||||||
"text-icon-strong": layout.fileTree.opened(),
|
|
||||||
"text-icon-weak": !layout.fileTree.opened(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SessionHeaderV2Actions state={v2ActionsState()} />
|
|
||||||
</Show>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,8 +57,6 @@ type SessionHeaderV2ActionsState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={props.state.statusVisible}>
|
<Show when={props.state.statusVisible}>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
import { For, Show, createMemo, lazy, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
@@ -14,7 +9,6 @@ import fuzzysort from "fuzzysort"
|
|||||||
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { SettingsList } from "./settings-list"
|
|
||||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||||
|
|
||||||
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
|
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
|
||||||
@@ -202,69 +196,6 @@ function filteredFor(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function useKeyCapture(input: {
|
|
||||||
active: () => string | null
|
|
||||||
stop: () => void
|
|
||||||
set: (id: string, keybind: string) => void
|
|
||||||
used: () => Map<string, { id: string; title: string }[]>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}) {
|
|
||||||
onMount(() => {
|
|
||||||
const handle = (event: KeyboardEvent) => {
|
|
||||||
const id = input.active()
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
event.stopImmediatePropagation()
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
input.stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear =
|
|
||||||
(event.key === "Backspace" || event.key === "Delete") &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
!event.shiftKey
|
|
||||||
if (clear) {
|
|
||||||
input.set(id, "none")
|
|
||||||
input.stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = recordKeybind(event)
|
|
||||||
if (!next) return
|
|
||||||
|
|
||||||
const conflicts = new Map<string, string>()
|
|
||||||
for (const sig of signatures(next)) {
|
|
||||||
for (const item of input.used().get(sig) ?? []) {
|
|
||||||
if (item.id === id) continue
|
|
||||||
conflicts.set(item.id, item.title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conflicts.size > 0) {
|
|
||||||
showToast({
|
|
||||||
title: input.language.t("settings.shortcuts.conflict.title"),
|
|
||||||
description: input.language.t("settings.shortcuts.conflict.description", {
|
|
||||||
keybind: formatKeybind(next, input.language.t),
|
|
||||||
titles: [...conflicts.values()].join(", "),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input.set(id, next)
|
|
||||||
input.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
makeEventListener(document, "keydown", handle, { capture: true })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createKeybindSettingsController(
|
export function createKeybindSettingsController(
|
||||||
input: {
|
input: {
|
||||||
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
|
||||||
@@ -411,7 +342,7 @@ export function createKeybindSettingsController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsKeybindsV2() {
|
export function SettingsKeybinds() {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const controller = createKeybindSettingsController({
|
const controller = createKeybindSettingsController({
|
||||||
@@ -532,252 +463,3 @@ function SettingsKeybindsV2View(props: {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
|
||||||
if (props.v2) return <SettingsKeybindsV2 />
|
|
||||||
|
|
||||||
const command = useCommand()
|
|
||||||
const language = useLanguage()
|
|
||||||
const settings = useSettings()
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
active: null as string | null,
|
|
||||||
filter: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
if (!store.active) return
|
|
||||||
setStore("active", null)
|
|
||||||
command.keybinds(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = (id: string) => {
|
|
||||||
if (store.active === id) {
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.active) stop()
|
|
||||||
|
|
||||||
setStore("active", id)
|
|
||||||
command.keybinds(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = createMemo(() => keybinds(settings.current.keybinds))
|
|
||||||
|
|
||||||
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
|
|
||||||
|
|
||||||
const resetAll = () => {
|
|
||||||
stop()
|
|
||||||
settings.keybinds.resetAll()
|
|
||||||
showToast({
|
|
||||||
title: language.t("settings.shortcuts.reset.toast.title"),
|
|
||||||
description: language.t("settings.shortcuts.reset.toast.description"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = createMemo(() => {
|
|
||||||
language.locale()
|
|
||||||
return listFor(command, map(), language.t("command.palette"))
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = (id: string) => list().get(id)?.title ?? ""
|
|
||||||
|
|
||||||
const grouped = createMemo(() => groupedFor(list()))
|
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
|
||||||
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasResults = createMemo(() => {
|
|
||||||
for (const group of GROUPS) {
|
|
||||||
const ids = filtered().get(group) ?? []
|
|
||||||
if (ids.length > 0) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const used = createMemo(() => {
|
|
||||||
const map = new Map<string, { id: string; title: string }[]>()
|
|
||||||
|
|
||||||
const add = (key: string, value: { id: string; title: string }) => {
|
|
||||||
const list = map.get(key)
|
|
||||||
if (!list) {
|
|
||||||
map.set(key, [value])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
list.push(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
|
|
||||||
for (const sig of signatures(palette)) {
|
|
||||||
add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) })
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueFor = (id: string) => {
|
|
||||||
const custom = settings.keybinds.get(id)
|
|
||||||
if (typeof custom === "string") return custom
|
|
||||||
|
|
||||||
const live = command.options.find((x) => x.id === id)
|
|
||||||
if (live?.keybind) return live.keybind
|
|
||||||
|
|
||||||
const meta = command.catalog.find((x) => x.id === id)
|
|
||||||
return meta?.keybind
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of list().keys()) {
|
|
||||||
if (id === PALETTE_ID) continue
|
|
||||||
for (const sig of signatures(valueFor(id))) {
|
|
||||||
add(sig, { id, title: title(id) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
|
|
||||||
|
|
||||||
useKeyCapture({
|
|
||||||
active: () => store.active,
|
|
||||||
stop,
|
|
||||||
set: setKeybind,
|
|
||||||
used,
|
|
||||||
language,
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (store.active) command.keybinds(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
const emptyResults = (
|
|
||||||
<Show when={store.filter && !hasResults()}>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
|
|
||||||
"settings-v2-shortcuts-status": props.v2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"text-14-regular text-text-weak": !props.v2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("settings.shortcuts.search.empty")}
|
|
||||||
</span>
|
|
||||||
<Show when={store.filter}>
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"text-14-regular text-text-strong mt-1": !props.v2,
|
|
||||||
"settings-v2-shortcuts-status-filter": props.v2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
"{store.filter}"
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
|
|
||||||
const groups = (
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
|
|
||||||
"flex flex-col gap-8 max-w-[720px]": !props.v2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<For each={GROUPS}>
|
|
||||||
{(group) => (
|
|
||||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"settings-v2-section": props.v2,
|
|
||||||
"flex flex-col gap-1": !props.v2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
classList={{
|
|
||||||
"settings-v2-section-title": props.v2,
|
|
||||||
"text-14-medium text-text-strong pb-2": !props.v2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t(groupKey[group])}
|
|
||||||
</h3>
|
|
||||||
<Dynamic component={props.v2 ? SettingsListV2 : SettingsList}>
|
|
||||||
<For each={filtered().get(group) ?? []}>
|
|
||||||
{(id) => (
|
|
||||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"text-14-regular text-text-strong": !props.v2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title(id)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-keybind-id={id}
|
|
||||||
classList={{
|
|
||||||
"settings-v2-keybind-button": props.v2,
|
|
||||||
"settings-v2-keybind-button--active": props.v2 && store.active === id,
|
|
||||||
"h-8 px-3 rounded-md text-12-regular": !props.v2,
|
|
||||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
|
||||||
!props.v2 && store.active !== id,
|
|
||||||
"border border-border-weak-base bg-surface-inset-base text-text-weak":
|
|
||||||
!props.v2 && store.active === id,
|
|
||||||
}}
|
|
||||||
onClick={() => start(id)}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={store.active === id}
|
|
||||||
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
|
||||||
>
|
|
||||||
{language.t("settings.shortcuts.pressKeys")}
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Dynamic>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
{emptyResults}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
|
||||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
|
||||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
|
||||||
<TextField
|
|
||||||
variant="ghost"
|
|
||||||
type="text"
|
|
||||||
value={store.filter}
|
|
||||||
onChange={(v) => setStore("filter", v)}
|
|
||||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
|
||||||
spellcheck={false}
|
|
||||||
autocorrect="off"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Show when={store.filter}>
|
|
||||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{groups}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { type ParentProps, Show } from "solid-js"
|
import { type ParentProps, Show } from "solid-js"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerProvider } from "@/context/server"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
|
||||||
|
|
||||||
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
|
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
@@ -20,10 +19,8 @@ export function SettingsServerScope(props: ParentProps<{ directory?: string }>)
|
|||||||
|
|
||||||
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
|
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
|
||||||
return (
|
return (
|
||||||
<ServerSDKProvider server={props.server}>
|
<ServerProvider conn={props.server}>
|
||||||
<ServerSyncProvider server={props.server}>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
</ServerProvider>
|
||||||
</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/components/server/server-management"
|
} from "@/components/server/server-management"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServers } from "@/context/servers"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useCheckServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth } from "@/utils/server-health"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -146,7 +146,7 @@ export const DialogServerV2: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createFormController(options: { onSelect?: () => void } = {}) {
|
function createFormController(options: { onSelect?: () => void } = {}) {
|
||||||
const server = useServer()
|
const server = useServers()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -173,8 +173,7 @@ function createFormController(options: { onSelect?: () => void } = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const allServers = () => {
|
const allServers = () => {
|
||||||
if (!server.current || server.list.includes(server.current)) return server.list
|
return server.list
|
||||||
return [server.current, ...server.list]
|
|
||||||
}
|
}
|
||||||
const editing = createMemo(() =>
|
const editing = createMemo(() =>
|
||||||
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
||||||
@@ -182,10 +181,8 @@ function createFormController(options: { onSelect?: () => void } = {}) {
|
|||||||
const add = (connection: ServerConnection.Http) => server.add(connection)
|
const add = (connection: ServerConnection.Http) => server.add(connection)
|
||||||
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
|
||||||
replaceServerConnection(originalKey, next, {
|
replaceServerConnection(originalKey, next, {
|
||||||
active: () => server.key,
|
|
||||||
removeTabs: (key) => tabs.removeServer(key),
|
removeTabs: (key) => tabs.removeServer(key),
|
||||||
add,
|
add,
|
||||||
setActive: (key) => server.setActive(key),
|
|
||||||
remove: (key) => server.remove(key),
|
remove: (key) => server.remove(key),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Component, createMemo, createSignal, startTransition } from "solid-js"
|
import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SettingsGeneralV2 } from "./general"
|
import { SettingsGeneral } from "./general"
|
||||||
import { SettingsAppearanceV2 } from "./appearance"
|
import { SettingsAppearanceV2 } from "./appearance"
|
||||||
import { SettingsKeybinds } from "../settings-keybinds"
|
import { SettingsKeybinds } from "../settings-keybinds"
|
||||||
import { SettingsNotificationsV2 } from "./notifications"
|
import { SettingsNotificationsV2 } from "./notifications"
|
||||||
import { SettingsProvidersV2 } from "./providers"
|
import { SettingsProvidersV2 } from "./providers"
|
||||||
import { SettingsModelsV2 } from "./models"
|
import { SettingsModelsV2 } from "./models"
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
|
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||||
import { SettingsProjectsV2 } from "./projects"
|
import { SettingsProjectsV2 } from "./projects"
|
||||||
import { SettingsExtensionsV2 } from "./extensions"
|
import { SettingsExtensionsV2 } from "./extensions"
|
||||||
import { SettingsServerScope } from "../settings-server-picker"
|
import { SettingsServerScope } from "../settings-server-picker"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useGlobal, useServerCtx } from "@/context/global"
|
||||||
import { useGlobal } from "@/context/global"
|
import { ServerConnection, useServers } from "@/context/servers"
|
||||||
import { ServerConnection } from "@/context/server"
|
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component<{
|
||||||
@@ -30,22 +30,40 @@ export const DialogSettings: Component<{
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const servers = useServers()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
|
|
||||||
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
|
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
const directory = createMemo(() => {
|
const server = createMemo(() => {
|
||||||
const server = global.settings.server.selected()
|
const route = layout.route()
|
||||||
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
|
switch (route.type) {
|
||||||
|
case "draft": {
|
||||||
|
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||||
|
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
|
||||||
|
}
|
||||||
|
case "session":
|
||||||
|
return servers.list.find((item) => ServerConnection.key(item) === route.server)
|
||||||
|
case "home":
|
||||||
|
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const serverCtx = useServerCtx(server)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const current = server()
|
||||||
|
if (current) global.settings.server.set(ServerConnection.key(current))
|
||||||
|
})
|
||||||
|
|
||||||
|
const directory = createMemo(() => {
|
||||||
|
const selected = global.settings.server.selected()
|
||||||
|
const current = server()
|
||||||
|
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
if (route.type === "dir-new-sesssion") return route.dir
|
|
||||||
if (route.type === "draft") {
|
if (route.type === "draft") {
|
||||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||||
return draft?.type === "draft" ? draft.directory : undefined
|
return draft?.type === "draft" ? draft.directory : undefined
|
||||||
}
|
}
|
||||||
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
|
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -95,6 +113,10 @@ export const DialogSettings: Component<{
|
|||||||
<Icon name="folder" />
|
<Icon name="folder" />
|
||||||
{language.t("settings.tab.projects")}
|
{language.t("settings.tab.projects")}
|
||||||
</TabsV2.Trigger>
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="workspaces">
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
{language.t("settings.tab.workspaces")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group 3: Capabilities & Extensions */}
|
{/* Group 3: Capabilities & Extensions */}
|
||||||
@@ -122,7 +144,7 @@ export const DialogSettings: Component<{
|
|||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
|
|
||||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
<SettingsGeneral server={server()} sessionID={props.sessionID} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
||||||
<SettingsAppearanceV2 />
|
<SettingsAppearanceV2 />
|
||||||
@@ -131,7 +153,7 @@ export const DialogSettings: Component<{
|
|||||||
<SettingsNotificationsV2 />
|
<SettingsNotificationsV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds v2 />
|
<SettingsKeybinds />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||||
<SettingsServersV2 />
|
<SettingsServersV2 />
|
||||||
@@ -140,6 +162,9 @@ export const DialogSettings: Component<{
|
|||||||
<SettingsProjectsV2 />
|
<SettingsProjectsV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<SettingsServerScope directory={directory()}>
|
<SettingsServerScope directory={directory()}>
|
||||||
|
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||||
|
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
|
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const mcps = createMemo<McpRowItem[]>(() => {
|
const mcps = createMemo<McpRowItem[]>(() => {
|
||||||
const configMcp = serverSync().data.config.mcp ?? {}
|
const configMcp = serverSync.data.config.mcp ?? {}
|
||||||
return Object.entries(configMcp).map(([name, config]) => ({
|
return Object.entries(configMcp).map(([name, config]) => ({
|
||||||
name,
|
name,
|
||||||
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
||||||
@@ -31,18 +31,16 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||||
const before = serverSync().data.config.mcp ?? {}
|
const before = serverSync.data.config.mcp ?? {}
|
||||||
const config = before[item.name]
|
const config = before[item.name]
|
||||||
if (typeof config !== "object" || config === null) return
|
if (typeof config !== "object" || config === null) return
|
||||||
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
||||||
serverSync().set("config", "mcp", next)
|
serverSync.set("config", "mcp", next)
|
||||||
void serverSync()
|
void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
|
||||||
.updateConfig({ mcp: next })
|
|
||||||
.catch(() => serverSync().set("config", "mcp", before))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugins = createMemo<PluginRowItem[]>(() => {
|
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||||
const raw = serverSync().data.config.plugin ?? []
|
const raw = serverSync.data.config.plugin ?? []
|
||||||
return raw.map((item) => {
|
return raw.map((item) => {
|
||||||
const name = typeof item === "string" ? item : item[0]
|
const name = typeof item === "string" ? item : item[0]
|
||||||
return { name }
|
return { name }
|
||||||
|
|||||||
@@ -17,17 +17,24 @@ import {
|
|||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
|
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
|
||||||
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
import { useGlobal, useServerCtx } from "@/context/global"
|
||||||
|
|
||||||
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||||
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
||||||
|
|
||||||
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
|
export function createPermissionScopeController(
|
||||||
const permission = usePermission()
|
server: Accessor<ServerConnection.Any | undefined>,
|
||||||
const serverSync = useServerSync()
|
sessionID: Accessor<string | undefined>,
|
||||||
|
) {
|
||||||
|
const serverCtx = useServerCtx(server)
|
||||||
|
const permission = () => serverCtx()?.permission
|
||||||
|
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
|
const s = server()
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!id) return undefined
|
if (!s || !id) return undefined
|
||||||
return serverSync().session.lineage.peek(id)?.session.location.directory
|
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -35,21 +42,21 @@ export function createPermissionScopeController(sessionID: Accessor<string | und
|
|||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
const dir = directory()
|
const dir = directory()
|
||||||
if (!id || !dir) return false
|
if (!id || !dir) return false
|
||||||
return permission.isAutoAccepting(id, dir)
|
return permission()?.isAutoAccepting(id, dir)
|
||||||
}),
|
}),
|
||||||
enabled: createMemo(() => !!directory()),
|
enabled: createMemo(() => !!directory()),
|
||||||
set: (checked: boolean) => {
|
set: (checked: boolean) => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
const dir = directory()
|
const dir = directory()
|
||||||
if (!id || !dir) return
|
if (!id || !dir) return
|
||||||
if (checked) return permission.enableAutoAccept(id, dir)
|
if (checked) return permission()?.enableAutoAccept(id, dir)
|
||||||
permission.disableAutoAccept(id, dir)
|
permission()?.disableAutoAccept(id, dir)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createShellSettingsController() {
|
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
|
||||||
const serverSync = useServerSync()
|
const serverCtx = useServerCtx(server)
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
||||||
@@ -58,7 +65,7 @@ export function createShellSettingsController() {
|
|||||||
},
|
},
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
const current = createMemo(() => serverSync().data.config.shell ?? "")
|
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shells: () => shells.latest,
|
shells: () => shells.latest,
|
||||||
@@ -66,7 +73,7 @@ export function createShellSettingsController() {
|
|||||||
select: (value: string) => {
|
select: (value: string) => {
|
||||||
if (value === current()) return
|
if (value === current()) return
|
||||||
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
||||||
// void serverSync().updateConfig({ shell: value })
|
// void serverSync.updateConfig({ shell: value })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import { useSettings } from "@/context/settings"
|
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||||
import { ExternalLink } from "../external-link"
|
import { ExternalLink } from "../external-link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type SoundSettingsController,
|
type SoundSettingsController,
|
||||||
} from "./general-controllers"
|
} from "./general-controllers"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
|
||||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||||
const fontSettings = {
|
const fontSettings = {
|
||||||
@@ -85,6 +86,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WorkspaceDestinationSetting: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
|
||||||
|
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
|
||||||
|
{ value: "local", label: language.t("settings.workspaces.default.local") },
|
||||||
|
{ value: "new", label: language.t("settings.workspaces.default.new") },
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.workspaces.default.title")}
|
||||||
|
description={language.t("settings.workspaces.default.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
options={options()}
|
||||||
|
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||||
|
value={(option) => option.value}
|
||||||
|
label={(option) => option.label}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
@@ -269,16 +298,20 @@ const LanguageSetting = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsGeneralV2: Component<{
|
export const SettingsGeneral: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
|
server?: ServerConnection.Any
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
const permissionScope = createPermissionScopeController(
|
||||||
const shell = createShellSettingsController()
|
() => props.server,
|
||||||
|
() => props.sessionID,
|
||||||
|
)
|
||||||
|
const shell = createShellSettingsController(() => props.server)
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
@@ -300,6 +333,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<LanguageSetting />
|
<LanguageSetting />
|
||||||
|
|
||||||
|
<WorkspaceDestinationSetting />
|
||||||
<PermissionScopeSetting controller={permissionScope} />
|
<PermissionScopeSetting controller={permissionScope} />
|
||||||
|
|
||||||
<ShellSetting controller={shell} />
|
<ShellSetting controller={shell} />
|
||||||
@@ -362,18 +396,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.showFileTree.title")}
|
|
||||||
description={language.t("settings.general.row.showFileTree.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-file-tree">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showFileTree()}
|
|
||||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.showSearch.title")}
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
description={language.t("settings.general.row.showSearch.description")}
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
@@ -518,7 +540,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
const models = useModels()
|
const models = useModels()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const [store, setStore] = persisted(
|
const [store, setStore] = persisted(
|
||||||
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
|
||||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Show, createMemo, type Component } from "solid-js"
|
import { Show, createMemo, type Component } from "solid-js"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/servers"
|
||||||
|
|
||||||
const allServers = { type: "all" } as const
|
const allServers = { type: "all" } as const
|
||||||
type ServerOption = ServerConnection.Any | typeof allServers
|
type ServerOption = ServerConnection.Any | typeof allServers
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { getProjectAvatarVariant } from "@/context/layout"
|
import { getProjectAvatarVariant } from "@/context/layout"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/servers"
|
||||||
import { displayName } from "@/pages/layout/helpers"
|
import { displayName } from "@/pages/layout/helpers"
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
const isConfigCustom = (providerID: string) => {
|
const isConfigCustom = (providerID: string) => {
|
||||||
const provider = serverSync().data.config.provider?.[providerID]
|
const provider = serverSync.data.config.provider?.[providerID]
|
||||||
if (!provider) return false
|
if (!provider) return false
|
||||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||||
@@ -106,13 +106,13 @@ export const SettingsProvidersV2: Component<{
|
|||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
const location = props.directory ? { directory: props.directory } : undefined
|
const location = props.directory ? { directory: props.directory } : undefined
|
||||||
await serverSdk()
|
await serverSdk.api.integration
|
||||||
.api.integration.get({ integrationID: providerID, location })
|
.get({ integrationID: providerID, location })
|
||||||
.then(async (integration) => {
|
.then(async (integration) => {
|
||||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
|
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
|
||||||
)
|
)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName } from "@/context/servers"
|
||||||
import { useServerCollectionController } from "../server/server-management-controller"
|
import { useServerCollectionController } from "../server/server-management-controller"
|
||||||
import { DialogServerV2 } from "./dialog-server-v2"
|
import { DialogServerV2 } from "./dialog-server-v2"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
|||||||
@@ -692,6 +692,223 @@
|
|||||||
color: var(--v2-text-text-base);
|
color: var(--v2-text-text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-header.settings-v2-workspaces-header {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-header .settings-v2-tab-title {
|
||||||
|
font-weight: 610;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-body.settings-v2-workspaces {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-count {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-delete-all {
|
||||||
|
color: var(--v2-state-fg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--v2-background-bg-base);
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row:not(:last-child) {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row-header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-path {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-active,
|
||||||
|
.settings-v2-workspaces-more {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-sessions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 0.5px solid var(--v2-border-border-base);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--v2-background-bg-base);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session:not(:last-child) {
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session > span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-block: 48px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.settings-v2-workspaces-header {
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-body.settings-v2-workspaces {
|
||||||
|
padding: 0 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar,
|
||||||
|
.settings-v2-workspaces-main {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-path {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-active {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
import { For, Show, createMemo } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
import type { Project } from "@/types"
|
||||||
|
import {
|
||||||
|
containsDirectory,
|
||||||
|
filterWorkspaceInventory,
|
||||||
|
inspectWorkspaceDeletion,
|
||||||
|
managedWorkspaceDirectories,
|
||||||
|
mergeWorkspaceSessionInventory,
|
||||||
|
removeWorkspacesSequentially,
|
||||||
|
sessionsForWorkspace,
|
||||||
|
type WorkspaceDeleteInspection,
|
||||||
|
workspaceInventory,
|
||||||
|
} from "@/utils/workspace"
|
||||||
|
import { listAllSessions } from "@/utils/session"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
type Workspace = {
|
||||||
|
directory: string
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
project: "all",
|
||||||
|
transaction: undefined as "confirm" | "running" | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const workspaces = createMemo(() => workspaceInventory(serverSync.data.project))
|
||||||
|
const projects = createMemo(() =>
|
||||||
|
serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
|
||||||
|
)
|
||||||
|
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||||
|
const projectOptions = createMemo(() => [
|
||||||
|
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
||||||
|
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
||||||
|
])
|
||||||
|
const selectedProject = createMemo(() =>
|
||||||
|
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
||||||
|
)
|
||||||
|
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||||
|
const captureDeleteContext = () => {
|
||||||
|
const sdk = serverSDK
|
||||||
|
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||||
|
}
|
||||||
|
const loadSessions = async (context = captureDeleteContext()) => {
|
||||||
|
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||||
|
return mergeWorkspaceSessionInventory(
|
||||||
|
fetched,
|
||||||
|
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const sessionQuery = useQuery(() => ({
|
||||||
|
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
|
||||||
|
queryFn: () => loadSessions(),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
}))
|
||||||
|
const sessionsByWorkspace = createMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
workspaces().map((workspace) => [
|
||||||
|
pathKey(workspace.directory),
|
||||||
|
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
|
||||||
|
const sessionCount = (workspace: Workspace) => {
|
||||||
|
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||||
|
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||||
|
const count = workspaceSessions(workspace).length
|
||||||
|
return language.plural("settings.workspaces.sessions", count, {
|
||||||
|
count,
|
||||||
|
project: projectName(workspace.project),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const lastActive = (workspace: Workspace) => {
|
||||||
|
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||||
|
if (!updated) return undefined
|
||||||
|
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
||||||
|
}
|
||||||
|
const sessionTime = (session: SessionInfo) => {
|
||||||
|
if (!session.time.updated) return undefined
|
||||||
|
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
||||||
|
const [working, branch, sessions] = await Promise.all([
|
||||||
|
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||||
|
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||||
|
loadSessions(context),
|
||||||
|
])
|
||||||
|
const result = inspectWorkspaceDeletion({
|
||||||
|
workspace: workspace.directory,
|
||||||
|
activeDirectory: context.activeDirectory,
|
||||||
|
sessions,
|
||||||
|
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
||||||
|
})
|
||||||
|
return { result, sessions }
|
||||||
|
}
|
||||||
|
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
|
||||||
|
const messages = [
|
||||||
|
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
|
||||||
|
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
|
||||||
|
result.dirty ? language.t("workspace.status.dirty") : undefined,
|
||||||
|
].filter((message): message is string => message !== undefined)
|
||||||
|
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
|
||||||
|
}
|
||||||
|
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: inspectionMessages(result)[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
|
||||||
|
const preflight = await inspect(workspace, context)
|
||||||
|
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
|
||||||
|
blocked(preflight.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const removed = await context.sdk.api.worktree
|
||||||
|
.remove({
|
||||||
|
projectID: workspace.project.id,
|
||||||
|
directory: workspace.directory,
|
||||||
|
force,
|
||||||
|
})
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!removed) return
|
||||||
|
tabs.store.forEach((tab) => {
|
||||||
|
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||||
|
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||||
|
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||||
|
if (!directoryMatches && !worktreeMatches) return
|
||||||
|
tabs.updateDraft(tab.draftID, {
|
||||||
|
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||||
|
worktree: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
clearWorkspaceTerminals(
|
||||||
|
workspace.directory,
|
||||||
|
preflight.sessions.map((session) => session.id),
|
||||||
|
platform,
|
||||||
|
context.sdk.scope,
|
||||||
|
)
|
||||||
|
context.sync.set(
|
||||||
|
"project",
|
||||||
|
produce((draft) => {
|
||||||
|
const project = draft.find((item) => item.id === workspace.project.id)
|
||||||
|
if (!project) return
|
||||||
|
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||||
|
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||||
|
)
|
||||||
|
project.worktrees = project.worktrees.filter(
|
||||||
|
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inspectionID = 0
|
||||||
|
const releaseConfirmation = () => {
|
||||||
|
if (store.transaction === "confirm") setStore("transaction", undefined)
|
||||||
|
}
|
||||||
|
const transact = async (task: () => Promise<void>) => {
|
||||||
|
if (store.transaction !== "confirm") return
|
||||||
|
setStore("transaction", "running")
|
||||||
|
try {
|
||||||
|
await task()
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setStore("transaction", undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const confirmDelete = (workspace: Workspace) => {
|
||||||
|
if (store.transaction) return
|
||||||
|
const context = captureDeleteContext()
|
||||||
|
const current = ++inspectionID
|
||||||
|
setStore("transaction", "confirm")
|
||||||
|
void dialog.push(
|
||||||
|
() => (
|
||||||
|
<DialogDeleteWorkspace
|
||||||
|
workspace={workspace}
|
||||||
|
scope={context.sdk.scope}
|
||||||
|
inspectionID={current}
|
||||||
|
inspect={() => inspect(workspace, context)}
|
||||||
|
inspectionMessages={inspectionMessages}
|
||||||
|
onDelete={() => transact(() => remove(workspace, true, context))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
releaseConfirmation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
||||||
|
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
||||||
|
}
|
||||||
|
const confirmDeleteAll = () => {
|
||||||
|
if (store.transaction) return
|
||||||
|
const context = captureDeleteContext()
|
||||||
|
const inventory = [...filtered()]
|
||||||
|
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||||
|
setStore("transaction", "confirm")
|
||||||
|
void dialog.push(
|
||||||
|
() => (
|
||||||
|
<DialogDeleteAllWorkspaces
|
||||||
|
count={inventory.length}
|
||||||
|
project={project}
|
||||||
|
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
releaseConfirmation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||||
|
<InlineServerSelect />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body settings-v2-workspaces">
|
||||||
|
<div class="settings-v2-workspaces-toolbar">
|
||||||
|
<span class="settings-v2-workspaces-count">
|
||||||
|
{language.plural("settings.workspaces.count", filtered().length)}
|
||||||
|
</span>
|
||||||
|
<div class="settings-v2-workspaces-toolbar-actions">
|
||||||
|
<Show when={projects().length > 1}>
|
||||||
|
<MenuV2 placement="bottom-end" gutter={6}>
|
||||||
|
<MenuV2.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
|
||||||
|
<span class="min-w-0 truncate">
|
||||||
|
{projectOptions().find((option) => option.id === selectedProject())?.label}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content>
|
||||||
|
<For each={projectOptions()}>
|
||||||
|
{(option) => (
|
||||||
|
<MenuV2.Item onSelect={() => setStore("project", option.id)}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
|
<Show when={selectedProject() === option.id}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</Show>
|
||||||
|
<Show when={filtered().length > 0}>
|
||||||
|
<MenuV2 placement="bottom-end" gutter={4}>
|
||||||
|
<MenuV2.Trigger
|
||||||
|
as={IconButtonV2}
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
aria-label={language.t("common.moreOptions")}
|
||||||
|
disabled={!!store.transaction}
|
||||||
|
icon={<Icon name="outline-dots" size="small" />}
|
||||||
|
/>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content>
|
||||||
|
<MenuV2.Item onSelect={confirmDeleteAll}>
|
||||||
|
<span class="settings-v2-workspaces-delete-all">
|
||||||
|
{language.t("settings.workspaces.deleteAll")}
|
||||||
|
</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-workspaces-inventory">
|
||||||
|
<Show
|
||||||
|
when={filtered().length > 0}
|
||||||
|
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
||||||
|
>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={filtered()}>
|
||||||
|
{(workspace) => {
|
||||||
|
const linked = () => workspaceSessions(workspace)
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-workspaces-row">
|
||||||
|
<div class="settings-v2-workspaces-row-header">
|
||||||
|
<div class="settings-v2-workspaces-copy">
|
||||||
|
<div class="settings-v2-workspaces-main">
|
||||||
|
<TooltipV2
|
||||||
|
value={workspace.directory}
|
||||||
|
placement="top-start"
|
||||||
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
tabIndex={0}
|
||||||
|
dir="ltr"
|
||||||
|
aria-label={workspace.directory}
|
||||||
|
class="settings-v2-workspaces-path"
|
||||||
|
>
|
||||||
|
{workspace.directory}
|
||||||
|
</span>
|
||||||
|
</TooltipV2>
|
||||||
|
</div>
|
||||||
|
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-workspaces-row-actions">
|
||||||
|
<Show when={lastActive(workspace)}>
|
||||||
|
{(value) => (
|
||||||
|
<TooltipV2
|
||||||
|
value={language.t("settings.workspaces.lastActiveSession")}
|
||||||
|
placement="top-end"
|
||||||
|
>
|
||||||
|
<span tabIndex={0} class="settings-v2-workspaces-active">
|
||||||
|
{value()}
|
||||||
|
</span>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
aria-label={language.t("workspace.delete.confirm", {
|
||||||
|
name: getFilename(workspace.directory),
|
||||||
|
})}
|
||||||
|
disabled={!!store.transaction}
|
||||||
|
icon={<Icon name="trash" size="small" />}
|
||||||
|
onClick={() => confirmDelete(workspace)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={linked().length > 0}>
|
||||||
|
<div class="settings-v2-workspaces-sessions">
|
||||||
|
<For each={linked()}>
|
||||||
|
{(session) => (
|
||||||
|
<div class="settings-v2-workspaces-session">
|
||||||
|
<span>{session.title}</span>
|
||||||
|
<Show when={sessionTime(session)}>
|
||||||
|
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const remove = () => {
|
||||||
|
const deleting = props.onDelete()
|
||||||
|
dialog.close()
|
||||||
|
void deleting
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("settings.workspaces.deleteAll")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||||
|
<br />
|
||||||
|
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
||||||
|
{language.t("settings.workspaces.deleteAll")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDeleteWorkspace(props: {
|
||||||
|
workspace: Workspace
|
||||||
|
scope: ServerScope
|
||||||
|
inspectionID: number
|
||||||
|
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
|
||||||
|
inspectionMessages: (result: WorkspaceDeleteInspection) => string[]
|
||||||
|
onDelete: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const status = useQuery(() => ({
|
||||||
|
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
||||||
|
queryFn: props.inspect,
|
||||||
|
staleTime: 0,
|
||||||
|
}))
|
||||||
|
const descriptions = () => {
|
||||||
|
if (status.isPending) return [language.t("workspace.status.checking")]
|
||||||
|
if (status.isError) return [language.t("workspace.status.error")]
|
||||||
|
if (!status.data) return []
|
||||||
|
return props.inspectionMessages(status.data.result)
|
||||||
|
}
|
||||||
|
const remove = () => {
|
||||||
|
const deleting = props.onDelete()
|
||||||
|
dialog.close()
|
||||||
|
void deleting
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("workspace.delete.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||||
|
<br />
|
||||||
|
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||||
|
{props.workspace.directory}
|
||||||
|
</code>
|
||||||
|
<br />
|
||||||
|
{language.t("settings.workspaces.delete.warning")}
|
||||||
|
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
disabled={status.isPending || status.isError || status.data?.result.active}
|
||||||
|
onClick={remove}
|
||||||
|
>
|
||||||
|
{language.t("workspace.delete.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import { createEffect, createMemo, createResource, For, type JSXElement, onClean
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServers } from "@/context/servers"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth } from "@/utils/server-health"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
@@ -103,9 +104,6 @@ type ServerStatusItem = {
|
|||||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const global = useGlobal()
|
|
||||||
const server = useServer()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
@@ -117,17 +115,10 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let dialogRun = 0
|
let dialogRun = 0
|
||||||
let dialogDead = false
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
dialogDead = true
|
|
||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const sortedServers = createMemo(() => {
|
|
||||||
const list = global.servers.list()
|
|
||||||
return listServersByHealth(list, server.key, global.servers.health)
|
|
||||||
})
|
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
|
||||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServers } from "@/context/servers"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
hasServiceNeedingAttention,
|
hasServiceNeedingAttention,
|
||||||
serverStatusDotClass,
|
serverStatusDotClass,
|
||||||
} from "./status-popover-indicator"
|
} from "./status-popover-indicator"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
|
|
||||||
@@ -82,16 +83,12 @@ export function StatusPopover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopoverV2() {
|
export function StatusPopoverV2() {
|
||||||
return <DirectoryStatusPopover />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DirectoryStatusPopover() {
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServerSDK()
|
const server = useServer()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const attention = createMemo(() =>
|
const attention = createMemo(() =>
|
||||||
hasServiceNeedingAttention({
|
hasServiceNeedingAttention({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/servers"
|
||||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
|
||||||
|
|
||||||
const remote = "remote" as ServerConnection.Key
|
const remote = "remote" as ServerConnection.Key
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
|
||||||
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
|
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
|
||||||
|
|
||||||
export type SessionTabsRemovedDetail = {
|
export type SessionTabsRemovedDetail = {
|
||||||
server?: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
directory: string
|
directory: string
|
||||||
sessionIDs: string[]
|
sessionIDs: string[]
|
||||||
}
|
}
|
||||||
@@ -19,16 +19,15 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
|
|||||||
if (!detail || typeof detail !== "object") return undefined
|
if (!detail || typeof detail !== "object") return undefined
|
||||||
if (!("directory" in detail)) return undefined
|
if (!("directory" in detail)) return undefined
|
||||||
if (!("sessionIDs" in detail)) return undefined
|
if (!("sessionIDs" in detail)) return undefined
|
||||||
|
if (!("server" in detail) || typeof detail.server !== "string") return undefined
|
||||||
if (typeof detail.directory !== "string") return undefined
|
if (typeof detail.directory !== "string") return undefined
|
||||||
if (!Array.isArray(detail.sessionIDs)) return undefined
|
if (!Array.isArray(detail.sessionIDs)) return undefined
|
||||||
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
|
|
||||||
|
|
||||||
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
|
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
|
||||||
if (sessionIDs.length === 0) return undefined
|
if (sessionIDs.length === 0) return undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server:
|
server: ServerConnection.Key.make(detail.server),
|
||||||
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
|
|
||||||
directory: detail.directory,
|
directory: detail.directory,
|
||||||
sessionIDs,
|
sessionIDs,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|||||||
import { createMutation } from "@tanstack/solid-query"
|
import { createMutation } from "@tanstack/solid-query"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal, useServerCtx } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection, serverName } from "@/context/server"
|
import { ServerConnection, serverName, useServers } from "@/context/servers"
|
||||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
@@ -46,11 +46,8 @@ export function TabNavItem(props: {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
const global = useGlobal()
|
const servers = useServers()
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
|
||||||
if (conn) return global.ensureServerCtx(conn)
|
|
||||||
})
|
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
const session = props.session
|
const session = props.session
|
||||||
if (!session) return
|
if (!session) return
|
||||||
@@ -74,8 +71,8 @@ export function TabNavItem(props: {
|
|||||||
})
|
})
|
||||||
// Only label the server when multiple servers are connected.
|
// Only label the server when multiple servers are connected.
|
||||||
const serverLabel = createMemo(() => {
|
const serverLabel = createMemo(() => {
|
||||||
if (global.servers.list().length <= 1) return
|
if (servers.list.length <= 1) return
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
|
||||||
return conn ? serverName(conn) : undefined
|
return conn ? serverName(conn) : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -251,6 +248,7 @@ export function TabNavItem(props: {
|
|||||||
}}
|
}}
|
||||||
data-slot="tab-title"
|
data-slot="tab-title"
|
||||||
data-titlebar-tab-title
|
data-titlebar-tab-title
|
||||||
|
dir="auto"
|
||||||
class="min-w-0 flex-1 outline-none leading-4"
|
class="min-w-0 flex-1 outline-none leading-4"
|
||||||
classList={{
|
classList={{
|
||||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
|||||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||||
import { arrayMove } from "@dnd-kit/helpers"
|
import { arrayMove } from "@dnd-kit/helpers"
|
||||||
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||||
import { useGlobal, type ServerCtx } from "@/context/global"
|
import { useGlobal, useServerCtx, type ServerCtx } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -339,10 +339,9 @@ export function TitlebarTabStrip(props: {
|
|||||||
let ref!: HTMLDivElement
|
let ref!: HTMLDivElement
|
||||||
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
|
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
|
||||||
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
|
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = useServerCtx(() => {
|
||||||
if (tab.type !== "session") return
|
if (tab.type !== "session") return
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||||
if (conn) return global.ensureServerCtx(conn)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (tab.type === "session") {
|
if (tab.type === "session") {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
|
|||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal, useServerCtx } from "@/context/global"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServers } from "@/context/servers"
|
||||||
import { tabKey, useTabs } from "@/context/tabs"
|
import { tabKey, useTabs } from "@/context/tabs"
|
||||||
import type { PromptSession } from "@/context/prompt"
|
import type { PromptSession } from "@/context/prompt"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
@@ -61,15 +61,13 @@ export function useTitlebarRightMount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
|
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||||
const layout = useLayout()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const server = useServer()
|
const servers = useServers()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const params = useParams()
|
|
||||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||||
@@ -77,11 +75,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const web = createMemo(() => platform.platform === "web")
|
|
||||||
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
|
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
|
||||||
const zoom = () => platform.webviewZoom?.() ?? 1
|
const zoom = () => platform.webviewZoom?.() ?? 1
|
||||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
|
||||||
const minHeight = () => {
|
const minHeight = () => {
|
||||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||||
if (mac()) return `${height / zoom()}px`
|
if (mac()) return `${height / zoom()}px`
|
||||||
@@ -97,14 +93,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
})
|
})
|
||||||
|
|
||||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||||
const creating = createMemo(() => {
|
|
||||||
const route = layout.route()
|
|
||||||
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
|
|
||||||
if (!params.dir) return false
|
|
||||||
if (params.id) return false
|
|
||||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
|
||||||
return parts.at(-1) === "session"
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = path()
|
const current = path()
|
||||||
@@ -116,10 +104,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const canBack = createMemo(() => history.index > 0)
|
|
||||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
|
||||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
|
||||||
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
|
|
||||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||||
const installing = props.update?.installing ?? false
|
const installing = props.update?.installing ?? false
|
||||||
const version = props.update?.version
|
const version = props.update?.version
|
||||||
@@ -188,7 +172,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={useV2Titlebar()}>
|
<Match when>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
@@ -200,9 +184,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
() => {
|
() => {
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
if (route.type !== "session") return undefined
|
if (route.type !== "session") return undefined
|
||||||
const conn = global.servers
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||||
.list()
|
|
||||||
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
|
|
||||||
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
|
||||||
},
|
},
|
||||||
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
|
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
|
||||||
@@ -245,7 +227,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
const s = session()
|
const s = session()
|
||||||
if (!s) return
|
if (!s) return
|
||||||
const sessionId = s.parentID ?? s.id
|
const sessionId = s.parentID ?? s.id
|
||||||
const next = { server: route.server ?? server.key, sessionId }
|
const next = { server: route.server, sessionId }
|
||||||
tabsStoreActions.addSessionTab(next)
|
tabsStoreActions.addSessionTab(next)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -258,53 +240,43 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
|
|
||||||
const openNewTab = () => {
|
const openNewTab = () => {
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
const activeSession = session()
|
switch (route.type) {
|
||||||
if (route.type === "session" && activeSession) {
|
case "session": {
|
||||||
const sessionTab = {
|
const activeSession = session()
|
||||||
type: "session" as const,
|
if (!activeSession) return
|
||||||
server: route.server ?? server.key,
|
|
||||||
sessionId: activeSession.id,
|
|
||||||
}
|
|
||||||
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
|
||||||
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTab = currentTab()
|
const sessionTab = {
|
||||||
if (activeTab?.type === "draft") {
|
type: "session" as const,
|
||||||
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
server: route.server,
|
||||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
sessionId: activeSession.id,
|
||||||
return
|
}
|
||||||
}
|
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||||
|
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
|
||||||
if (route.type === "home") {
|
|
||||||
const selection = layout.home.selection()
|
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
|
|
||||||
const project = conn
|
|
||||||
? global
|
|
||||||
.ensureServerCtx(conn)
|
|
||||||
.projects.list()
|
|
||||||
.find((item) => item.worktree === selection.directory)
|
|
||||||
: undefined
|
|
||||||
if (conn && project) {
|
|
||||||
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "draft": {
|
||||||
|
const activeTab = currentTab()
|
||||||
|
if (activeTab?.type !== "draft") return
|
||||||
|
|
||||||
|
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||||
|
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "home": {
|
||||||
|
const selection = layout.home.selection()
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
|
||||||
|
const project = conn
|
||||||
|
? global
|
||||||
|
.ensureServerCtx(conn)
|
||||||
|
.projects.list()
|
||||||
|
.find((item) => item.worktree === selection.directory)
|
||||||
|
: undefined
|
||||||
|
if (conn && project) {
|
||||||
|
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = layout.projects.list()[0]
|
|
||||||
if (current) {
|
|
||||||
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = global.servers.list().flatMap((conn) => {
|
|
||||||
const project = global.ensureServerCtx(conn).projects.list()[0]
|
|
||||||
return project ? [{ server: ServerConnection.key(conn), project }] : []
|
|
||||||
})[0]
|
|
||||||
if (!fallback) return
|
|
||||||
|
|
||||||
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
|
|
||||||
}
|
}
|
||||||
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
|
||||||
|
|
||||||
@@ -430,158 +402,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when>
|
|
||||||
<div
|
|
||||||
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
|
||||||
style={{ zoom: counterZoom() }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"flex items-center min-w-0": true,
|
|
||||||
"pl-2": !macTrafficLights(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={windows() || linux()}>
|
|
||||||
<WindowsAppMenu command={command} platform={platform} />
|
|
||||||
</Show>
|
|
||||||
<Show when={mac()}>
|
|
||||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
|
||||||
<IconButton
|
|
||||||
icon="menu"
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon rounded-md"
|
|
||||||
onClick={layout.mobileSidebar.toggle}
|
|
||||||
aria-label={language.t("sidebar.menu.toggle")}
|
|
||||||
aria-expanded={layout.mobileSidebar.opened()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={!mac()}>
|
|
||||||
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
|
|
||||||
<IconButton
|
|
||||||
icon="menu"
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon rounded-md"
|
|
||||||
onClick={layout.mobileSidebar.toggle}
|
|
||||||
aria-label={language.t("sidebar.menu.toggle")}
|
|
||||||
aria-expanded={layout.mobileSidebar.opened()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<TooltipKeybind
|
|
||||||
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
|
|
||||||
placement="bottom"
|
|
||||||
title={language.t("command.sidebar.toggle")}
|
|
||||||
keybind={command.keybind("sidebar.toggle")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
|
||||||
onClick={layout.sidebar.toggle}
|
|
||||||
aria-label={language.t("command.sidebar.toggle")}
|
|
||||||
aria-expanded={layout.sidebar.opened()}
|
|
||||||
>
|
|
||||||
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
<div class="hidden xl:flex items-center shrink-0">
|
|
||||||
<Show when={params.dir}>
|
|
||||||
<div
|
|
||||||
class="flex items-center shrink-0 w-8 mr-1"
|
|
||||||
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
|
||||||
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="bottom"
|
|
||||||
title={language.t("command.session.new")}
|
|
||||||
keybind={command.keybind("session.new")}
|
|
||||||
openDelay={800}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
|
||||||
disabled={layout.sidebar.opened()}
|
|
||||||
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
|
||||||
onClick={() => {
|
|
||||||
if (!params.dir) return
|
|
||||||
navigate(`/${params.dir}/session`)
|
|
||||||
}}
|
|
||||||
aria-label={language.t("command.session.new")}
|
|
||||||
aria-current={creating() ? "page" : undefined}
|
|
||||||
>
|
|
||||||
<IconV2 name="edit" size="small" />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div
|
|
||||||
class="flex items-center shrink-0"
|
|
||||||
classList={{
|
|
||||||
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
|
|
||||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
|
||||||
"duration-180 ease-in": layout.sidebar.opened(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={hasProjects() && nav()}>
|
|
||||||
<div class="flex items-center gap-0 transition-transform">
|
|
||||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
icon="chevron-left"
|
|
||||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
|
||||||
disabled={!canBack()}
|
|
||||||
onClick={back}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
icon="chevron-right"
|
|
||||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
|
||||||
disabled={!canForward()}
|
|
||||||
onClick={forward}
|
|
||||||
aria-label={language.t("common.goForward")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChannelIndicator debugTools={props.debugTools} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
|
||||||
<div
|
|
||||||
id="opencode-titlebar-center"
|
|
||||||
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"flex items-center min-w-0 justify-end": true,
|
|
||||||
"pr-2": !windows(),
|
|
||||||
}}
|
|
||||||
data-tauri-drag-region
|
|
||||||
>
|
|
||||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
|
||||||
<Show when={windows()}>
|
|
||||||
<div class="shrink-0" style={{ width: windowsControlsWidth() }} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||||||
const decoded = decodeSessionKey(key)
|
const decoded = decodeSessionKey(key)
|
||||||
return createRoot((dispose) => ({
|
return createRoot((dispose) => ({
|
||||||
value: createCommentSession(
|
value: createCommentSession(
|
||||||
serverSDK().scope,
|
serverSDK.scope,
|
||||||
decoded.dir,
|
decoded.dir,
|
||||||
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
const scope = createMemo(() => sdk().directory)
|
const scope = createMemo(() => sdk().directory)
|
||||||
const path = createPathHelpers(scope)
|
const path = createPathHelpers(scope)
|
||||||
const tabs = layout.tabs(() =>
|
const tabs = layout.tabs(() =>
|
||||||
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
|
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
@@ -123,7 +123,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewCache = createFileViewCache(serverSDK().scope)
|
const viewCache = createFileViewCache(serverSDK.scope)
|
||||||
const view = createMemo(() => viewCache.load(scope(), params.id))
|
const view = createMemo(() => viewCache.load(scope(), params.id))
|
||||||
|
|
||||||
const ensure = (file: string) => {
|
const ensure = (file: string) => {
|
||||||
@@ -211,8 +211,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||||
serverSDK()
|
serverSDK.api.file
|
||||||
.api.file.find(
|
.find(
|
||||||
{
|
{
|
||||||
location: { directory: sdk().directory },
|
location: { directory: sdk().directory },
|
||||||
query,
|
query,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type ProjectApi = ServerApi["project"]
|
type ProjectApi = ServerApi["project"]
|
||||||
|
type WorktreeApi = ServerApi["worktree"]
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
@@ -106,16 +107,58 @@ describe("query keys", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("loads projects from the current endpoint", async () => {
|
test("loads projects from the current endpoint", async () => {
|
||||||
const api = {
|
const calls: string[] = []
|
||||||
|
const projects = {
|
||||||
list: async () => [
|
list: async () => [
|
||||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
],
|
],
|
||||||
} as unknown as ProjectApi
|
} as unknown as ProjectApi
|
||||||
|
const worktrees = {
|
||||||
|
list: async ({ projectID }: { projectID: string }) => {
|
||||||
|
calls.push(projectID)
|
||||||
|
return [
|
||||||
|
{ directory: `/${projectID}` },
|
||||||
|
{ directory: `/${projectID}/clone` },
|
||||||
|
{ directory: `/${projectID}/copy`, strategy: "git" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
} as unknown as WorktreeApi
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||||
|
|
||||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||||
|
expect(result.map((project) => project.sandboxes)).toEqual([
|
||||||
|
["/a/clone", "/a/copy"],
|
||||||
|
["/b/clone", "/b/copy"],
|
||||||
|
])
|
||||||
|
expect(result.map((project) => project.worktrees)).toEqual([
|
||||||
|
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
|
||||||
|
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
|
||||||
|
])
|
||||||
|
expect(calls.toSorted()).toEqual(["a", "b"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps projects whose directory inventory cannot load", async () => {
|
||||||
|
const projects = {
|
||||||
|
list: async () => [
|
||||||
|
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
|
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
|
],
|
||||||
|
} as unknown as ProjectApi
|
||||||
|
const worktrees = {
|
||||||
|
list: async ({ projectID }: { projectID: string }) => {
|
||||||
|
if (projectID === "b") throw new Error("unavailable")
|
||||||
|
return [{ directory: "/a/copy", strategy: "git" as const }]
|
||||||
|
},
|
||||||
|
} as unknown as WorktreeApi
|
||||||
|
|
||||||
|
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||||
|
|
||||||
|
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
|
||||||
|
{ id: "a", sandboxes: ["/a/copy"] },
|
||||||
|
{ id: "b", sandboxes: [] },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads references from the current location-scoped endpoint", async () => {
|
test("loads references from the current location-scoped endpoint", async () => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
|||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
import { sameDirectory } from "@/utils/workspace"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -106,6 +107,7 @@ type ProjectApi = {
|
|||||||
readonly list: () => Promise<ProjectListOutput>
|
readonly list: () => Promise<ProjectListOutput>
|
||||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||||
}
|
}
|
||||||
|
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
type McpApi = ServerApi["mcp"]
|
||||||
@@ -113,15 +115,35 @@ type PermissionApi = ServerApi["permission"]
|
|||||||
type QuestionApi = ServerApi["question"]
|
type QuestionApi = ServerApi["question"]
|
||||||
type VcsApi = ServerApi["vcs"]
|
type VcsApi = ServerApi["vcs"]
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
api.list().then((projects) => {
|
projects.list().then(async (items) => {
|
||||||
return projects
|
return (
|
||||||
.filter((p) => !!p?.id)
|
await Promise.all(
|
||||||
.map(normalizeProjectInfo)
|
items
|
||||||
|
.filter((project) => !!project?.id)
|
||||||
|
.map(async (project) => {
|
||||||
|
const directories = await worktrees
|
||||||
|
.list({ projectID: project.id })
|
||||||
|
.catch(() => [
|
||||||
|
{ directory: project.canonical },
|
||||||
|
...(project.sandboxes ?? [])
|
||||||
|
.filter((directory) => !sameDirectory(project.canonical, directory))
|
||||||
|
.map((directory) => ({ directory })),
|
||||||
|
])
|
||||||
|
return normalizeProjectInfo({
|
||||||
|
...project,
|
||||||
|
sandboxes: directories
|
||||||
|
.map((item) => item.directory)
|
||||||
|
.filter((directory) => !sameDirectory(project.canonical, directory)),
|
||||||
|
worktrees: directories,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
)
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
@@ -130,7 +152,11 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
serverAPI: CatalogApi & {
|
||||||
|
readonly location: LocationApi
|
||||||
|
readonly project: ProjectApi
|
||||||
|
readonly worktree: WorktreeApi
|
||||||
|
}
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
@@ -144,7 +170,7 @@ export async function bootstrapGlobal(input: {
|
|||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { Message, Part, Project, Todo } from "@/types"
|
import type { Message, Part, Project, Todo } from "@/types"
|
||||||
import type {
|
import type {
|
||||||
@@ -187,10 +187,10 @@ export function applyDirectoryEvent(input: {
|
|||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "project.directory.resolved": {
|
case "worktree.resolved": {
|
||||||
const properties = event.properties as { projectID: string; directory: string; previous: string }
|
const properties = event.properties as { projectID: string; directory: string; previous: string }
|
||||||
input.store.session.forEach((session, index) => {
|
input.store.session.forEach((session, index) => {
|
||||||
const adopted = ProjectDirectories.adopt(
|
const adopted = Worktree.adopt(
|
||||||
{ projectID: session.projectID, directory: session.location.directory },
|
{ projectID: session.projectID, directory: session.location.directory },
|
||||||
properties,
|
properties,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -124,9 +124,11 @@ export function normalizeProviderList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||||
|
const worktree = "canonical" in project ? project.canonical : project.worktree
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
worktree: "canonical" in project ? project.canonical : project.worktree,
|
worktree,
|
||||||
|
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
|
||||||
vcs: project.vcs === "git" ? "git" : undefined,
|
vcs: project.vcs === "git" ? "git" : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createEffect, createMemo, createRoot } from "solid-js"
|
import { Accessor, createEffect, createMemo, createRoot } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./servers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { useServerHealth } from "@/utils/server-health"
|
import { useServerHealth } from "@/utils/server-health"
|
||||||
import { createServerSdkContext } from "./server-sdk"
|
import { createServerSdkContext } from "./server-sdk"
|
||||||
import { createServerSyncContext } from "./server-sync"
|
import { createServerSyncContext } from "./server-sync"
|
||||||
import { getOwner } from "solid-js/web"
|
import { getOwner } from "solid-js/web"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { createServerPermissionState } from "./permission"
|
||||||
|
import { createServerNotificationState } from "./notification"
|
||||||
|
|
||||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||||
name: "Global",
|
name: "Global",
|
||||||
init: () => {
|
init: () => {
|
||||||
const server = useServer()
|
const server = useServers()
|
||||||
const serverHealth = useServerHealth(
|
const serverHealth = useServerHealth(
|
||||||
() => server.list,
|
() => server.list,
|
||||||
() => true,
|
() => true,
|
||||||
@@ -34,23 +36,21 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
|||||||
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
|
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
|
||||||
})
|
})
|
||||||
|
|
||||||
const serverCtxs = new Map<
|
const serverCtxs = new Map<ServerConnection.Key, ReturnType<typeof createServerController>>()
|
||||||
ServerConnection.Key,
|
const serverCtxDisposers = new Map<ServerConnection.Key, () => void>()
|
||||||
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
|
|
||||||
>()
|
|
||||||
|
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
|
|
||||||
const ensureServerCtx = (conn: ServerConnection.Any) => {
|
const ensureServerCtx = (conn: ServerConnection.Any) => {
|
||||||
const key = ServerConnection.key(conn)
|
const key = ServerConnection.key(conn)
|
||||||
const existing = serverCtxs.get(key)
|
const existing = serverCtxs.get(key)
|
||||||
if (existing) return existing.serverCtx
|
if (existing) return existing
|
||||||
const root = createRoot((dispose) => {
|
const serverCtx = createRoot((dispose) => {
|
||||||
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
|
serverCtxDisposers.set(key, dispose)
|
||||||
return { dispose, serverCtx }
|
return createServerController(conn, server.scope(key), server.projects.forServer(key))
|
||||||
}, owner as any)
|
}, owner as any)
|
||||||
serverCtxs.set(key, root)
|
serverCtxs.set(key, serverCtx)
|
||||||
return root.serverCtx
|
return serverCtx
|
||||||
}
|
}
|
||||||
|
|
||||||
createMemo(() => {
|
createMemo(() => {
|
||||||
@@ -62,8 +62,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
for (const [key] of serverCtxs) {
|
for (const [key] of serverCtxs) {
|
||||||
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
|
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
|
||||||
const { dispose } = serverCtxs.get(key)!
|
serverCtxDisposers.get(key)?.()
|
||||||
dispose()
|
serverCtxDisposers.delete(key)
|
||||||
serverCtxs.delete(key)
|
serverCtxs.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,13 +92,16 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function createServerCtx(
|
function createServerController(
|
||||||
conn: ServerConnection.Any,
|
conn: ServerConnection.Any,
|
||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
projects: ReturnType<typeof createServerProjects>,
|
projects: ReturnType<typeof createServerProjects>,
|
||||||
) {
|
) {
|
||||||
|
const connKey = ServerConnection.key(conn)
|
||||||
const sdk = createServerSdkContext(conn, scope)
|
const sdk = createServerSdkContext(conn, scope)
|
||||||
const sync = createServerSyncContext(sdk)
|
const sync = createServerSyncContext(sdk)
|
||||||
|
const permission = createServerPermissionState({ sdk, sync })
|
||||||
|
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||||
@@ -139,10 +142,22 @@ function createServerCtx(
|
|||||||
list: projectsList,
|
list: projectsList,
|
||||||
recentlyClosed: recentlyClosedList,
|
recentlyClosed: recentlyClosedList,
|
||||||
},
|
},
|
||||||
|
permission,
|
||||||
|
notification,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerCtx = ReturnType<typeof createServerCtx>
|
export function useServerCtx(server: Accessor<ServerConnection.Any>): Accessor<ServerCtx>
|
||||||
|
export function useServerCtx(server: Accessor<ServerConnection.Any | undefined>): Accessor<ServerCtx | undefined>
|
||||||
|
export function useServerCtx(server: Accessor<ServerConnection.Any | undefined>) {
|
||||||
|
const global = useGlobal()
|
||||||
|
return () => {
|
||||||
|
const s = server()
|
||||||
|
if (s) return global.ensureServerCtx(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerCtx = ReturnType<typeof createServerController>
|
||||||
|
|
||||||
function isLocalHost(url: string) {
|
function isLocalHost(url: string) {
|
||||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
|
|||||||
import { createEffect, createMemo, createResource } 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 { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
import {
|
||||||
|
pluralCategory,
|
||||||
|
type UiI18nPluralLookupKey,
|
||||||
|
type UiI18nPluralKey,
|
||||||
|
type UiPluralCategory,
|
||||||
|
} from "@opencode-ai/ui/context/i18n"
|
||||||
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 uiEn } from "@opencode-ai/ui/i18n/en"
|
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||||
@@ -20,7 +25,7 @@ import {
|
|||||||
export type Locale = DesktopNativeLocale
|
export type Locale = DesktopNativeLocale
|
||||||
export type Direction = "ltr" | "rtl"
|
export type Direction = "ltr" | "rtl"
|
||||||
|
|
||||||
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"])
|
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "he", "ur", "pa", "fa", "dv"])
|
||||||
|
|
||||||
function localeDirection(locale: Locale): Direction {
|
function localeDirection(locale: Locale): Direction {
|
||||||
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
||||||
@@ -28,13 +33,17 @@ function localeDirection(locale: Locale): Direction {
|
|||||||
|
|
||||||
type RawDictionary = typeof en & typeof uiEn
|
type RawDictionary = typeof en & typeof uiEn
|
||||||
type Dictionary = i18n.Flatten<RawDictionary>
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
type PluralKey =
|
type AppI18nKey = Extract<keyof typeof en, string>
|
||||||
| UiI18nPluralKey
|
type AppI18nPluralKey = {
|
||||||
| "session.question.pending"
|
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||||
| "session.followupDock.summary"
|
}[AppI18nKey]
|
||||||
| "session.revertDock.summary"
|
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||||
| "session.background.shell"
|
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||||
| "session.background.subagent"
|
type TranslationKey<Key extends Extract<keyof Dictionary, string>> = Key extends
|
||||||
|
| AppI18nPluralLookupKey
|
||||||
|
| UiI18nPluralLookupKey
|
||||||
|
? never
|
||||||
|
: Key
|
||||||
type Source = { dict: Record<string, string> }
|
type Source = { dict: Record<string, string> }
|
||||||
|
|
||||||
function cookie(locale: Locale) {
|
function cookie(locale: Locale) {
|
||||||
@@ -64,6 +73,7 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
|
|||||||
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
|
||||||
uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
|
uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
|
||||||
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
|
||||||
|
he: () => merge(import("@/i18n/he"), import("@opencode-ai/ui/i18n/he")),
|
||||||
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
|
||||||
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
|
||||||
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
|
||||||
@@ -191,18 +201,25 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
initialValue: dicts.get(initial) ?? base,
|
initialValue: dicts.get(initial) ?? base,
|
||||||
})
|
})
|
||||||
|
|
||||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <
|
||||||
key: keyof Dictionary,
|
Key extends Extract<keyof Dictionary, string>,
|
||||||
|
>(
|
||||||
|
key: TranslationKey<Key>,
|
||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
) => string
|
) => string
|
||||||
|
|
||||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
const pluralForm = (
|
||||||
const category = pluralCategory(intl(), count)
|
key: PluralKey,
|
||||||
|
category: UiPluralCategory,
|
||||||
|
params?: Record<string, string | number | boolean>,
|
||||||
|
) => {
|
||||||
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||||
const candidate = `${key}.${category}`
|
const candidate = `${key}.${category}`
|
||||||
const fallback = `${key}.other`
|
const fallback = `${key}.other`
|
||||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
|
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
|
||||||
}
|
}
|
||||||
|
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
|
||||||
|
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
|
||||||
|
|
||||||
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
||||||
|
|
||||||
@@ -233,6 +250,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
label,
|
label,
|
||||||
t,
|
t,
|
||||||
plural,
|
plural,
|
||||||
|
pluralForm,
|
||||||
setLocale(next: Locale) {
|
setLocale(next: Locale) {
|
||||||
setStore("locale", normalizeLocale(next))
|
setStore("locale", normalizeLocale(next))
|
||||||
},
|
},
|
||||||
|
|||||||
+103
-247
@@ -4,13 +4,10 @@ import { useLocation } from "@solidjs/router"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useServerSync } from "./server-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { ServerConnection, useServers } from "./servers"
|
||||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import type { Project } from "@/types"
|
import type { Project } from "@/types"
|
||||||
import { normalizeProjectInfo } from "./global-sync/utils"
|
|
||||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||||
@@ -19,7 +16,6 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
|||||||
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
||||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import { type DraftTab, useTabs } from "./tabs"
|
|
||||||
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
|
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
|
||||||
|
|
||||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||||
@@ -75,13 +71,6 @@ type SessionView = {
|
|||||||
todoCollapsed?: boolean
|
todoCollapsed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabHandoff = {
|
|
||||||
scope: ServerScope
|
|
||||||
dir: string
|
|
||||||
id: string
|
|
||||||
at: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||||
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
|
||||||
|
|
||||||
@@ -91,9 +80,8 @@ export type ReviewPanelSource = "context-button" | "other"
|
|||||||
|
|
||||||
export type LayoutRoute =
|
export type LayoutRoute =
|
||||||
| { type: "home" }
|
| { type: "home" }
|
||||||
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
|
| { type: "draft"; draftID: string }
|
||||||
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
| { type: "session"; sessionId: string; server: ServerConnection.Key }
|
||||||
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
|
|
||||||
|
|
||||||
const sessionPath = (key: string) => {
|
const sessionPath = (key: string) => {
|
||||||
const dir = SessionStateKey.route(key).split("/")[0]
|
const dir = SessionStateKey.route(key).split("/")[0]
|
||||||
@@ -148,34 +136,23 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
|||||||
const dirBase64 = parts[0]
|
const dirBase64 = parts[0]
|
||||||
const dir = decode64(dirBase64)
|
const dir = decode64(dirBase64)
|
||||||
if (!dir) return { type: "home" }
|
if (!dir) return { type: "home" }
|
||||||
|
if (parts[1] === "session") return { type: "home" }
|
||||||
|
|
||||||
if (parts[1] !== "session") return { type: "home" }
|
throw new Error("Unrecognised route!")
|
||||||
|
}
|
||||||
|
|
||||||
const id = parts[2]
|
export const useCurrentRoute = () => {
|
||||||
if (id) return { type: "session", sessionId: id }
|
const location = useLocation()
|
||||||
return { type: "dir-new-sesssion", dir, dirBase64 }
|
return createMemo(() => currentRoute(location.pathname, location.search))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const serverSdk = useServerSDK()
|
// const serverSync = useServerSync()
|
||||||
const serverSync = useServerSync()
|
const servers = useServers()
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const location = useLocation()
|
|
||||||
const route = createMemo(() => {
|
|
||||||
const value = currentRoute(location.pathname, location.search)
|
|
||||||
if (value.type === "home") return value
|
|
||||||
if (value.server) return value
|
|
||||||
if (value.type === "draft") {
|
|
||||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
|
|
||||||
if (draft) return { ...value, server: draft.server }
|
|
||||||
}
|
|
||||||
return { ...value, server: server.key }
|
|
||||||
})
|
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
@@ -267,9 +244,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{ ...target, migrate },
|
{ ...Persist.global("layout", ["layout.v6"]), migrate },
|
||||||
createStore({
|
createStore({
|
||||||
sidebar: {
|
sidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
@@ -298,11 +274,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
sessionTabs: {} as Record<string, SessionTabs>,
|
sessionTabs: {} as Record<string, SessionTabs>,
|
||||||
sessionView: {} as Record<string, SessionView>,
|
sessionView: {} as Record<string, SessionView>,
|
||||||
handoff: {
|
|
||||||
tabs: undefined as TabHandoff | undefined,
|
|
||||||
},
|
|
||||||
home: {
|
home: {
|
||||||
selection: { server: server.key } as HomeProjectSelection,
|
selection: { server: ServerConnection.key(servers.list[0]) } as HomeProjectSelection,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -442,168 +415,116 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
return available[Math.floor(Math.random() * available.length)]
|
return available[Math.floor(Math.random() * available.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
// function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
|
// const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
|
||||||
const projectID = childStore.project
|
// const projectID = childStore.project
|
||||||
const metadata = projectID
|
// const metadata = projectID
|
||||||
? serverSync().data.project.find((x) => x.id === projectID)
|
// ? serverSync.data.project.find((x) => x.id === projectID)
|
||||||
: serverSync().data.project.find((x) => x.worktree === project.worktree)
|
// : serverSync.data.project.find((x) => x.worktree === project.worktree)
|
||||||
|
|
||||||
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
// // Preserve local icon override from per-workspace localStorage cache (childStore.icon).
|
||||||
// Without this, different subdirectories of the same git repo would share the same
|
// // Without this, different subdirectories of the same git repo would share the same
|
||||||
// icon from the database instead of using their individual overrides.
|
// // icon from the database instead of using their individual overrides.
|
||||||
const base = { ...metadata, ...project }
|
// const base = { ...metadata, ...project }
|
||||||
if (childStore.icon) {
|
// if (childStore.icon) {
|
||||||
return { ...base, icon: { ...base.icon, override: childStore.icon } }
|
// return { ...base, icon: { ...base.icon, override: childStore.icon } }
|
||||||
}
|
// }
|
||||||
return base
|
// return base
|
||||||
}
|
// }
|
||||||
|
|
||||||
const roots = createMemo(() => {
|
// const roots = createMemo(() => {
|
||||||
const map = new Map<string, string>()
|
// const map = new Map<string, string>()
|
||||||
for (const project of serverSync().data.project) {
|
// for (const project of serverSync.data.project) {
|
||||||
const sandboxes = project.sandboxes ?? []
|
// const sandboxes = project.sandboxes ?? []
|
||||||
for (const sandbox of sandboxes) {
|
// for (const sandbox of sandboxes) {
|
||||||
map.set(sandbox, project.worktree)
|
// map.set(sandbox, project.worktree)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
return map
|
// return map
|
||||||
})
|
// })
|
||||||
|
|
||||||
const rootFor = (directory: string) => {
|
// const rootFor = (directory: string) => {
|
||||||
const map = roots()
|
// const map = roots()
|
||||||
if (map.size === 0) return directory
|
// if (map.size === 0) return directory
|
||||||
|
|
||||||
const visited = new Set<string>()
|
// const visited = new Set<string>()
|
||||||
const chain = [directory]
|
// const chain = [directory]
|
||||||
|
|
||||||
while (chain.length) {
|
// while (chain.length) {
|
||||||
const current = chain[chain.length - 1]
|
// const current = chain[chain.length - 1]
|
||||||
if (!current) return directory
|
// if (!current) return directory
|
||||||
|
|
||||||
const next = map.get(current)
|
// const next = map.get(current)
|
||||||
if (!next) return current
|
// if (!next) return current
|
||||||
|
|
||||||
if (visited.has(next)) return directory
|
// if (visited.has(next)) return directory
|
||||||
visited.add(next)
|
// visited.add(next)
|
||||||
chain.push(next)
|
// chain.push(next)
|
||||||
}
|
// }
|
||||||
|
|
||||||
return directory
|
// return directory
|
||||||
}
|
// }
|
||||||
|
|
||||||
createEffect(() => {
|
const enriched = createMemo(() => [] /* servers.projects.list().map(enrich) */)
|
||||||
const projects = server.projects.list()
|
|
||||||
const seen = new Set(projects.map((project) => project.worktree))
|
|
||||||
|
|
||||||
batch(() => {
|
// createEffect(() => {
|
||||||
for (const project of projects) {
|
// const projects = enriched()
|
||||||
const root = rootFor(project.worktree)
|
// if (projects.length === 0) return
|
||||||
if (root === project.worktree) continue
|
// if (!serverSync.ready) return
|
||||||
|
|
||||||
server.projects.remove(project.worktree)
|
// for (const project of projects) {
|
||||||
|
// if (!project.id) continue
|
||||||
|
// if (project.id === "global") continue
|
||||||
|
// serverSync.project.icon(project.worktree, project.icon?.override)
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|
||||||
if (!seen.has(root)) {
|
// createEffect(() => {
|
||||||
server.projects.open(root)
|
// const projects = enriched()
|
||||||
seen.add(root)
|
// if (projects.length === 0) return
|
||||||
}
|
|
||||||
|
|
||||||
if (project.expanded) server.projects.expand(root)
|
// for (const project of projects) {
|
||||||
}
|
// if (project.icon?.color) colorRequested.delete(project.worktree)
|
||||||
})
|
// }
|
||||||
})
|
|
||||||
|
|
||||||
const enriched = createMemo(() => server.projects.list().map(enrich))
|
// const used = new Set<string>()
|
||||||
const list = createMemo(() => {
|
// for (const project of projects) {
|
||||||
const projects = enriched()
|
// const color = project.icon?.color ?? colors[project.worktree]
|
||||||
return projects.map((project) => {
|
// if (color) used.add(color)
|
||||||
const color = project.icon?.color ?? colors[project.worktree]
|
// }
|
||||||
if (!color) return project
|
|
||||||
const icon = project.icon ? { ...project.icon, color } : { color }
|
|
||||||
return { ...project, icon }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
// for (const project of projects) {
|
||||||
const projects = enriched()
|
// if (project.icon?.color || project.icon?.override || project.icon?.url) continue
|
||||||
if (projects.length === 0) return
|
// const worktree = project.worktree
|
||||||
if (!serverSync().ready) return
|
// const existing = colors[worktree]
|
||||||
|
// const color = existing ?? pickAvailableColor(used)
|
||||||
|
// if (!existing) {
|
||||||
|
// used.add(color)
|
||||||
|
// setColors(worktree, color)
|
||||||
|
// }
|
||||||
|
// if (!project.id) continue
|
||||||
|
|
||||||
for (const project of projects) {
|
// const requested = colorRequested.get(worktree)
|
||||||
if (!project.id) continue
|
// if (requested === color) continue
|
||||||
if (project.id === "global") continue
|
// colorRequested.set(worktree, color)
|
||||||
serverSync().project.icon(project.worktree, project.icon?.override)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
// if (project.id === "global") {
|
||||||
const projects = enriched()
|
// serverSync.project.meta(worktree, { icon: { color } })
|
||||||
if (projects.length === 0) return
|
// continue
|
||||||
|
// }
|
||||||
|
|
||||||
for (const project of projects) {
|
// const projectID = project.id
|
||||||
if (project.icon?.color) colorRequested.delete(project.worktree)
|
// void (async () => {
|
||||||
}
|
// // TODO: Restore project color updates when the V2 client exposes a project update API.
|
||||||
|
// void projectID
|
||||||
const used = new Set<string>()
|
// })().catch(() => {
|
||||||
for (const project of projects) {
|
// if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
||||||
const color = project.icon?.color ?? colors[project.worktree]
|
// })
|
||||||
if (color) used.add(color)
|
// }
|
||||||
}
|
// })
|
||||||
|
|
||||||
for (const project of projects) {
|
|
||||||
if (project.icon?.color || project.icon?.override || project.icon?.url) continue
|
|
||||||
const worktree = project.worktree
|
|
||||||
const existing = colors[worktree]
|
|
||||||
const color = existing ?? pickAvailableColor(used)
|
|
||||||
if (!existing) {
|
|
||||||
used.add(color)
|
|
||||||
setColors(worktree, color)
|
|
||||||
}
|
|
||||||
if (!project.id) continue
|
|
||||||
|
|
||||||
const requested = colorRequested.get(worktree)
|
|
||||||
if (requested === color) continue
|
|
||||||
colorRequested.set(worktree, color)
|
|
||||||
|
|
||||||
if (project.id === "global") {
|
|
||||||
serverSync().project.meta(worktree, { icon: { color } })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectID = project.id
|
|
||||||
void (async () => {
|
|
||||||
// TODO: Restore project color updates when the V2 client exposes a project update API.
|
|
||||||
void projectID
|
|
||||||
})().catch(() => {
|
|
||||||
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let sessionFrame: number | undefined
|
|
||||||
let sessionTimer: number | undefined
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
sessionFrame = requestAnimationFrame(() => {
|
|
||||||
sessionFrame = undefined
|
|
||||||
sessionTimer = window.setTimeout(() => {
|
|
||||||
sessionTimer = undefined
|
|
||||||
void Promise.all(
|
|
||||||
server.projects.list().map((project) => {
|
|
||||||
return serverSync().project.loadSessions(project.worktree)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
|
|
||||||
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
route,
|
route: useCurrentRoute(),
|
||||||
ready,
|
ready,
|
||||||
home: {
|
home: {
|
||||||
selection: createMemo(() => store.home.selection),
|
selection: createMemo(() => store.home.selection),
|
||||||
@@ -611,71 +532,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
setStore("home", "selection", reconcile(selection))
|
setStore("home", "selection", reconcile(selection))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handoff: {
|
|
||||||
tabs: createMemo(() => store.handoff?.tabs),
|
|
||||||
setTabs(dir: string, id: string) {
|
|
||||||
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
|
|
||||||
},
|
|
||||||
clearTabs() {
|
|
||||||
if (!store.handoff?.tabs) return
|
|
||||||
setStore("handoff", "tabs", undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
projects: {
|
|
||||||
list,
|
|
||||||
recentlyClosed: createMemo(() => {
|
|
||||||
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
|
|
||||||
return server.projects
|
|
||||||
.recentlyClosed()
|
|
||||||
.filter((worktree) => known.has(pathKey(worktree)))
|
|
||||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
|
||||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
|
||||||
}),
|
|
||||||
open(directory: string) {
|
|
||||||
const root = rootFor(directory)
|
|
||||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
|
||||||
void serverSync().project.loadSessions(root)
|
|
||||||
server.projects.open(root)
|
|
||||||
},
|
|
||||||
close(directory: string) {
|
|
||||||
server.projects.close(directory)
|
|
||||||
},
|
|
||||||
expand(directory: string) {
|
|
||||||
server.projects.expand(directory)
|
|
||||||
},
|
|
||||||
collapse(directory: string) {
|
|
||||||
server.projects.collapse(directory)
|
|
||||||
},
|
|
||||||
move(directory: string, toIndex: number) {
|
|
||||||
server.projects.move(directory, toIndex)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sidebar: {
|
|
||||||
opened: createMemo(() => store.sidebar.opened),
|
|
||||||
open() {
|
|
||||||
setStore("sidebar", "opened", true)
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
setStore("sidebar", "opened", false)
|
|
||||||
},
|
|
||||||
toggle() {
|
|
||||||
setStore("sidebar", "opened", (x) => !x)
|
|
||||||
},
|
|
||||||
width: createMemo(() => store.sidebar.width),
|
|
||||||
resize(width: number) {
|
|
||||||
setStore("sidebar", "width", width)
|
|
||||||
},
|
|
||||||
workspaces(directory: string) {
|
|
||||||
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
|
|
||||||
},
|
|
||||||
setWorkspaces(directory: string, value: boolean) {
|
|
||||||
setStore("sidebar", "workspaces", directory, value)
|
|
||||||
},
|
|
||||||
toggleWorkspaces(directory: string) {
|
|
||||||
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
|
|
||||||
setStore("sidebar", "workspaces", directory, !current)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
terminal: {
|
terminal: {
|
||||||
height: createMemo(() => store.terminal.height),
|
height: createMemo(() => store.terminal.height),
|
||||||
resize(height: number) {
|
resize(height: number) {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
|
|
||||||
const [saved, setSaved, , savedReady] = persisted(
|
const [saved, setSaved, , savedReady] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
||||||
migrate,
|
migrate,
|
||||||
},
|
},
|
||||||
createStore<Saved>({
|
createStore<Saved>({
|
||||||
@@ -127,14 +127,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const scope = createMemo<State | undefined>(() => {
|
const scope = createMemo<State | undefined>(() => {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return store.draft ?? store.promoting
|
if (!session) return store.draft ?? store.promoting
|
||||||
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
|
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk().directory, session))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const key = handoffKey(serverSDK().scope, sdk().directory, session)
|
const key = handoffKey(serverSDK.scope, sdk().directory, session)
|
||||||
const next = handoff.get(key)
|
const next = handoff.get(key)
|
||||||
if (!next) return
|
if (!next) return
|
||||||
if (saved.session[session] !== undefined) {
|
if (saved.session[session] !== undefined) {
|
||||||
@@ -385,7 +385,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
promote(dir: string, session: string, state?: State) {
|
promote(dir: string, session: string, state?: State) {
|
||||||
const next = clone(state ?? snapshot())
|
const next = clone(state ?? snapshot())
|
||||||
if (!next) return
|
if (!next) return
|
||||||
const key = handoffKey(serverSDK().scope, dir, session)
|
const key = handoffKey(serverSDK.scope, dir, session)
|
||||||
handoff.set(key, next)
|
handoff.set(key, next)
|
||||||
|
|
||||||
if (dir === sdk().directory) {
|
if (dir === sdk().directory) {
|
||||||
@@ -400,7 +400,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
if (msg.sessionID !== session) return
|
if (msg.sessionID !== session) return
|
||||||
if (saved.session[session] !== undefined) return
|
if (saved.session[session] !== undefined) return
|
||||||
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
|
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
|
||||||
|
|
||||||
setSaved("session", session, {
|
setSaved("session", session, {
|
||||||
agent: msg.agent,
|
agent: msg.agent,
|
||||||
|
|||||||
+155
-145
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, createResource } from "solid-js"
|
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||||
@@ -22,152 +22,162 @@ function modelKey(model: ModelKey) {
|
|||||||
return `${model.providerID}:${model.modelID}`
|
return `${model.providerID}:${model.modelID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createModelsPersistedState = () => {
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
Persist.global("model", ["model.v1"]),
|
||||||
|
createStore<Store>({
|
||||||
|
user: [],
|
||||||
|
recent: [],
|
||||||
|
variant: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [store, setStore, ready] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||||
|
const providers = useProviders(() => directory())
|
||||||
|
|
||||||
|
const [store, setStore, ready] = createModelsPersistedState()
|
||||||
|
|
||||||
|
const available = createMemo(() =>
|
||||||
|
providers.connected().flatMap((p) =>
|
||||||
|
Object.values(p.models).map((m) => ({
|
||||||
|
...m,
|
||||||
|
provider: p,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const release = createMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
available().map((model) => {
|
||||||
|
const parsed = DateTime.fromISO(model.release_date)
|
||||||
|
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const latest = createMemo(() =>
|
||||||
|
pipe(
|
||||||
|
available(),
|
||||||
|
filter(
|
||||||
|
(x) =>
|
||||||
|
Math.abs(
|
||||||
|
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||||
|
.diffNow()
|
||||||
|
.as("months"),
|
||||||
|
) < 6,
|
||||||
|
),
|
||||||
|
groupBy((x) => x.provider.id),
|
||||||
|
mapValues((models) =>
|
||||||
|
pipe(
|
||||||
|
models,
|
||||||
|
groupBy((x) => x.family),
|
||||||
|
values(),
|
||||||
|
(groups) =>
|
||||||
|
groups.flatMap((g) => {
|
||||||
|
const first = firstBy(g, [(x) => x.release_date, "desc"])
|
||||||
|
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
values(),
|
||||||
|
flat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
|
||||||
|
|
||||||
|
const visibility = createMemo(() => {
|
||||||
|
const map = new Map<string, Visibility>()
|
||||||
|
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = createMemo(() =>
|
||||||
|
available().map((m) => ({
|
||||||
|
...m,
|
||||||
|
name: m.name.replace("(latest)", "").trim(),
|
||||||
|
latest: m.name.includes("(latest)"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
|
||||||
|
|
||||||
|
function update(model: ModelKey, state: Visibility) {
|
||||||
|
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||||
|
if (index >= 0) {
|
||||||
|
setStore("user", index, (current) => ({ ...current, visibility: state }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("user", store.user.length, { ...model, visibility: state })
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = (model: ModelKey) => {
|
||||||
|
const key = modelKey(model)
|
||||||
|
const state = visibility().get(key)
|
||||||
|
if (state === "hide") return false
|
||||||
|
if (state === "show") return true
|
||||||
|
if (latestSet().has(key)) return true
|
||||||
|
const date = release().get(key)
|
||||||
|
if (!date?.isValid) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||||
|
update(model, state ? "show" : "hide")
|
||||||
|
}
|
||||||
|
|
||||||
|
const push = (model: ModelKey) => {
|
||||||
|
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
|
||||||
|
if (uniq.length > RECENT_LIMIT) uniq.pop()
|
||||||
|
setStore("recent", uniq)
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
|
||||||
|
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
|
||||||
|
|
||||||
|
const setVariant = (model: ModelKey, value: string | undefined) => {
|
||||||
|
const key = variantKey(model)
|
||||||
|
if (!store.variant) {
|
||||||
|
setStore("variant", { [key]: value })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("variant", key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recentModels] = createResource(
|
||||||
|
async () => {
|
||||||
|
const recent = store.recent
|
||||||
|
await ready.promise
|
||||||
|
return recent
|
||||||
|
},
|
||||||
|
(p) => p,
|
||||||
|
{ initialValue: [] },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
list,
|
||||||
|
find,
|
||||||
|
visible,
|
||||||
|
setVisibility,
|
||||||
|
recent: {
|
||||||
|
list: () => recentModels()!,
|
||||||
|
push,
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
get: getVariant,
|
||||||
|
set: setVariant,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||||
name: "Models",
|
name: "Models",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { directory?: string } = {}) => {
|
init: (props: { directory?: string | Accessor<string | undefined> } = {}) => {
|
||||||
const providers = useProviders(() => props.directory)
|
return createModelsController(() => (typeof props.directory === "function" ? props.directory() : props.directory))
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
|
||||||
Persist.global("model", ["model.v1"]),
|
|
||||||
createStore<Store>({
|
|
||||||
user: [],
|
|
||||||
recent: [],
|
|
||||||
variant: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const available = createMemo(() =>
|
|
||||||
providers.connected().flatMap((p) =>
|
|
||||||
Object.values(p.models).map((m) => ({
|
|
||||||
...m,
|
|
||||||
provider: p,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const release = createMemo(
|
|
||||||
() =>
|
|
||||||
new Map(
|
|
||||||
available().map((model) => {
|
|
||||||
const parsed = DateTime.fromISO(model.release_date)
|
|
||||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const latest = createMemo(() =>
|
|
||||||
pipe(
|
|
||||||
available(),
|
|
||||||
filter(
|
|
||||||
(x) =>
|
|
||||||
Math.abs(
|
|
||||||
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
|
||||||
.diffNow()
|
|
||||||
.as("months"),
|
|
||||||
) < 6,
|
|
||||||
),
|
|
||||||
groupBy((x) => x.provider.id),
|
|
||||||
mapValues((models) =>
|
|
||||||
pipe(
|
|
||||||
models,
|
|
||||||
groupBy((x) => x.family),
|
|
||||||
values(),
|
|
||||||
(groups) =>
|
|
||||||
groups.flatMap((g) => {
|
|
||||||
const first = firstBy(g, [(x) => x.release_date, "desc"])
|
|
||||||
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
values(),
|
|
||||||
flat(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
|
|
||||||
|
|
||||||
const visibility = createMemo(() => {
|
|
||||||
const map = new Map<string, Visibility>()
|
|
||||||
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = createMemo(() =>
|
|
||||||
available().map((m) => ({
|
|
||||||
...m,
|
|
||||||
name: m.name.replace("(latest)", "").trim(),
|
|
||||||
latest: m.name.includes("(latest)"),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
|
|
||||||
|
|
||||||
function update(model: ModelKey, state: Visibility) {
|
|
||||||
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
|
||||||
if (index >= 0) {
|
|
||||||
setStore("user", index, (current) => ({ ...current, visibility: state }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("user", store.user.length, { ...model, visibility: state })
|
|
||||||
}
|
|
||||||
|
|
||||||
const visible = (model: ModelKey) => {
|
|
||||||
const key = modelKey(model)
|
|
||||||
const state = visibility().get(key)
|
|
||||||
if (state === "hide") return false
|
|
||||||
if (state === "show") return true
|
|
||||||
if (latestSet().has(key)) return true
|
|
||||||
const date = release().get(key)
|
|
||||||
if (!date?.isValid) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
|
||||||
update(model, state ? "show" : "hide")
|
|
||||||
}
|
|
||||||
|
|
||||||
const push = (model: ModelKey) => {
|
|
||||||
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
|
|
||||||
if (uniq.length > RECENT_LIMIT) uniq.pop()
|
|
||||||
setStore("recent", uniq)
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
|
|
||||||
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
|
|
||||||
|
|
||||||
const setVariant = (model: ModelKey, value: string | undefined) => {
|
|
||||||
const key = variantKey(model)
|
|
||||||
if (!store.variant) {
|
|
||||||
setStore("variant", { [key]: value })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("variant", key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [recentModels] = createResource(
|
|
||||||
async () => {
|
|
||||||
const recent = store.recent
|
|
||||||
await ready.promise
|
|
||||||
return recent
|
|
||||||
},
|
|
||||||
(p) => p,
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
ready,
|
|
||||||
list,
|
|
||||||
find,
|
|
||||||
visible,
|
|
||||||
setVisibility,
|
|
||||||
recent: {
|
|
||||||
list: () => recentModels()!,
|
|
||||||
push,
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
get: getVariant,
|
|
||||||
set: setVariant,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { ServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import type { ServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
@@ -13,10 +12,11 @@ import type { EventSessionError } from "@/types"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { playSoundById } from "@/utils/sound"
|
import { playSoundById } from "@/utils/sound"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServers } from "./servers"
|
||||||
import { type DraftTab, useTabs } from "./tabs"
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { useServer } from "./server"
|
||||||
|
|
||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -109,131 +109,14 @@ function buildNotificationIndex(list: Notification[]) {
|
|||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) {
|
||||||
name: "Notification",
|
const platform = usePlatform()
|
||||||
gate: false,
|
const settings = useSettings()
|
||||||
init: () => {
|
const language = useLanguage()
|
||||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
|
||||||
const global = useGlobal()
|
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const platform = usePlatform()
|
|
||||||
const settings = useSettings()
|
|
||||||
const language = useLanguage()
|
|
||||||
const owner = getOwner()
|
|
||||||
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
|
||||||
|
|
||||||
const activeServer = createMemo(() => {
|
|
||||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
|
||||||
if (search.draftId) {
|
|
||||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
|
||||||
if (draft) return draft.server
|
|
||||||
}
|
|
||||||
return server.key
|
|
||||||
})
|
|
||||||
const activeDirectory = createMemo(() => decode64(params.dir))
|
|
||||||
const activeSession = createMemo(() => params.id)
|
|
||||||
|
|
||||||
const ensure = (key: ServerConnection.Key) => {
|
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
|
||||||
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
|
||||||
const ctx = global.ensureServerCtx(conn)
|
|
||||||
const existing = states.get(ctx.sdk.scope)
|
|
||||||
if (existing) return existing.state
|
|
||||||
const root = createRoot(
|
|
||||||
(dispose) => ({
|
|
||||||
dispose,
|
|
||||||
state: createServerNotificationState({
|
|
||||||
sdk: ctx.sdk,
|
|
||||||
sync: ctx.sync,
|
|
||||||
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
|
||||||
directory: activeDirectory,
|
|
||||||
sessionID: activeSession,
|
|
||||||
platform,
|
|
||||||
settings,
|
|
||||||
language,
|
|
||||||
navigate,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
owner ?? undefined,
|
|
||||||
)
|
|
||||||
states.set(ctx.sdk.scope, root)
|
|
||||||
return root.state
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
|
||||||
states.forEach((value, scope) => {
|
|
||||||
if (scopes.has(scope)) return
|
|
||||||
value.dispose()
|
|
||||||
states.delete(scope)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
|
||||||
|
|
||||||
const selected = () => {
|
|
||||||
const list = global.servers.list()
|
|
||||||
const key = activeServer()
|
|
||||||
if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
|
|
||||||
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
|
||||||
if (!conn) throw new Error("Notification server not found")
|
|
||||||
return ensure(ServerConnection.key(conn))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready: () => selected().ready(),
|
|
||||||
ensureServerState: ensure,
|
|
||||||
session: {
|
|
||||||
all: (session: string) => selected().session.all(session),
|
|
||||||
unseen: (session: string) => selected().session.unseen(session),
|
|
||||||
unseenCount: (session: string) => selected().session.unseenCount(session),
|
|
||||||
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
|
||||||
markViewed: (session: string) => selected().session.markViewed(session),
|
|
||||||
},
|
|
||||||
project: {
|
|
||||||
all: (directory: string) => selected().project.all(directory),
|
|
||||||
unseen: (directory: string) => selected().project.unseen(directory),
|
|
||||||
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
|
||||||
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
|
||||||
markViewed: (directory: string) => selected().project.markViewed(directory),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type NotificationState = ReturnType<typeof createServerNotificationState>
|
|
||||||
|
|
||||||
function createServerNotificationState(input: {
|
|
||||||
sdk: ServerSDK
|
|
||||||
sync: ServerSync
|
|
||||||
active: Accessor<boolean>
|
|
||||||
directory: Accessor<string | undefined>
|
|
||||||
sessionID: Accessor<string | undefined>
|
|
||||||
platform: ReturnType<typeof usePlatform>
|
|
||||||
settings: ReturnType<typeof useSettings>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
navigate: (href: string) => void
|
|
||||||
}) {
|
|
||||||
const serverSDK = () => input.sdk
|
|
||||||
const serverSync = () => input.sync
|
|
||||||
const platform = input.platform
|
|
||||||
const settings = input.settings
|
|
||||||
const language = input.language
|
|
||||||
|
|
||||||
const empty: Notification[] = []
|
const empty: Notification[] = []
|
||||||
|
|
||||||
const currentDirectory = input.directory
|
|
||||||
const currentSession = input.sessionID
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
Persist.serverGlobal(input.sdk.scope, "notification", ["notification.v1"]),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as Notification[],
|
list: [] as Notification[],
|
||||||
}),
|
}),
|
||||||
@@ -316,7 +199,7 @@ function createServerNotificationState(input: {
|
|||||||
|
|
||||||
const lookup = async (directory: string, sessionID?: string) => {
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
if (!sessionID) return undefined
|
if (!sessionID) return undefined
|
||||||
const sync = serverSync().ensureDirSyncContext(directory)
|
const sync = input.sync.ensureDirSyncContext(directory)
|
||||||
const session = sync.session.get(sessionID)
|
const session = sync.session.get(sessionID)
|
||||||
if (session) return session
|
if (session) return session
|
||||||
return sync.session
|
return sync.session
|
||||||
@@ -325,17 +208,16 @@ function createServerNotificationState(input: {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
const viewedInCurrentSession = (sessionID: string) => {
|
||||||
if (!input.active()) return false
|
return typeof location !== "undefined" && location.pathname === sessionHref(input.key, sessionID)
|
||||||
const activeDirectory = currentDirectory()
|
|
||||||
const activeSession = currentSession()
|
|
||||||
if (!activeSession) return false
|
|
||||||
if (!sessionID) return false
|
|
||||||
if (activeDirectory && directory !== activeDirectory) return false
|
|
||||||
return sessionID === activeSession
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
const navigate = (href: string) => {
|
||||||
|
history.pushState(null, "", href)
|
||||||
|
dispatchEvent(new PopStateEvent("popstate"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
void lookup(directory, sessionID).then((session) => {
|
void lookup(directory, sessionID).then((session) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
@@ -349,15 +231,15 @@ function createServerNotificationState(input: {
|
|||||||
append({
|
append({
|
||||||
directory,
|
directory,
|
||||||
time,
|
time,
|
||||||
viewed: viewedInCurrentSession(directory, sessionID),
|
viewed: viewedInCurrentSession(sessionID),
|
||||||
type: "turn-complete",
|
type: "turn-complete",
|
||||||
session: sessionID,
|
session: sessionID,
|
||||||
})
|
})
|
||||||
|
|
||||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
const href = sessionHref(input.key, sessionID)
|
||||||
if (settings.notifications.agent()) {
|
if (settings.notifications.agent()) {
|
||||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||||
input.navigate(href),
|
navigate(href),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -381,7 +263,7 @@ function createServerNotificationState(input: {
|
|||||||
append({
|
append({
|
||||||
directory,
|
directory,
|
||||||
time,
|
time,
|
||||||
viewed: viewedInCurrentSession(directory, sessionID),
|
viewed: viewedInCurrentSession(sessionID),
|
||||||
type: "error",
|
type: "error",
|
||||||
session: sessionID ?? "global",
|
session: sessionID ?? "global",
|
||||||
error,
|
error,
|
||||||
@@ -389,14 +271,14 @@ function createServerNotificationState(input: {
|
|||||||
const description =
|
const description =
|
||||||
session?.title ??
|
session?.title ??
|
||||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
const href = sessionHref(input.key, sessionID ?? "global")
|
||||||
if (settings.notifications.errors()) {
|
if (settings.notifications.errors()) {
|
||||||
void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
|
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = serverSDK().event.listen((e) => {
|
const unsub = input.sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (
|
if (
|
||||||
event.type !== "session.execution.succeeded" &&
|
event.type !== "session.execution.succeeded" &&
|
||||||
@@ -486,3 +368,8 @@ function createServerNotificationState(input: {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useNotification = () => {
|
||||||
|
const server = useServer()
|
||||||
|
return server.ctx.notification
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { ServerSync } from "./server-sync"
|
|||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServers } from "./servers"
|
||||||
import { type DraftTab, useTabs } from "./tabs"
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
autoRespondsPermission,
|
autoRespondsPermission,
|
||||||
sessionAutoAccept,
|
sessionAutoAccept,
|
||||||
} from "./permission-auto-respond"
|
} from "./permission-auto-respond"
|
||||||
|
import { useServer } from "./server"
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
type PermissionRespondFn = (input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -51,142 +52,9 @@ function hasPermissionPromptRules(permission: unknown) {
|
|||||||
return Object.values(config).some(isNonAllowRule)
|
return Object.values(config).some(isNonAllowRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
|
||||||
name: "Permission",
|
|
||||||
gate: false,
|
|
||||||
init: () => {
|
|
||||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
|
||||||
const global = useGlobal()
|
|
||||||
const server = useServer()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const settings = useSettings()
|
|
||||||
const owner = getOwner()
|
|
||||||
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
|
|
||||||
|
|
||||||
const activeDraft = createMemo(() => {
|
|
||||||
if (!search.draftId) return
|
|
||||||
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeServer = createMemo(() => {
|
|
||||||
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
|
|
||||||
return activeDraft()?.server ?? server.key
|
|
||||||
})
|
|
||||||
|
|
||||||
const ensure = (key: ServerConnection.Key) => {
|
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
|
||||||
if (!conn) throw new Error(`Permission server not found: ${key}`)
|
|
||||||
const ctx = global.ensureServerCtx(conn)
|
|
||||||
const existing = states.get(ctx.sdk.scope)
|
|
||||||
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
|
|
||||||
return existing.state
|
|
||||||
}
|
|
||||||
if (existing) {
|
|
||||||
existing.dispose()
|
|
||||||
states.delete(ctx.sdk.scope)
|
|
||||||
}
|
|
||||||
const root = createRoot(
|
|
||||||
(dispose) => ({
|
|
||||||
key,
|
|
||||||
dispose,
|
|
||||||
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
|
|
||||||
}),
|
|
||||||
owner ?? undefined,
|
|
||||||
)
|
|
||||||
states.set(ctx.sdk.scope, root)
|
|
||||||
return root.state
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const list = global.servers.list()
|
|
||||||
const keys = new Set(list.map(ServerConnection.key))
|
|
||||||
states.forEach((value, scope) => {
|
|
||||||
if (keys.has(value.key)) return
|
|
||||||
value.dispose()
|
|
||||||
states.delete(scope)
|
|
||||||
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
|
|
||||||
if (replacement) ensure(ServerConnection.key(replacement))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
|
||||||
|
|
||||||
let lastSelected: PermissionState | undefined
|
|
||||||
const selected = () => {
|
|
||||||
const key = activeServer()
|
|
||||||
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
|
|
||||||
lastSelected = ensure(key)
|
|
||||||
}
|
|
||||||
if (lastSelected) return lastSelected
|
|
||||||
return ensure(server.key)
|
|
||||||
}
|
|
||||||
const activeDirectory = createMemo(() => {
|
|
||||||
const directory = decode64(params.dir)
|
|
||||||
if (directory) return directory
|
|
||||||
const draft = activeDraft()
|
|
||||||
if (draft) return draft.directory
|
|
||||||
if (!params.id) return
|
|
||||||
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
|
|
||||||
return selected().sync.session.lineage.peek(params.id)?.session.location.directory
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const directory = activeDirectory()
|
|
||||||
if (!directory) return
|
|
||||||
selected().enableConfiguredDirectory(directory)
|
|
||||||
})
|
|
||||||
|
|
||||||
const permissionsEnabled = createMemo(() => {
|
|
||||||
const directory = activeDirectory()
|
|
||||||
if (!directory) return false
|
|
||||||
return selected().permissionsEnabled(directory)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready: () => selected().ready(),
|
|
||||||
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
|
|
||||||
currentServerState: () => selected().api,
|
|
||||||
respond(input: Parameters<PermissionRespondFn>[0]) {
|
|
||||||
selected().respond(input)
|
|
||||||
},
|
|
||||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
|
||||||
return selected().autoResponds(permission, directory)
|
|
||||||
},
|
|
||||||
isAutoAccepting(sessionID: string, directory?: string) {
|
|
||||||
return selected().isAutoAccepting(sessionID, directory)
|
|
||||||
},
|
|
||||||
isAutoAcceptingDirectory(directory: string) {
|
|
||||||
return selected().isAutoAcceptingDirectory(directory)
|
|
||||||
},
|
|
||||||
toggleAutoAccept(sessionID: string, directory: string) {
|
|
||||||
selected().toggleAutoAccept(sessionID, directory)
|
|
||||||
},
|
|
||||||
toggleAutoAcceptDirectory(directory: string) {
|
|
||||||
selected().toggleAutoAcceptDirectory(directory)
|
|
||||||
},
|
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
|
||||||
selected().enableAutoAccept(sessionID, directory)
|
|
||||||
},
|
|
||||||
disableAutoAccept(sessionID: string, directory?: string) {
|
|
||||||
selected().disableAutoAccept(sessionID, directory)
|
|
||||||
},
|
|
||||||
permissionsEnabled,
|
|
||||||
isPermissionAllowAll(directory: string) {
|
|
||||||
return selected().isPermissionAllowAll(directory)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type PermissionState = ReturnType<typeof createServerPermissionState>
|
|
||||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||||
|
|
||||||
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||||
@@ -474,3 +342,8 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const usePermission = () => {
|
||||||
|
const server = useServer()
|
||||||
|
return server.ctx.permission
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { DesktopMenuAction } from "../desktop-menu"
|
import type { DesktopMenuAction } from "../desktop-menu"
|
||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./servers"
|
||||||
import type { WslServersPlatform } from "../wsl/types"
|
import type { WslServersPlatform } from "../wsl/types"
|
||||||
import type { UpdaterPlatform } from "../updater"
|
import type { UpdaterPlatform } from "../updater"
|
||||||
import type { DraftStore } from "@/utils/draft-store"
|
import type { DraftStore } from "@/utils/draft-store"
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./servers"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useTabs, type Tab } from "./tabs"
|
import { useTabs, type Tab } from "./tabs"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import {
|
import {
|
||||||
createPromptReady,
|
createPromptReady,
|
||||||
createPromptSession,
|
createPromptSession,
|
||||||
@@ -101,14 +102,16 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
const serverKey = () =>
|
const serverKey = () =>
|
||||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK.server)
|
||||||
const scope = (): PromptScope =>
|
const scope = (): PromptScope =>
|
||||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||||
const load = (scope: PromptScope) => {
|
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
const current = settings.general.newLayoutDesigns()
|
||||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||||
|
: undefined
|
||||||
|
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK.scope, scope)
|
||||||
|
|
||||||
const key = scopeKey(scope)
|
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const entry = createRoot(
|
const entry = createRoot(
|
||||||
(dispose) => ({
|
(dispose) => ({
|
||||||
value: createPromptSession(serverSDK().scope, scope),
|
value: createPromptSession(target?.scope ?? serverSDK.scope, scope),
|
||||||
dispose,
|
dispose,
|
||||||
}),
|
}),
|
||||||
owner,
|
owner,
|
||||||
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(scope()))
|
const session = createMemo(() => load(scope()))
|
||||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||||
|
scope ? load(scope, target) : session()
|
||||||
const ready = createPromptReady(session)
|
const ready = createPromptReady(session)
|
||||||
|
|
||||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||||
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||||
|
pick(scope, target).capture(),
|
||||||
current: withSuspense(() => session().current()),
|
current: withSuspense(() => session().current()),
|
||||||
cursor: withSuspense(() => session().cursor()),
|
cursor: withSuspense(() => session().cursor()),
|
||||||
dirty: withSuspense(() => session().dirty()),
|
dirty: withSuspense(() => session().dirty()),
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ export type { DirectorySDK } from "./server-sdk"
|
|||||||
const context = createSimpleContext({
|
const context = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||||
init: (props: { directory: string }) => {
|
init: (props: { directory: string | Accessor<string> }) => {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
return createMemo(() => serverSDK().ensureDirSdkContext(props.directory))
|
return createMemo(() => {
|
||||||
|
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||||
|
return serverSDK.ensureDirSdkContext(directory)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import type { Event } from "@/types"
|
import type { Event } from "@/types"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection } from "./servers"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { useGlobal } from "./global"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { useServer } from "./server"
|
||||||
|
|
||||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||||
@@ -347,22 +345,10 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
|
export const useServerSDK = () => {
|
||||||
name: "ServerSDK",
|
const server = useServer()
|
||||||
// Returns an accessor so the resolved server can change reactively (e.g. a
|
return server.ctx.sdk
|
||||||
// /new-session draft retargeting its server) without re-instantiating the subtree.
|
}
|
||||||
init: (props: { server?: ServerConnection.Any }) => {
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
|
||||||
const server = useServer()
|
|
||||||
|
|
||||||
return createMemo<ServerSDK>(() => {
|
|
||||||
const conn = props.server ?? server.current
|
|
||||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
|
||||||
return global.ensureServerCtx(conn).sdk
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type SDKEventMap = {
|
type SDKEventMap = {
|
||||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||||
|
|||||||
@@ -541,6 +541,23 @@ describe("server session", () => {
|
|||||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies moved session locations without evicting cached state", () => {
|
||||||
|
const current = { ...session("child"), location: { directory: "/repo/worktree" } }
|
||||||
|
const ctx = setup({ child: current })
|
||||||
|
ctx.store.remember(current)
|
||||||
|
|
||||||
|
ctx.store.applyV2({
|
||||||
|
id: "evt_moved",
|
||||||
|
created: 2,
|
||||||
|
type: "session.moved",
|
||||||
|
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||||
|
location: current.location,
|
||||||
|
data: { sessionID: "child", location: { directory: "/repo" }, projectID: "project", subpath: "packages/app" },
|
||||||
|
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||||
|
|
||||||
|
expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" })
|
||||||
|
})
|
||||||
|
|
||||||
test("loads session content through the server client", async () => {
|
test("loads session content through the server client", async () => {
|
||||||
const ctx = setup({ root: session("root") })
|
const ctx = setup({ root: session("root") })
|
||||||
|
|
||||||
@@ -1184,6 +1201,16 @@ describe("server session", () => {
|
|||||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("removes projected messages when rolling back optimistic content", () => {
|
||||||
|
const message = userMessage("message")
|
||||||
|
const store = setup({ child: session("child") }).store
|
||||||
|
store.optimistic.add({ sessionID: "child", message, parts: [] })
|
||||||
|
|
||||||
|
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||||
|
|
||||||
|
expect(store.data.session_message.child).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test("does not remove content confirmed by a message event", () => {
|
test("does not remove content confirmed by a message event", () => {
|
||||||
const message = userMessage("message")
|
const message = userMessage("message")
|
||||||
const part = textPart(message.id)
|
const part = textPart(message.id)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type {
|
import type {
|
||||||
FormInfo,
|
FormInfo,
|
||||||
@@ -257,7 +257,13 @@ export function createServerSession(
|
|||||||
const indexProjectedMessage = (message: Message) => {
|
const indexProjectedMessage = (message: Message) => {
|
||||||
const current = data.session_message[message.sessionID] ?? []
|
const current = data.session_message[message.sessionID] ?? []
|
||||||
if (current.some((item) => item.id === message.id)) return
|
if (current.some((item) => item.id === message.id)) return
|
||||||
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)]))
|
const projected = projectMessageSource(message)
|
||||||
|
const projectedIDs = new Set(projected.map((item) => item.id))
|
||||||
|
setData(
|
||||||
|
"session_message",
|
||||||
|
message.sessionID,
|
||||||
|
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const remember = (session: SessionInfo) => {
|
const remember = (session: SessionInfo) => {
|
||||||
@@ -940,13 +946,10 @@ export function createServerSession(
|
|||||||
setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id))
|
setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "project.directory.resolved") {
|
if (event.type === "worktree.resolved") {
|
||||||
Object.values(data.info).forEach((info) => {
|
Object.values(data.info).forEach((info) => {
|
||||||
if (!info) return
|
if (!info) return
|
||||||
const adopted = ProjectDirectories.adopt(
|
const adopted = Worktree.adopt({ projectID: info.projectID, directory: info.location.directory }, event.data)
|
||||||
{ projectID: info.projectID, directory: info.location.directory },
|
|
||||||
event.data,
|
|
||||||
)
|
|
||||||
if (adopted) remember({ ...info, ...adopted })
|
if (adopted) remember({ ...info, ...adopted })
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -1446,6 +1449,7 @@ export function createServerSession(
|
|||||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||||
if (!items)
|
if (!items)
|
||||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||||
|
indexProjectedMessage(input.message)
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
@@ -1479,6 +1483,10 @@ export function createServerSession(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const projectedIDs = new Set(projectMessageSource(item.message).map((message) => message.id))
|
||||||
|
setData("session_message", input.sessionID, (messages) =>
|
||||||
|
messages?.filter((message) => !projectedIDs.has(message.id)),
|
||||||
|
)
|
||||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
loadMcpResourcesQuery,
|
loadMcpResourcesQuery,
|
||||||
reconcileActiveSessionStatuses,
|
reconcileActiveSessionStatuses,
|
||||||
seedActiveSessionStatuses,
|
seedActiveSessionStatuses,
|
||||||
|
shouldRefreshWorkspaceSessions,
|
||||||
} from "./server-sync"
|
} from "./server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
@@ -202,6 +203,19 @@ describe("estimateRootSessionTotal", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("workspace session inventory", () => {
|
||||||
|
test("refreshes for session identity and location changes", () => {
|
||||||
|
const event = (type: string, current?: string) =>
|
||||||
|
({ type, current: current ? { type: current } : undefined }) as Parameters<
|
||||||
|
typeof shouldRefreshWorkspaceSessions
|
||||||
|
>[0]
|
||||||
|
|
||||||
|
expect(shouldRefreshWorkspaceSessions(event("session.created"))).toBe(true)
|
||||||
|
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
|
||||||
|
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("canDisposeDirectory", () => {
|
describe("canDisposeDirectory", () => {
|
||||||
test("rejects pinned or inflight directories", () => {
|
test("rejects pinned or inflight directories", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { batch, createMemo, getOwner, onCleanup, untrack } from "solid-js"
|
import { batch, getOwner, onCleanup, untrack } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
||||||
@@ -32,12 +32,9 @@ import { createRefreshQueue } from "./global-sync/queue"
|
|||||||
import { directoryKey } from "./global-sync/utils"
|
import { directoryKey } from "./global-sync/utils"
|
||||||
import { pathKey, PathKey } from "@/utils/path-key"
|
import { pathKey, PathKey } from "@/utils/path-key"
|
||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { useGlobal } from "./global"
|
import { ServerConnection } from "./servers"
|
||||||
import { ServerConnection, useServer } from "./server"
|
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
@@ -57,6 +54,18 @@ import { createServerSession, type ServerSession } from "./server-session"
|
|||||||
import { createCatalogSync } from "./server-sync/catalog"
|
import { createCatalogSync } from "./server-sync/catalog"
|
||||||
import { createConnectionSync } from "./server-sync/connection"
|
import { createConnectionSync } from "./server-sync/connection"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
import { useServer } from "./server"
|
||||||
|
|
||||||
|
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||||
|
const type = event.current?.type ?? event.type
|
||||||
|
return (
|
||||||
|
type === "session.created" ||
|
||||||
|
type === "session.deleted" ||
|
||||||
|
type === "session.moved" ||
|
||||||
|
type === "session.renamed" ||
|
||||||
|
type === "session.forked"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -185,7 +194,7 @@ export function reconcileActiveSessionStatuses(
|
|||||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree),
|
||||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||||
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
||||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||||
@@ -553,12 +562,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
const toDirectoryEvent = (event: ServerEvent) => {
|
const toDirectoryEvent = (event: ServerEvent) => {
|
||||||
if (event.current?.type === "session.created") return
|
if (event.current?.type === "session.created") return
|
||||||
if (
|
if (event.current?.type !== "session.renamed" && event.current?.type !== "session.usage.updated") return event
|
||||||
event.current?.type !== "session.renamed" &&
|
|
||||||
event.current?.type !== "session.moved" &&
|
|
||||||
event.current?.type !== "session.usage.updated"
|
|
||||||
)
|
|
||||||
return event
|
|
||||||
const info = session.get(event.current.data.sessionID)
|
const info = session.get(event.current.data.sessionID)
|
||||||
if (info) return { type: "session.updated", properties: { info } }
|
if (info) return { type: "session.updated", properties: { info } }
|
||||||
return event
|
return event
|
||||||
@@ -576,6 +580,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
markSessionListChanged(event, directory, previousDirectory)
|
markSessionListChanged(event, directory, previousDirectory)
|
||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
|
if (event.current?.type === "session.moved") {
|
||||||
|
const info = session.get(event.current.data.sessionID)
|
||||||
|
if (info) indexSession(info)
|
||||||
|
}
|
||||||
|
if (shouldRefreshWorkspaceSessions(event)) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||||
|
})
|
||||||
|
}
|
||||||
if (event.current?.type === "session.created")
|
if (event.current?.type === "session.created")
|
||||||
void session
|
void session
|
||||||
.resolve(event.current.data.sessionID, { force: true })
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
@@ -624,20 +638,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
refresh: () => void bootstrap.refetch(),
|
refresh: () => void bootstrap.refetch(),
|
||||||
setGlobalProject: setProjects,
|
setGlobalProject: setProjects,
|
||||||
})
|
})
|
||||||
if (
|
if (eventType === "config.updated" || eventType === "agent.updated" || eventType === "worktree.updated")
|
||||||
eventType === "config.updated" ||
|
|
||||||
eventType === "agent.updated" ||
|
|
||||||
eventType === "project.directories.updated"
|
|
||||||
)
|
|
||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
|
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.current?.type === "session.moved") {
|
|
||||||
const info = session.get(event.current.data.sessionID)
|
|
||||||
if (info) indexSession(info)
|
|
||||||
}
|
|
||||||
if (event.current?.type === "session.forked")
|
if (event.current?.type === "session.forked")
|
||||||
void session
|
void session
|
||||||
.resolve(event.current.data.sessionID, { force: true })
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
@@ -667,7 +673,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
void loadCommands(directory, serverSDK.api.command)
|
void loadCommands(directory, serverSDK.api.command)
|
||||||
.then((commands) => setStore("command", commands))
|
.then((commands) => setStore("command", commands))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
if (eventType === "project.directories.updated") void bootstrap.refetch()
|
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||||
const projected = toDirectoryEvent(event)
|
const projected = toDirectoryEvent(event)
|
||||||
if (projected)
|
if (projected)
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
@@ -804,24 +810,12 @@ export function createServerSyncContext(serverSDK: ServerSDK) {
|
|||||||
|
|
||||||
export type ServerSync = ReturnType<typeof createServerSyncContext>
|
export type ServerSync = ReturnType<typeof createServerSyncContext>
|
||||||
|
|
||||||
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
|
export const useServerSync = () => {
|
||||||
name: "ServerSync",
|
const server = useServer()
|
||||||
// Returns an accessor so the resolved server can change reactively without
|
return server.ctx.sync
|
||||||
// re-instantiating the subtree (mirrors useServerSDK).
|
}
|
||||||
init: (props: { server?: ServerConnection.Any }) => {
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
|
||||||
const server = useServer()
|
|
||||||
|
|
||||||
return createMemo<ServerSync>(() => {
|
|
||||||
const conn = props.server ?? server.current
|
|
||||||
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
|
|
||||||
return global.ensureServerCtx(conn).sync
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export function useQueryOptions() {
|
export function useQueryOptions() {
|
||||||
const sync = useServerSync()
|
const sync = useServerSync()
|
||||||
return createMemo(() => sync().queryOptions)
|
return sync.queryOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createRoot, createSignal } from "solid-js"
|
import { createRoot, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import {
|
import {
|
||||||
createServerProjects,
|
|
||||||
migrateCanonicalLocalServerState,
|
migrateCanonicalLocalServerState,
|
||||||
nextServerAfterRemoval,
|
nextServerAfterRemoval,
|
||||||
resolveServerList,
|
resolveServerList,
|
||||||
ServerConnection,
|
ServerConnection,
|
||||||
} from "./server"
|
} from "./servers"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
describe("resolveServerList", () => {
|
describe("resolveServerList", () => {
|
||||||
@@ -95,110 +93,6 @@ test("active server removal falls back across built-in and persisted servers", (
|
|||||||
).toBe(ServerConnection.Key.make("sidecar"))
|
).toBe(ServerConnection.Key.make("sidecar"))
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createServerProjects", () => {
|
|
||||||
test("keeps active and explicit server buckets in one reactive store", () => {
|
|
||||||
createRoot((dispose) => {
|
|
||||||
const [scope] = createSignal(ServerScope.local)
|
|
||||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
|
||||||
const active = createServerProjects({ scope, store, setStore })
|
|
||||||
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
|
|
||||||
|
|
||||||
remote.open("/repo")
|
|
||||||
expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }])
|
|
||||||
expect(active.list()).toEqual([])
|
|
||||||
|
|
||||||
const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
|
|
||||||
expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }])
|
|
||||||
|
|
||||||
adopted.close("/repo")
|
|
||||||
expect(remote.list()).toEqual([])
|
|
||||||
dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("tracks recently closed projects and drops them when reopened", () => {
|
|
||||||
createRoot((dispose) => {
|
|
||||||
const [scope] = createSignal(ServerScope.local)
|
|
||||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
|
||||||
const projects = createServerProjects({ scope, store, setStore })
|
|
||||||
|
|
||||||
projects.open("/a")
|
|
||||||
projects.open("/b")
|
|
||||||
projects.close("/a")
|
|
||||||
expect(projects.recentlyClosed()).toEqual(["/a"])
|
|
||||||
|
|
||||||
projects.close("/b")
|
|
||||||
expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
|
|
||||||
|
|
||||||
projects.open("/a")
|
|
||||||
expect(projects.recentlyClosed()).toEqual(["/b"])
|
|
||||||
expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
|
|
||||||
dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove drops a project without recording it as recently closed", () => {
|
|
||||||
createRoot((dispose) => {
|
|
||||||
const [scope] = createSignal(ServerScope.local)
|
|
||||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
|
||||||
const projects = createServerProjects({ scope, store, setStore })
|
|
||||||
|
|
||||||
projects.open("/repo/subdir")
|
|
||||||
projects.remove("/repo/subdir")
|
|
||||||
expect(projects.list()).toEqual([])
|
|
||||||
expect(projects.recentlyClosed()).toEqual([])
|
|
||||||
dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("retains recently closed history beyond the visible display limit", () => {
|
|
||||||
createRoot((dispose) => {
|
|
||||||
const [scope] = createSignal(ServerScope.local)
|
|
||||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
|
||||||
const projects = createServerProjects({ scope, store, setStore })
|
|
||||||
|
|
||||||
// Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
|
|
||||||
// this prevents display-filtered entries from evicting still-visible ones.
|
|
||||||
for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
|
|
||||||
projects.open(dir)
|
|
||||||
projects.close(dir)
|
|
||||||
}
|
|
||||||
expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
|
|
||||||
dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("caps recently closed history at the store limit", () => {
|
|
||||||
createRoot((dispose) => {
|
|
||||||
const [scope] = createSignal(ServerScope.local)
|
|
||||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
|
||||||
const projects = createServerProjects({ scope, store, setStore })
|
|
||||||
|
|
||||||
for (let i = 1; i <= 20; i++) {
|
|
||||||
projects.open(`/p${i}`)
|
|
||||||
projects.close(`/p${i}`)
|
|
||||||
}
|
|
||||||
expect(projects.recentlyClosed()).toHaveLength(16)
|
|
||||||
expect(projects.recentlyClosed()[0]).toBe("/p20")
|
|
||||||
expect(projects.recentlyClosed().at(-1)).toBe("/p5")
|
|
||||||
dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("dedupes recently closed entries by normalized path", () => {
|
|
||||||
createRoot((dispose) => {
|
|
||||||
const [scope] = createSignal(ServerScope.local)
|
|
||||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
|
||||||
const projects = createServerProjects({ scope, store, setStore })
|
|
||||||
|
|
||||||
projects.close("/repo")
|
|
||||||
projects.close("/repo/")
|
|
||||||
expect(projects.recentlyClosed()).toEqual(["/repo/"])
|
|
||||||
dispose()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("migrateCanonicalLocalServerState", () => {
|
describe("migrateCanonicalLocalServerState", () => {
|
||||||
test("moves an existing canonical web bucket into local scope", () => {
|
test("moves an existing canonical web bucket into local scope", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,375 +1,23 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { type Accessor, batch, createMemo } from "solid-js"
|
import { ServerConnection } from "./servers"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { useGlobal } from "./global"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
|
||||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
|
||||||
type ServerProjectState = {
|
|
||||||
projects: Record<string, StoredProject[]>
|
|
||||||
lastProject: Record<string, string>
|
|
||||||
recentlyClosed: Record<string, string[]>
|
|
||||||
}
|
|
||||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
|
||||||
// The store retains more history than is displayed. Consumers filter recently closed entries
|
|
||||||
// against the live project list (dropping deleted projects) and then cap the visible count via
|
|
||||||
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
|
|
||||||
// filtered out do not evict still-visible ones from the persisted store.
|
|
||||||
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
|
|
||||||
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
|
|
||||||
|
|
||||||
export function normalizeServerUrl(input: string) {
|
|
||||||
const trimmed = input.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
|
|
||||||
return withProtocol.replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
|
|
||||||
if (!conn) return ""
|
|
||||||
if (conn.displayName && !ignoreDisplayName) return conn.displayName
|
|
||||||
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLocalHost(url: string) {
|
|
||||||
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
|
||||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
|
|
||||||
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
|
|
||||||
if (!isRecord(value)) return value
|
|
||||||
const projects = isRecord(value.projects) ? value.projects : undefined
|
|
||||||
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
|
|
||||||
const previousProjects = projects?.[canonicalLocalServer]
|
|
||||||
const previousLastProject = lastProject?.[canonicalLocalServer]
|
|
||||||
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
|
|
||||||
|
|
||||||
const next = { ...value }
|
|
||||||
if (projects && Array.isArray(previousProjects)) {
|
|
||||||
const local = Array.isArray(projects.local) ? projects.local : []
|
|
||||||
const worktrees = new Set(
|
|
||||||
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
|
|
||||||
)
|
|
||||||
const migrated = previousProjects.filter((project) => {
|
|
||||||
if (!isRecord(project) || typeof project.worktree !== "string") return true
|
|
||||||
if (worktrees.has(project.worktree)) return false
|
|
||||||
worktrees.add(project.worktree)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
|
|
||||||
delete nextProjects[canonicalLocalServer]
|
|
||||||
next.projects = nextProjects
|
|
||||||
}
|
|
||||||
if (lastProject && typeof previousLastProject === "string") {
|
|
||||||
const nextLastProject = { ...lastProject }
|
|
||||||
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
|
|
||||||
delete nextLastProject[canonicalLocalServer]
|
|
||||||
next.lastProject = nextLastProject
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createServerProjects<T extends ServerProjectState>(input: {
|
|
||||||
scope: Accessor<ServerScope>
|
|
||||||
store: Store<T>
|
|
||||||
setStore: SetStoreFunction<T>
|
|
||||||
}) {
|
|
||||||
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
|
|
||||||
const current = () => input.store.projects[input.scope()] ?? []
|
|
||||||
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
|
|
||||||
const remove = (directory: string) => {
|
|
||||||
setStore(
|
|
||||||
"projects",
|
|
||||||
input.scope(),
|
|
||||||
current().filter((project) => project.worktree !== directory),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
list: current,
|
|
||||||
recentlyClosed: currentClosed,
|
|
||||||
remove,
|
|
||||||
open(directory: string) {
|
|
||||||
const scope = input.scope()
|
|
||||||
const key = pathKey(directory)
|
|
||||||
const closed = currentClosed()
|
|
||||||
if (closed.some((worktree) => pathKey(worktree) === key)) {
|
|
||||||
setStore(
|
|
||||||
"recentlyClosed",
|
|
||||||
scope,
|
|
||||||
closed.filter((worktree) => pathKey(worktree) !== key),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (current().some((project) => project.worktree === directory)) return
|
|
||||||
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
|
|
||||||
},
|
|
||||||
// User-initiated close: removes the project and records it in recently closed.
|
|
||||||
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
|
|
||||||
close(directory: string) {
|
|
||||||
remove(directory)
|
|
||||||
const key = pathKey(directory)
|
|
||||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
|
|
||||||
0,
|
|
||||||
RECENTLY_CLOSED_HISTORY_LIMIT,
|
|
||||||
)
|
|
||||||
setStore("recentlyClosed", input.scope(), closed)
|
|
||||||
},
|
|
||||||
expand(directory: string) {
|
|
||||||
const index = current().findIndex((project) => project.worktree === directory)
|
|
||||||
if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
|
|
||||||
},
|
|
||||||
collapse(directory: string) {
|
|
||||||
const index = current().findIndex((project) => project.worktree === directory)
|
|
||||||
if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
|
|
||||||
},
|
|
||||||
move(directory: string, toIndex: number) {
|
|
||||||
const fromIndex = current().findIndex((project) => project.worktree === directory)
|
|
||||||
if (fromIndex === -1 || fromIndex === toIndex) return
|
|
||||||
const next = [...current()]
|
|
||||||
const [item] = next.splice(fromIndex, 1)
|
|
||||||
next.splice(toIndex, 0, item)
|
|
||||||
setStore("projects", input.scope(), next)
|
|
||||||
},
|
|
||||||
last() {
|
|
||||||
return input.store.lastProject[input.scope()]
|
|
||||||
},
|
|
||||||
touch(directory: string) {
|
|
||||||
setStore("lastProject", input.scope(), directory)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveServerList(input: {
|
|
||||||
props?: Array<ServerConnection.Any>
|
|
||||||
stored: StoredServer[]
|
|
||||||
}): Array<ServerConnection.Any> {
|
|
||||||
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
|
|
||||||
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const value of input.stored) {
|
|
||||||
const conn: ServerConnection.Http =
|
|
||||||
typeof value === "string"
|
|
||||||
? {
|
|
||||||
type: "http" as const,
|
|
||||||
http: { url: value },
|
|
||||||
}
|
|
||||||
: "http" in value
|
|
||||||
? value
|
|
||||||
: { type: "http", http: value }
|
|
||||||
const key = ServerConnection.key(conn)
|
|
||||||
|
|
||||||
const existing = deduped.get(key)
|
|
||||||
if (existing)
|
|
||||||
deduped.set(key, {
|
|
||||||
...existing,
|
|
||||||
...conn,
|
|
||||||
http: { ...existing.http, ...conn.http },
|
|
||||||
})
|
|
||||||
else deduped.set(key, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...deduped.values()]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canRemoveServer(input: {
|
|
||||||
key: ServerConnection.Key
|
|
||||||
provided?: Array<ServerConnection.Any>
|
|
||||||
stored: StoredServer[]
|
|
||||||
}) {
|
|
||||||
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
|
|
||||||
return input.stored.some((server) =>
|
|
||||||
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace ServerConnection {
|
|
||||||
type Base = { displayName?: string; label?: string }
|
|
||||||
|
|
||||||
export type HttpBase = {
|
|
||||||
url: string
|
|
||||||
username?: string
|
|
||||||
password?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular web connections
|
|
||||||
export type Http = {
|
|
||||||
type: "http"
|
|
||||||
http: HttpBase
|
|
||||||
authToken?: boolean
|
|
||||||
} & Base
|
|
||||||
|
|
||||||
export type Sidecar = {
|
|
||||||
type: "sidecar"
|
|
||||||
http: HttpBase
|
|
||||||
} & (
|
|
||||||
| // Regular desktop server
|
|
||||||
{ variant: "base" }
|
|
||||||
// WSL server (windows only)
|
|
||||||
| {
|
|
||||||
variant: "wsl"
|
|
||||||
distro: string
|
|
||||||
}
|
|
||||||
) &
|
|
||||||
Base
|
|
||||||
|
|
||||||
// Remote server desktop can SSH into
|
|
||||||
export type Ssh = {
|
|
||||||
type: "ssh"
|
|
||||||
host: string
|
|
||||||
// SSH client exposes an HTTP server for the app to use as a proxy
|
|
||||||
http: HttpBase
|
|
||||||
} & Base
|
|
||||||
|
|
||||||
export type Any =
|
|
||||||
| Http
|
|
||||||
// All these are desktop-only
|
|
||||||
| (Sidecar | Ssh)
|
|
||||||
|
|
||||||
export const key = (conn: Any): Key => {
|
|
||||||
switch (conn.type) {
|
|
||||||
case "http":
|
|
||||||
return Key.make(conn.http.url)
|
|
||||||
case "sidecar": {
|
|
||||||
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
|
|
||||||
return Key.make("sidecar")
|
|
||||||
}
|
|
||||||
case "ssh":
|
|
||||||
return Key.make(`ssh:${conn.host}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Key = string & { _brand: "Key" }
|
|
||||||
export const Key = { make: (v: string) => v as Key }
|
|
||||||
|
|
||||||
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
|
|
||||||
export const local = (conn?: Any) =>
|
|
||||||
!!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function nextServerAfterRemoval(
|
|
||||||
servers: ServerConnection.Any[],
|
|
||||||
removed: ServerConnection.Key,
|
|
||||||
fallback: ServerConnection.Key,
|
|
||||||
) {
|
|
||||||
const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
|
|
||||||
const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
|
|
||||||
return next ? ServerConnection.key(next) : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Must be keyed for now
|
||||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
name: "Server",
|
name: "Server",
|
||||||
gate: true,
|
init: (props: { conn: ServerConnection.Any }) => {
|
||||||
init: (props: {
|
const conn = props.conn
|
||||||
defaultServer: ServerConnection.Key
|
const key = ServerConnection.key(conn)
|
||||||
canonicalLocalServer?: ServerConnection.Key
|
|
||||||
servers?: Array<ServerConnection.Any>
|
|
||||||
}) => {
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
|
||||||
{
|
|
||||||
...Persist.global("server", ["server.v3"]),
|
|
||||||
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
|
|
||||||
},
|
|
||||||
createStore({
|
|
||||||
list: [] as StoredServer[],
|
|
||||||
projects: {} as Record<string, StoredProject[]>,
|
|
||||||
lastProject: {} as Record<string, string>,
|
|
||||||
recentlyClosed: {} as Record<string, string[]>,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
const global = useGlobal()
|
||||||
|
|
||||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
|
||||||
return resolveServerList({ stored: store.list, props: props.servers })
|
|
||||||
})
|
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
|
||||||
active: props.defaultServer,
|
|
||||||
})
|
|
||||||
|
|
||||||
function setActive(input: ServerConnection.Key) {
|
|
||||||
if (state.active !== input) setState("active", input)
|
|
||||||
}
|
|
||||||
|
|
||||||
function add(input: ServerConnection.Http) {
|
|
||||||
const url_ = normalizeServerUrl(input.http.url)
|
|
||||||
if (!url_) return
|
|
||||||
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
|
|
||||||
return batch(() => {
|
|
||||||
const existing = store.list.findIndex((x) => url(x) === url_)
|
|
||||||
if (existing !== -1) {
|
|
||||||
setStore("list", existing, conn)
|
|
||||||
} else {
|
|
||||||
setStore("list", store.list.length, conn)
|
|
||||||
}
|
|
||||||
setState("active", ServerConnection.key(conn))
|
|
||||||
return conn
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove(key: ServerConnection.Key) {
|
|
||||||
const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
|
|
||||||
const list = store.list.filter((x) => url(x) !== key)
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", list)
|
|
||||||
if (state.active === key) setState("active", next)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function canRemove(key: ServerConnection.Key) {
|
|
||||||
return canRemoveServer({ key, provided: props.servers, stored: store.list })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isReady = Object.assign(
|
|
||||||
createMemo(() => ready() && !!state.active),
|
|
||||||
{ promise: ready.promise },
|
|
||||||
)
|
|
||||||
|
|
||||||
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
|
|
||||||
const projects = createServerProjects({ scope, store, setStore })
|
|
||||||
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
|
|
||||||
const projectsForServer = (key: ServerConnection.Key) => {
|
|
||||||
const existing = projectStores.get(key)
|
|
||||||
if (existing) return existing
|
|
||||||
const next = createServerProjects({ scope: () => scope(key), store, setStore })
|
|
||||||
projectStores.set(key, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
|
||||||
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
|
|
||||||
)
|
|
||||||
const isLocal = createMemo(() => ServerConnection.local(current()))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: isReady,
|
conn,
|
||||||
isLocal,
|
key,
|
||||||
get key() {
|
isLocal: ServerConnection.local(props.conn),
|
||||||
return state.active
|
ctx: global.ensureServerCtx(conn),
|
||||||
},
|
get health() {
|
||||||
get name() {
|
return global.servers.health[key]
|
||||||
return serverName(current())
|
|
||||||
},
|
|
||||||
get list() {
|
|
||||||
return allServers()
|
|
||||||
},
|
|
||||||
get current() {
|
|
||||||
return current()
|
|
||||||
},
|
|
||||||
setActive,
|
|
||||||
add,
|
|
||||||
remove,
|
|
||||||
canRemove,
|
|
||||||
scope,
|
|
||||||
projects: {
|
|
||||||
...projects,
|
|
||||||
forServer: projectsForServer,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { type Accessor, batch, createMemo } from "solid-js"
|
||||||
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
|
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||||
|
type ServerProjectState = {
|
||||||
|
projects: Record<string, StoredProject[]>
|
||||||
|
lastProject: Record<string, string>
|
||||||
|
recentlyClosed: Record<string, string[]>
|
||||||
|
}
|
||||||
|
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||||
|
// The store retains more history than is displayed. Consumers filter recently closed entries
|
||||||
|
// against the live project list (dropping deleted projects) and then cap the visible count via
|
||||||
|
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
|
||||||
|
// filtered out do not evict still-visible ones from the persisted store.
|
||||||
|
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
|
||||||
|
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
|
||||||
|
|
||||||
|
export function normalizeServerUrl(input: string) {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
|
||||||
|
return withProtocol.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
|
||||||
|
if (!conn) return ""
|
||||||
|
if (conn.displayName && !ignoreDisplayName) return conn.displayName
|
||||||
|
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalHost(url: string) {
|
||||||
|
const host = url.replace(/^https?:\/\//, "").split(":")[0]
|
||||||
|
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
|
||||||
|
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
|
||||||
|
if (!isRecord(value)) return value
|
||||||
|
const projects = isRecord(value.projects) ? value.projects : undefined
|
||||||
|
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
|
||||||
|
const previousProjects = projects?.[canonicalLocalServer]
|
||||||
|
const previousLastProject = lastProject?.[canonicalLocalServer]
|
||||||
|
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
|
||||||
|
|
||||||
|
const next = { ...value }
|
||||||
|
if (projects && Array.isArray(previousProjects)) {
|
||||||
|
const local = Array.isArray(projects.local) ? projects.local : []
|
||||||
|
const worktrees = new Set(
|
||||||
|
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
|
||||||
|
)
|
||||||
|
const migrated = previousProjects.filter((project) => {
|
||||||
|
if (!isRecord(project) || typeof project.worktree !== "string") return true
|
||||||
|
if (worktrees.has(project.worktree)) return false
|
||||||
|
worktrees.add(project.worktree)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
|
||||||
|
delete nextProjects[canonicalLocalServer]
|
||||||
|
next.projects = nextProjects
|
||||||
|
}
|
||||||
|
if (lastProject && typeof previousLastProject === "string") {
|
||||||
|
const nextLastProject = { ...lastProject }
|
||||||
|
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
|
||||||
|
delete nextLastProject[canonicalLocalServer]
|
||||||
|
next.lastProject = nextLastProject
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerProjects<T extends ServerProjectState>(input: {
|
||||||
|
scope: () => ServerScope
|
||||||
|
store: Store<T>
|
||||||
|
setStore: SetStoreFunction<T>
|
||||||
|
}) {
|
||||||
|
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
|
||||||
|
const current = () => input.store.projects[input.scope()] ?? []
|
||||||
|
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
|
||||||
|
const remove = (directory: string) => {
|
||||||
|
setStore(
|
||||||
|
"projects",
|
||||||
|
input.scope(),
|
||||||
|
current().filter((project) => project.worktree !== directory),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
list: current,
|
||||||
|
recentlyClosed: currentClosed,
|
||||||
|
remove,
|
||||||
|
open(directory: string) {
|
||||||
|
const scope = input.scope()
|
||||||
|
const key = pathKey(directory)
|
||||||
|
const closed = currentClosed()
|
||||||
|
if (closed.some((worktree) => pathKey(worktree) === key)) {
|
||||||
|
setStore(
|
||||||
|
"recentlyClosed",
|
||||||
|
scope,
|
||||||
|
closed.filter((worktree) => pathKey(worktree) !== key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (current().some((project) => project.worktree === directory)) return
|
||||||
|
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
|
||||||
|
},
|
||||||
|
// User-initiated close: removes the project and records it in recently closed.
|
||||||
|
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
|
||||||
|
close(directory: string) {
|
||||||
|
remove(directory)
|
||||||
|
const key = pathKey(directory)
|
||||||
|
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
|
||||||
|
0,
|
||||||
|
RECENTLY_CLOSED_HISTORY_LIMIT,
|
||||||
|
)
|
||||||
|
setStore("recentlyClosed", input.scope(), closed)
|
||||||
|
},
|
||||||
|
expand(directory: string) {
|
||||||
|
const index = current().findIndex((project) => project.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
|
||||||
|
},
|
||||||
|
collapse(directory: string) {
|
||||||
|
const index = current().findIndex((project) => project.worktree === directory)
|
||||||
|
if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
|
||||||
|
},
|
||||||
|
move(directory: string, toIndex: number) {
|
||||||
|
const fromIndex = current().findIndex((project) => project.worktree === directory)
|
||||||
|
if (fromIndex === -1 || fromIndex === toIndex) return
|
||||||
|
const next = [...current()]
|
||||||
|
const [item] = next.splice(fromIndex, 1)
|
||||||
|
next.splice(toIndex, 0, item)
|
||||||
|
setStore("projects", input.scope(), next)
|
||||||
|
},
|
||||||
|
last() {
|
||||||
|
return input.store.lastProject[input.scope()]
|
||||||
|
},
|
||||||
|
touch(directory: string) {
|
||||||
|
setStore("lastProject", input.scope(), directory)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveServerList(input: {
|
||||||
|
props?: Array<ServerConnection.Any>
|
||||||
|
stored: StoredServer[]
|
||||||
|
}): Array<ServerConnection.Any> {
|
||||||
|
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
|
||||||
|
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const value of input.stored) {
|
||||||
|
const conn: ServerConnection.Http =
|
||||||
|
typeof value === "string"
|
||||||
|
? {
|
||||||
|
type: "http" as const,
|
||||||
|
http: { url: value },
|
||||||
|
}
|
||||||
|
: "http" in value
|
||||||
|
? value
|
||||||
|
: { type: "http", http: value }
|
||||||
|
const key = ServerConnection.key(conn)
|
||||||
|
|
||||||
|
const existing = deduped.get(key)
|
||||||
|
if (existing)
|
||||||
|
deduped.set(key, {
|
||||||
|
...existing,
|
||||||
|
...conn,
|
||||||
|
http: { ...existing.http, ...conn.http },
|
||||||
|
})
|
||||||
|
else deduped.set(key, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...deduped.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRemoveServer(input: {
|
||||||
|
key: ServerConnection.Key
|
||||||
|
provided?: Array<ServerConnection.Any>
|
||||||
|
stored: StoredServer[]
|
||||||
|
}) {
|
||||||
|
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
|
||||||
|
return input.stored.some((server) =>
|
||||||
|
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ServerConnection {
|
||||||
|
type Base = { displayName?: string; label?: string }
|
||||||
|
|
||||||
|
export type HttpBase = {
|
||||||
|
url: string
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular web connections
|
||||||
|
export type Http = {
|
||||||
|
type: "http"
|
||||||
|
http: HttpBase
|
||||||
|
authToken?: boolean
|
||||||
|
} & Base
|
||||||
|
|
||||||
|
export type Sidecar = {
|
||||||
|
type: "sidecar"
|
||||||
|
http: HttpBase
|
||||||
|
} & (
|
||||||
|
| // Regular desktop server
|
||||||
|
{ variant: "base" }
|
||||||
|
// WSL server (windows only)
|
||||||
|
| {
|
||||||
|
variant: "wsl"
|
||||||
|
distro: string
|
||||||
|
}
|
||||||
|
) &
|
||||||
|
Base
|
||||||
|
|
||||||
|
// Remote server desktop can SSH into
|
||||||
|
export type Ssh = {
|
||||||
|
type: "ssh"
|
||||||
|
host: string
|
||||||
|
// SSH client exposes an HTTP server for the app to use as a proxy
|
||||||
|
http: HttpBase
|
||||||
|
} & Base
|
||||||
|
|
||||||
|
export type Any =
|
||||||
|
| Http
|
||||||
|
// All these are desktop-only
|
||||||
|
| (Sidecar | Ssh)
|
||||||
|
|
||||||
|
export const key = (conn: Any): Key => {
|
||||||
|
switch (conn.type) {
|
||||||
|
case "http":
|
||||||
|
return Key.make(conn.http.url)
|
||||||
|
case "sidecar": {
|
||||||
|
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
|
||||||
|
return Key.make("sidecar")
|
||||||
|
}
|
||||||
|
case "ssh":
|
||||||
|
return Key.make(`ssh:${conn.host}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Key = string & { _brand: "Key" }
|
||||||
|
export const Key = { make: (v: string) => v as Key }
|
||||||
|
|
||||||
|
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
|
||||||
|
export const local = (conn?: Any) =>
|
||||||
|
!!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextServerAfterRemoval(
|
||||||
|
servers: ServerConnection.Any[],
|
||||||
|
removed: ServerConnection.Key,
|
||||||
|
fallback: ServerConnection.Key,
|
||||||
|
) {
|
||||||
|
const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
|
||||||
|
const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
|
||||||
|
return next ? ServerConnection.key(next) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
|
||||||
|
name: "Server",
|
||||||
|
gate: true,
|
||||||
|
init: (props: {
|
||||||
|
defaultServer: ServerConnection.Key
|
||||||
|
canonicalLocalServer?: ServerConnection.Key
|
||||||
|
servers?: Array<ServerConnection.Any>
|
||||||
|
}) => {
|
||||||
|
const [store, setStore, _] = persisted(
|
||||||
|
{
|
||||||
|
...Persist.global("server", ["server.v3"]),
|
||||||
|
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
|
||||||
|
},
|
||||||
|
createStore({
|
||||||
|
list: [] as StoredServer[],
|
||||||
|
projects: {} as Record<string, StoredProject[]>,
|
||||||
|
lastProject: {} as Record<string, string>,
|
||||||
|
recentlyClosed: {} as Record<string, string[]>,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
||||||
|
|
||||||
|
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||||
|
return resolveServerList({ stored: store.list, props: props.servers })
|
||||||
|
})
|
||||||
|
|
||||||
|
function add(input: ServerConnection.Http) {
|
||||||
|
const url_ = normalizeServerUrl(input.http.url)
|
||||||
|
if (!url_) return
|
||||||
|
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
|
||||||
|
return batch(() => {
|
||||||
|
const existing = store.list.findIndex((x) => url(x) === url_)
|
||||||
|
if (existing !== -1) {
|
||||||
|
setStore("list", existing, conn)
|
||||||
|
} else {
|
||||||
|
setStore("list", store.list.length, conn)
|
||||||
|
}
|
||||||
|
return conn
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(key: ServerConnection.Key) {
|
||||||
|
const list = store.list.filter((x) => url(x) !== key)
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", list)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRemove(key: ServerConnection.Key) {
|
||||||
|
return canRemoveServer({ key, provided: props.servers, stored: store.list })
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = (key: ServerConnection.Key) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
|
||||||
|
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
|
||||||
|
const projectsForServer = (key: ServerConnection.Key) => {
|
||||||
|
const existing = projectStores.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
const next = createServerProjects({ scope: () => scope(key), store, setStore })
|
||||||
|
projectStores.set(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get list() {
|
||||||
|
return allServers()
|
||||||
|
},
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
canRemove,
|
||||||
|
scope,
|
||||||
|
projects: {
|
||||||
|
forServer: projectsForServer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -2,6 +2,10 @@ import { createStore, reconcile } from "solid-js/store"
|
|||||||
import { createEffect, createMemo } from "solid-js"
|
import { createEffect, createMemo } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||||
|
export type WorkspaceLastUsed = "local" | "workspace"
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -44,6 +48,10 @@ export interface Settings {
|
|||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: boolean
|
autoApprove: boolean
|
||||||
}
|
}
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: WorkspaceDefaultDestination
|
||||||
|
lastUsed: Record<string, WorkspaceLastUsed>
|
||||||
|
}
|
||||||
notifications: NotificationSettings
|
notifications: NotificationSettings
|
||||||
sounds: SoundSettings
|
sounds: SoundSettings
|
||||||
}
|
}
|
||||||
@@ -126,6 +134,10 @@ const defaultSettings: Settings = {
|
|||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: false,
|
autoApprove: false,
|
||||||
},
|
},
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: "last-used",
|
||||||
|
lastUsed: {},
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: true,
|
agent: true,
|
||||||
permissions: true,
|
permissions: true,
|
||||||
@@ -291,6 +303,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("permissions", "autoApprove", value)
|
setStore("permissions", "autoApprove", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: withFallback(
|
||||||
|
() => store.workspaces?.defaultDestination,
|
||||||
|
defaultSettings.workspaces.defaultDestination,
|
||||||
|
),
|
||||||
|
setDefaultDestination(value: WorkspaceDefaultDestination) {
|
||||||
|
setStore("workspaces", (current) => ({
|
||||||
|
...defaultSettings.workspaces,
|
||||||
|
...current,
|
||||||
|
defaultDestination: value,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
lastUsed(scope: ServerScope, projectID: string) {
|
||||||
|
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
|
||||||
|
},
|
||||||
|
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
|
||||||
|
setStore("workspaces", (current) => ({
|
||||||
|
...defaultSettings.workspaces,
|
||||||
|
...current,
|
||||||
|
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||||
setAgent(value: boolean) {
|
setAgent(value: boolean) {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const useSync = () => {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
||||||
return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory))
|
return createMemo(() => serverSync.ensureDirSyncContext(sdk().directory))
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ServerConnection } from "./server"
|
import type { ServerConnection } from "./servers"
|
||||||
import type { Tab } from "./tabs"
|
import type { Tab } from "./tabs"
|
||||||
|
|
||||||
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
|
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createTabMemory } from "./tab-memory"
|
|||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
import type { SessionTab, Tab } from "./tabs"
|
import type { SessionTab, Tab } from "./tabs"
|
||||||
import { migrateTabs } from "./tab-migration"
|
import { migrateTabs } from "./tab-migration"
|
||||||
import type { ServerConnection } from "./server"
|
import type { ServerConnection } from "./servers"
|
||||||
|
|
||||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServers } from "./servers"
|
||||||
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
|
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
@@ -13,6 +13,7 @@ import { createTabMemory } from "./tab-memory"
|
|||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||||
import { migrateTabs } from "./tab-migration"
|
import { migrateTabs } from "./tab-migration"
|
||||||
|
import { useCurrentRoute } from "./layout"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
@@ -54,13 +55,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
name: "Tabs",
|
name: "Tabs",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const server = useServer()
|
const servers = useServers()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const fallback = server.key
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.window("tabs"),
|
...Persist.window("tabs"),
|
||||||
migrate: (value: unknown) => migrateTabs(value, fallback),
|
migrate: (value: unknown) => migrateTabs(value, ServerConnection.key(servers.list[0])),
|
||||||
},
|
},
|
||||||
createStore<Tab[]>([]),
|
createStore<Tab[]>([]),
|
||||||
)
|
)
|
||||||
@@ -72,6 +72,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const memory = createTabMemory(getOwner())
|
const memory = createTabMemory(getOwner())
|
||||||
|
const currentRoute = useCurrentRoute()
|
||||||
|
|
||||||
const closing = new Set<string>()
|
const closing = new Set<string>()
|
||||||
let recentWrite = 0
|
let recentWrite = 0
|
||||||
@@ -120,11 +121,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !recentReady()) return
|
if (!ready() || !recentReady()) return
|
||||||
const servers = new Set(server.list.map(ServerConnection.key))
|
const serversSet = new Set(servers.list.map(ServerConnection.key))
|
||||||
const next = store.filter((tab) => servers.has(tab.server))
|
const next = store.filter((tab) => serversSet.has(tab.server))
|
||||||
if (next.length !== store.length) {
|
if (next.length !== store.length) {
|
||||||
for (const tab of store) {
|
for (const tab of store) {
|
||||||
if (!servers.has(tab.server)) {
|
if (!serversSet.has(tab.server)) {
|
||||||
const key = tabKey(tab)
|
const key = tabKey(tab)
|
||||||
memory.remove(key)
|
memory.remove(key)
|
||||||
removeInfo(key)
|
removeInfo(key)
|
||||||
@@ -141,8 +142,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!closedReady()) return
|
if (!closedReady()) return
|
||||||
const servers = new Set(server.list.map(ServerConnection.key))
|
const serversSet = new Set(servers.list.map(ServerConnection.key))
|
||||||
const next = closed.filter((entry) => servers.has(entry.tab.server))
|
const next = closed.filter((entry) => serversSet.has(entry.tab.server))
|
||||||
if (next.length !== closed.length) setClosed(() => next)
|
if (next.length !== closed.length) setClosed(() => next)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -293,10 +294,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
for (const key of removed) removeInfo(key)
|
for (const key of removed) removeInfo(key)
|
||||||
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
|
||||||
for (const draftID of drafts) removeDraftPersisted(draftID)
|
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||||
if (server.key === key) navigate("/")
|
|
||||||
},
|
},
|
||||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||||
const targetServer = input.server ?? server.key
|
const targetServer = input.server
|
||||||
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
|
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
|
||||||
const removed = store
|
const removed = store
|
||||||
.filter(
|
.filter(
|
||||||
@@ -307,17 +307,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
const sessionIDs = new Set(input.sessionIDs)
|
const sessionIDs = new Set(input.sessionIDs)
|
||||||
const currentHref =
|
const route = currentRoute()
|
||||||
targetServer === server.key && params.dir && params.id
|
const sessionRoute = route.type === "session" ? route : undefined
|
||||||
? tabHref({
|
const currentIndex = sessionRoute
|
||||||
type: "session",
|
|
||||||
server: targetServer,
|
|
||||||
sessionId: params.id,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
const currentIndex = currentHref
|
|
||||||
? tabs.findIndex(
|
? tabs.findIndex(
|
||||||
(tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref,
|
(tab) =>
|
||||||
|
tab.type === "session" &&
|
||||||
|
tab.server === sessionRoute.server &&
|
||||||
|
tab.sessionId === sessionRoute.sessionId,
|
||||||
)
|
)
|
||||||
: -1
|
: -1
|
||||||
const currentTab = tabs[currentIndex]
|
const currentTab = tabs[currentIndex]
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const cache = new Map<string, TerminalCacheEntry>()
|
const cache = new Map<string, TerminalCacheEntry>()
|
||||||
const scope = () => serverSDK().scope
|
const scope = () => serverSDK.scope
|
||||||
const directory = createMemo(() => base64Encode(sdk().directory))
|
const directory = createMemo(() => base64Encode(sdk().directory))
|
||||||
|
|
||||||
caches.add(cache)
|
caches.add(cache)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { dict as en } from "@/i18n/en"
|
|||||||
import { dict as zh } from "@/i18n/zh"
|
import { dict as zh } from "@/i18n/zh"
|
||||||
import { authFromToken } from "@/utils/server"
|
import { authFromToken } from "@/utils/server"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { ServerConnection } from "./context/server"
|
import { ServerConnection } from "./context/servers"
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
|
|
||||||
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
|
export const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
|
||||||
|
|
||||||
type DirectoryCatalog = {
|
type DirectoryCatalog = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export function useIntegrations(directory: Accessor<string | undefined>) {
|
|||||||
const query = createQuery(() => {
|
const query = createQuery(() => {
|
||||||
const value = directory()
|
const value = directory()
|
||||||
return {
|
return {
|
||||||
...serverSync().queryOptions.integrations(value ? pathKey(value) : null),
|
...serverSync.queryOptions.integrations(value ? pathKey(value) : null),
|
||||||
enabled: serverSDK().connection.status() === "connected",
|
enabled: serverSDK.connection.status() === "connected",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useServerSync } from "@/context/server-sync"
|
import { useQueryOptions } from "@/context/server-sync"
|
||||||
import { Iterable, pipe } from "effect"
|
import { Iterable, pipe } from "effect"
|
||||||
import { createEffect, createMemo, type Accessor } from "solid-js"
|
import { type Accessor } from "solid-js"
|
||||||
import { selectProviderCatalog } from "./provider-catalog"
|
import { emptyProviderCatalog } from "./provider-catalog"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
export const popularProviders = [
|
export const popularProviders = [
|
||||||
"opencode",
|
"opencode",
|
||||||
@@ -16,31 +18,16 @@ export const popularProviders = [
|
|||||||
const popularProviderSet = new Set(popularProviders)
|
const popularProviderSet = new Set(popularProviders)
|
||||||
|
|
||||||
export function useProviders(directory: Accessor<string | undefined>) {
|
export function useProviders(directory: Accessor<string | undefined>) {
|
||||||
const serverSync = useServerSync()
|
const providersQuery = useQuery(() => {
|
||||||
const dir = directory
|
const queryOpts = useQueryOptions()
|
||||||
const providers = () => {
|
const dir = directory()
|
||||||
const value = dir()
|
return queryOpts.providers(dir ? pathKey(dir) : null)
|
||||||
const projectStore = value ? serverSync().child(value)[0] : undefined
|
})
|
||||||
if (value)
|
|
||||||
return selectProviderCatalog({
|
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
||||||
explicit: true,
|
|
||||||
directory: value,
|
|
||||||
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
|
|
||||||
})
|
|
||||||
return selectProviderCatalog({
|
|
||||||
explicit: false,
|
|
||||||
directory: value,
|
|
||||||
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
|
|
||||||
global: serverSync().data.provider,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => {
|
ready: () => providersQuery.isSuccess,
|
||||||
const value = dir()
|
|
||||||
if (!value) return false
|
|
||||||
return serverSync().child(value)[0].provider_ready
|
|
||||||
},
|
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
default: () => providers().default,
|
default: () => providers().default,
|
||||||
popular: () =>
|
popular: () =>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe("desktop native translations", () => {
|
|||||||
"Українська",
|
"Українська",
|
||||||
"Bosanski",
|
"Bosanski",
|
||||||
"العربية",
|
"العربية",
|
||||||
|
"עברית",
|
||||||
"Norsk",
|
"Norsk",
|
||||||
"Português (Brasil)",
|
"Português (Brasil)",
|
||||||
"ไทย",
|
"ไทย",
|
||||||
@@ -133,6 +134,11 @@ describe("desktop native locale detection", () => {
|
|||||||
expect(detectDesktopNativeLocale(["nb-NO"])).toBe("no")
|
expect(detectDesktopNativeLocale(["nb-NO"])).toBe("no")
|
||||||
expect(detectDesktopNativeLocale(["nn-NO"])).toBe("no")
|
expect(detectDesktopNativeLocale(["nn-NO"])).toBe("no")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("recognizes Hebrew language tags", () => {
|
||||||
|
expect(detectDesktopNativeLocale(["he"])).toBe("he")
|
||||||
|
expect(detectDesktopNativeLocale(["he-IL"])).toBe("he")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("desktop native ICU data", () => {
|
describe("desktop native ICU data", () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const DESKTOP_NATIVE_LOCALES = [
|
|||||||
"uk",
|
"uk",
|
||||||
"bs",
|
"bs",
|
||||||
"ar",
|
"ar",
|
||||||
|
"he",
|
||||||
"no",
|
"no",
|
||||||
"br",
|
"br",
|
||||||
"th",
|
"th",
|
||||||
@@ -80,6 +81,7 @@ export const DESKTOP_NATIVE_LABELS: Record<DesktopNativeLocale, string> = {
|
|||||||
uk: "Українська",
|
uk: "Українська",
|
||||||
bs: "Bosanski",
|
bs: "Bosanski",
|
||||||
ar: "العربية",
|
ar: "العربية",
|
||||||
|
he: "עברית",
|
||||||
no: "Norsk",
|
no: "Norsk",
|
||||||
br: "Português (Brasil)",
|
br: "Português (Brasil)",
|
||||||
th: "ไทย",
|
th: "ไทย",
|
||||||
@@ -145,6 +147,7 @@ export const DESKTOP_NATIVE_LOCALE_TAGS: Record<DesktopNativeLocale, string> = {
|
|||||||
uk: "uk",
|
uk: "uk",
|
||||||
bs: "bs",
|
bs: "bs",
|
||||||
ar: "ar",
|
ar: "ar",
|
||||||
|
he: "he-IL",
|
||||||
no: "nb-NO",
|
no: "nb-NO",
|
||||||
br: "pt-BR",
|
br: "pt-BR",
|
||||||
th: "th",
|
th: "th",
|
||||||
@@ -202,7 +205,7 @@ export function detectDesktopNativeLocale(languages: readonly string[]): Desktop
|
|||||||
if (["no", "nb", "nn"].includes(source.language)) return "no"
|
if (["no", "nb", "nn"].includes(source.language)) return "no"
|
||||||
const match = DESKTOP_NATIVE_LOCALES.find((candidate) => {
|
const match = DESKTOP_NATIVE_LOCALES.find((candidate) => {
|
||||||
const target = locale(DESKTOP_NATIVE_LOCALE_TAGS[candidate])
|
const target = locale(DESKTOP_NATIVE_LOCALE_TAGS[candidate])
|
||||||
return target?.language === source.language && target.script === source.script
|
return target?.language === source.language && normalizeScript(target.script) === normalizeScript(source.script)
|
||||||
})
|
})
|
||||||
if (match) return match
|
if (match) return match
|
||||||
}
|
}
|
||||||
@@ -221,6 +224,9 @@ function locale(value: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bun's ICU currently reports Punjabi's Arabic script as the invalid "Aran" code.
|
||||||
|
const normalizeScript = (script?: string) => (script === "Aran" ? "Arab" : script)
|
||||||
|
|
||||||
export const DESKTOP_NATIVE_ENGLISH = {
|
export const DESKTOP_NATIVE_ENGLISH = {
|
||||||
"desktop.menu.app": "OpenCode",
|
"desktop.menu.app": "OpenCode",
|
||||||
"desktop.menu.file": "File",
|
"desktop.menu.file": "File",
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ export const dict = {
|
|||||||
|
|
||||||
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
|
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
|
||||||
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
||||||
|
"toast.migration.failed.title": "Data migration failed",
|
||||||
|
|
||||||
"toast.update.title": "Update available",
|
"toast.update.title": "Update available",
|
||||||
"toast.update.description": "A new version of OpenCode ({{version}}) is now available to install.",
|
"toast.update.description": "A new version of OpenCode ({{version}}) is now available to install.",
|
||||||
@@ -1166,6 +1167,47 @@ export const dict = {
|
|||||||
"session.delete.button": "Delete session",
|
"session.delete.button": "Delete session",
|
||||||
|
|
||||||
"workspace.new": "New workspace",
|
"workspace.new": "New workspace",
|
||||||
|
"common.viewAll": "View all",
|
||||||
|
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||||
|
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||||
|
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||||
|
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||||
|
"session.new.workspace.search.placeholder": "Search workspaces",
|
||||||
|
"settings.tab.workspaces": "Workspaces",
|
||||||
|
"settings.workspaces.filter.all": "All projects",
|
||||||
|
"settings.workspaces.empty": "No workspaces",
|
||||||
|
"settings.workspaces.count.one": "{{count}} workspace",
|
||||||
|
"settings.workspaces.count.other": "{{count}} workspaces",
|
||||||
|
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||||
|
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||||
|
"settings.workspaces.lastActiveSession": "Last active session",
|
||||||
|
"settings.workspaces.deleteAll": "Delete all workspaces",
|
||||||
|
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
|
||||||
|
"settings.workspaces.delete.warning":
|
||||||
|
"The workspace directory and branch will be permanently removed, including any unmerged changes shown below.",
|
||||||
|
"settings.workspaces.deleteAll.warning":
|
||||||
|
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||||
|
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
|
||||||
|
"settings.workspaces.delete.blocked.linked":
|
||||||
|
"Linked sessions will remain, but their working directory will be permanently removed.",
|
||||||
|
"settings.workspaces.default.title": "Default environment",
|
||||||
|
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||||
|
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||||
|
"settings.workspaces.default.local": "Local directory",
|
||||||
|
"settings.workspaces.default.new": "New workspace",
|
||||||
|
"workspace.move.title": "Move to workspace",
|
||||||
|
"workspace.move.menu.title": "Move session to",
|
||||||
|
"workspace.move.failed": "Failed to move session",
|
||||||
|
"workspace.lifecycle.creating": "Creating workspace",
|
||||||
|
"workspace.lifecycle.created": "Workspace created",
|
||||||
|
"workspace.lifecycle.starting": "Starting session",
|
||||||
|
"workspace.onboarding.title": "Isolate sessions with workspaces",
|
||||||
|
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||||
|
"workspace.lifecycle.moving": "Moving to workspace",
|
||||||
|
"workspace.lifecycle.set": "Workspace set",
|
||||||
|
"session.summary.title": "Session details",
|
||||||
|
"session.summary.noBranch": "No branch",
|
||||||
|
"session.summary.basedOn": "Based on {{branch}}",
|
||||||
"workspace.type.local": "local",
|
"workspace.type.local": "local",
|
||||||
"workspace.type.sandbox": "sandbox",
|
"workspace.type.sandbox": "sandbox",
|
||||||
"workspace.create.failed.title": "Failed to create workspace",
|
"workspace.create.failed.title": "Failed to create workspace",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user